Merge branch 'main' into icon

This commit is contained in:
Damontecres 2025-10-15 15:12:20 -04:00
commit bf4dfe4429
No known key found for this signature in database
62 changed files with 1725 additions and 1131 deletions

24
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,24 @@
# Contributing to Dolphin
We appreciate your interest in contributing to Dolphin!
## Code of Conduct
We follow Jellyfin's example by adhering to their [Code of Conduct](https://jellyfin.org/docs/general/community-standards/#code-of-conduct).
## How to contribute
You can contribute in several ways:
* File bug reports
* Request new features
* Propose code changes via pull request
In all cases, be sure to fully explain the issue.
### Code change proposals
You acknowledge that, if applicable, submitting and subsequent acceptance of any pull request you, the code contributor of the pull request, agree and acknowledge that the code will be licensed under [GPLv2](LICENSE).
Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features.
See the [developer's guide](./DEVELOPMENT.md) for more information.

26
DEVELOPMENT.md Normal file
View file

@ -0,0 +1,26 @@
# Dolphin developer's guide
See also the [Contributing](CONTRIBUTING.md) guide for general information on contributing to the project.
## Overview
This project is an Android TV client for Jellyfin. It is written in Kotlin and uses the official [Jellyfin Kotlin SDK](https://github.com/jellyfin/jellyfin-sdk-kotlin) to interact with the server.
The app is a single Activity (`MainActivity`) with MVVM architecture.
The app uses:
* [Compose](https://developer.android.com/jetpack/compose) for the UI
* [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) for navigating app screen
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
* [Coil](https://coil-kt.github.io/coil/) for image loading
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
## Getting started
We follow GitHub's fork & pull request model for contributions.
After forking and cloning your fork, you can import the project into Android Studio.
You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).

View file

@ -72,6 +72,7 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
@ -227,4 +228,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs)
} }

View file

@ -41,6 +41,16 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin package com.github.damontecres.dolphin
import android.app.Application import android.app.Application
import android.util.Log
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber import timber.log.Timber
@ -10,8 +11,25 @@ class DolphinApplication : Application() {
instance = this instance = this
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
// TODO minimal logging for release builds?
Timber.plant(Timber.DebugTree()) 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)
}
},
)
} }
} }

View file

@ -55,9 +55,17 @@ interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun addServer(server: JellyfinServer): Long fun addServer(server: JellyfinServer): Long
@Update(onConflict = OnConflictStrategy.ABORT) @Update
fun updateServer(server: JellyfinServer): Int fun updateServer(server: JellyfinServer): Int
@Transaction
fun addOrUpdateServer(server: JellyfinServer) {
val result = addServer(server)
if (result == -1L) {
updateServer(server)
}
}
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(user: JellyfinUser) fun addUser(user: JellyfinUser)

View file

@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
@ -42,7 +43,7 @@ class ServerRepository
*/ */
suspend fun addAndChangeServer(server: JellyfinServer) { suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
serverDao.addServer(server) serverDao.addOrUpdateServer(server)
} }
changeServer(server) changeServer(server)
} }
@ -74,14 +75,15 @@ class ServerRepository
apiClient.userApi apiClient.userApi
.getCurrentUser() .getCurrentUser()
.content .content
val sysInfo by apiClient.systemApi.getSystemInfo()
val updatedServer = server.copy(name = userDto.serverName) val updatedServer = server.copy(name = sysInfo.serverName)
val updatedUser = val updatedUser =
user.copy( user.copy(
id = userDto.id.toString(), id = userDto.id.toString(),
name = userDto.name, name = userDto.name,
) )
serverDao.addServer(updatedServer) serverDao.addOrUpdateServer(updatedServer)
serverDao.addUser(updatedUser) serverDao.addUser(updatedUser)
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
it it

View file

@ -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.detail.series.SeasonEpisode
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -24,7 +25,11 @@ data class BaseItem(
@Transient val name = data.name @Transient val name = data.name
@Transient @Transient
val indexNumber = data.indexNumber val title = if (type == BaseItemKind.EPISODE) data.seriesName else name
@Transient
val subtitle =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient @Transient
val resumeMs = val resumeMs =
@ -33,12 +38,23 @@ data class BaseItem(
?.ticks ?.ticks
?.inWholeMilliseconds ?.inWholeMilliseconds
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()
private fun dateAsIndex(): Int? =
data.premiereDate
?.let {
it.year.toString() +
it.monthValue.toString().padStart(2, '0') +
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
fun destination(): Destination { fun destination(): Destination {
val result = val result =
// Redirect episodes & seasons to their series if possible // Redirect episodes & seasons to their series if possible
when (type) { when (type) {
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
data.indexNumber?.let { episode -> indexNumber?.let { episode ->
data.parentIndexNumber?.let { season -> data.parentIndexNumber?.let { season ->
Destination.SeriesOverview( Destination.SeriesOverview(
data.seriesId!!, data.seriesId!!,

View file

@ -1,29 +0,0 @@
package com.github.damontecres.dolphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
sealed interface DolphinModel {
val id: UUID
val name: String?
val type: BaseItemKind
val imageUrl: String?
}
fun convertModel(
dto: BaseItemDto,
api: ApiClient,
): DolphinModel =
when (dto.type) {
BaseItemKind.COLLECTION_FOLDER -> Library.fromDto(dto, api)
// TODO
BaseItemKind.VIDEO -> Video.fromDto(dto, api)
BaseItemKind.SERIES -> Video.fromDto(dto, api)
BaseItemKind.MOVIE -> Video.fromDto(dto, api)
BaseItemKind.SEASON -> Video.fromDto(dto, api)
BaseItemKind.EPISODE -> Video.fromDto(dto, api)
else -> throw IllegalArgumentException("Unsupported item type: ${dto.type}")
}

View file

@ -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,
)
}
}

View file

@ -0,0 +1,95 @@
package com.github.damontecres.dolphin.data.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
import com.github.damontecres.dolphin.util.GetPlaylistItemsRequestHandler
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
class Playlist(
items: List<BaseItem>,
startIndex: Int = 0,
) {
val items = items.subList(startIndex, items.size)
var index by mutableIntStateOf(0)
fun hasPrevious(): Boolean = index > 0
fun hasNext(): Boolean = index < items.size
fun getPreviousAndReverse(): BaseItem = items[--index]
fun getAndAdvance(): BaseItem = items[++index]
fun peek(): BaseItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size)
fun advanceTo(id: UUID): BaseItem? {
while (hasNext()) {
val potential = getAndAdvance()
if (potential.id == id) {
return potential
}
}
return null
}
companion object {
const val MAX_SIZE = 100
}
}
@Singleton
class PlaylistCreator
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
): Playlist {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
shuffled: Boolean,
): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.from(it, api) }
if (shuffled) {
baseItems = baseItems.shuffled()
}
return Playlist(baseItems, 0)
}
}

View file

@ -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),
)
}
}

View file

@ -164,6 +164,18 @@ sealed interface AppPreference<T> {
summarizer = { value -> value?.toString() }, 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 = val RewatchNextUp =
AppSwitchPreference( AppSwitchPreference(
title = R.string.rewatch_next_up, title = R.string.rewatch_next_up,
@ -439,6 +451,13 @@ sealed interface AppPreference<T> {
indexToValue = { SkipSegmentBehavior.forNumber(it) }, indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { it.number },
) )
val ClearImageCache =
AppClickablePreference(
title = R.string.clear_image_cache,
getter = { },
setter = { prefs, _ -> prefs },
)
} }
} }
@ -450,6 +469,7 @@ val basicPreferences =
listOf( listOf(
AppPreference.HomePageItems, AppPreference.HomePageItems,
AppPreference.RewatchNextUp, AppPreference.RewatchNextUp,
AppPreference.CombineContinueNext,
AppPreference.PlayThemeMusic, AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab, AppPreference.RememberSelectedTab,
AppPreference.ThemeColors, AppPreference.ThemeColors,
@ -513,6 +533,7 @@ val advancedPreferences =
title = R.string.more, title = R.string.more,
preferences = preferences =
listOf( listOf(
AppPreference.ClearImageCache,
AppPreference.OssLicenseInfo, AppPreference.OssLicenseInfo,
), ),
), ),

View file

@ -49,6 +49,7 @@ class AppPreferencesSerializer
.apply { .apply {
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
}.build() }.build()
interfacePreferences = interfacePreferences =
InterfacePreferences InterfacePreferences

View file

@ -38,6 +38,7 @@ val DefaultItemFields =
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.TRICKPLAY, ItemFields.TRICKPLAY,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.CHAPTERS,
) )
val DefaultButtonPadding = val DefaultButtonPadding =

View file

@ -98,7 +98,7 @@ fun GridCard(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Text( Text(
text = item?.name ?: "", text = item?.title ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -109,7 +109,7 @@ fun GridCard(
.enableMarquee(focusedAfterDelay), .enableMarquee(focusedAfterDelay),
) )
Text( Text(
text = item?.data?.productionYear?.toString() ?: "", text = item?.subtitle ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =

View file

@ -5,22 +5,16 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -29,134 +23,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.dp
import androidx.compose.ui.unit.sp 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.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.R 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.Cards
import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.logCoilError 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 @Composable
fun ItemCardImage( fun ItemCardImage(

View file

@ -29,21 +29,14 @@ fun ItemRow(
items: List<BaseItem?>, items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
cardContent: @Composable ( cardContent: @Composable (
index: Int, index: Int,
item: BaseItem?, item: BaseItem?,
modifier: Modifier, modifier: Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) -> Unit = { index, item, mod, onClick, onLongClick -> ) -> Unit,
ItemCard( modifier: Modifier = Modifier,
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
)
},
focusPair: FocusPair? = null, focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
) { ) {

View file

@ -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...",
)
}
}
}

View file

@ -41,6 +41,7 @@ fun SeasonCard(
imageHeight: Dp = Dp.Unspecified, imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
) { ) {
val dto = item?.data val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
@ -89,7 +90,7 @@ fun SeasonCard(
ItemCardImage( ItemCardImage(
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
name = item?.name, name = item?.name,
showOverlay = false, showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false, favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false, watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
@ -109,7 +110,7 @@ fun SeasonCard(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Text( Text(
text = dto?.name ?: "", text = item?.title ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =
@ -119,7 +120,7 @@ fun SeasonCard(
.enableMarquee(focusedAfterDelay), .enableMarquee(focusedAfterDelay),
) )
Text( Text(
text = item?.data?.productionYear?.toString() ?: "", text = item?.subtitle ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =

View file

@ -27,7 +27,6 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
@ -63,7 +62,7 @@ class CollectionFolderViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
) : ItemViewModel<Library>(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf()) val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>() val sortAndDirection = MutableLiveData<SortAndDirection>()
@ -72,6 +71,7 @@ class CollectionFolderViewModel
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
recursive: Boolean,
): Job = ): Job =
viewModelScope.launch( viewModelScope.launch(
LoadingExceptionHandler( LoadingExceptionHandler(
@ -80,10 +80,13 @@ class CollectionFolderViewModel
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
fetchItem(itemId, potential) fetchItem(itemId, potential)
loadResults(sortAndDirection) loadResults(sortAndDirection, recursive)
} }
fun loadResults(sortAndDirection: SortAndDirection) { fun loadResults(
sortAndDirection: SortAndDirection,
recursive: Boolean,
) {
item.value?.let { item -> item.value?.let { item ->
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -96,28 +99,47 @@ class CollectionFolderViewModel
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) 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() else -> listOf()
} }
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = item.id, parentId = item.id,
isSeries = true,
mediaTypes = null,
// recursive = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = listOf(item.id),
sortBy = sortBy =
listOf( listOf(
sortAndDirection.sort, sortAndDirection.sort,
ItemSortBy.SORT_NAME, ItemSortBy.SORT_NAME,
ItemSortBy.PRODUCTION_YEAR, ItemSortBy.PRODUCTION_YEAR,
), ),
sortOrder = listOf(sortAndDirection.direction), sortOrder =
listOf(
sortAndDirection.direction,
SortOrder.ASCENDING,
SortOrder.ASCENDING,
),
fields = DefaultItemFields, fields = DefaultItemFields,
) )
val newPager = val newPager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager.init() newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0) if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -145,6 +167,7 @@ class CollectionFolderViewModel
nameLessThan = letter.toString(), nameLessThan = letter.toString(),
limit = 0, limit = 0,
enableTotalRecordCount = true, enableTotalRecordCount = true,
recursive = true,
) )
val result by GetItemsRequestHandler.execute(api, request) val result by GetItemsRequestHandler.execute(api, request)
result.totalRecordCount result.totalRecordCount
@ -160,6 +183,7 @@ class CollectionFolderViewModel
fun CollectionFolderGrid( fun CollectionFolderGrid(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
recursive: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel(),
@ -172,12 +196,11 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(destination.itemId, destination.item, initialSortAndDirection) viewModel.init(destination.itemId, destination.item, initialSortAndDirection, recursive)
} }
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection) val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val library by viewModel.model.observeAsState()
val pager by viewModel.pager.observeAsState() val pager by viewModel.pager.observeAsState()
when (val state = loading) { when (val state = loading) {
@ -189,14 +212,13 @@ fun CollectionFolderGrid(
pager?.let { pager -> pager?.let { pager ->
CollectionFolderGridContent( CollectionFolderGridContent(
preferences, preferences,
library!!,
item!!, item!!,
pager, pager,
sortAndDirection = sortAndDirection, sortAndDirection = sortAndDirection,
modifier = modifier, modifier = modifier,
onClickItem = onClickItem, onClickItem = onClickItem,
onSortChange = { onSortChange = {
viewModel.loadResults(it) viewModel.loadResults(it, recursive)
}, },
showTitle = showTitle, showTitle = showTitle,
positionCallback = positionCallback, positionCallback = positionCallback,
@ -210,7 +232,6 @@ fun CollectionFolderGrid(
@Composable @Composable
fun CollectionFolderGridContent( fun CollectionFolderGridContent(
preferences: UserPreferences, preferences: UserPreferences,
library: Library,
item: BaseItem, item: BaseItem,
pager: List<BaseItem?>, pager: List<BaseItem?>,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
@ -221,7 +242,7 @@ fun CollectionFolderGridContent(
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, 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 = val sortOptions =
when (item.data.collectionType) { when (item.data.collectionType) {
CollectionType.MOVIES -> MovieSortOptions CollectionType.MOVIES -> MovieSortOptions
@ -262,7 +283,7 @@ fun CollectionFolderGridContent(
CardGrid( CardGrid(
pager = pager, pager = pager,
onClickItem = onClickItem, onClickItem = onClickItem,
longClicker = {}, onLongClickItem = {},
letterPosition = letterPosition, letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference showJumpButtons = false, // TODO add preference

View file

@ -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 * A dialog that can be scrolled, typically for longer text content
*/ */

View file

@ -0,0 +1,70 @@
package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
@Composable
fun OverviewText(
overview: String,
maxLines: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textBoxHeight: Dp = maxLines * 20.dp,
enabled: Boolean = true,
) {
val context = LocalContext.current
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else {
Color.Unspecified
}
Box(
modifier =
modifier
.background(bgColor, shape = RoundedCornerShape(8.dp))
.playSoundOnFocus(true)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
playOnClickSound(context)
onClick.invoke()
},
) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(8.dp)
.height(textBoxHeight),
)
}
}

View file

@ -15,7 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged 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.R
import com.github.damontecres.dolphin.ui.PreviewTvSpec import com.github.damontecres.dolphin.ui.PreviewTvSpec
import com.github.damontecres.dolphin.ui.theme.DolphinTheme import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -139,14 +137,10 @@ fun ExpandablePlayButtons(
modifier = modifier =
modifier modifier
.focusGroup() .focusGroup()
.focusProperties { .focusRestorer(firstFocus),
onEnter = {
firstFocus.tryRequestFocus()
}
},
) { ) {
if (resumePosition > Duration.ZERO) { if (resumePosition > Duration.ZERO) {
item { item("play") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.resume, R.string.resume,
resumePosition, resumePosition,
@ -157,7 +151,7 @@ fun ExpandablePlayButtons(
.focusRequester(firstFocus), .focusRequester(firstFocus),
) )
} }
item { item("restart") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.restart, R.string.restart,
Duration.ZERO, Duration.ZERO,
@ -168,7 +162,7 @@ fun ExpandablePlayButtons(
) )
} }
} else { } else {
item { item("play") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.play, R.string.play,
Duration.ZERO, Duration.ZERO,
@ -182,7 +176,7 @@ fun ExpandablePlayButtons(
} }
// Watched button // Watched button
item { item("watched") {
ExpandableFaButton( ExpandableFaButton(
title = if (watched) R.string.mark_unwatched else R.string.mark_watched, 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, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
@ -192,7 +186,7 @@ fun ExpandablePlayButtons(
} }
// More button // More button
item { item("more") {
ExpandablePlayButton( ExpandablePlayButton(
R.string.more, R.string.more,
Duration.ZERO, Duration.ZERO,

View file

@ -69,7 +69,7 @@ private const val DEBUG = false
fun CardGrid( fun CardGrid(
pager: List<BaseItem?>, pager: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
longClicker: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
showJumpButtons: Boolean, showJumpButtons: Boolean,
@ -257,7 +257,7 @@ fun CardGrid(
onClickItem.invoke(item) onClickItem.invoke(item)
} }
}, },
onLongClick = { if (item != null) longClicker.invoke(item) }, onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier = modifier =
mod mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus)) .ifElse(index == 0, Modifier.focusRequester(zeroFocus))

View file

@ -18,6 +18,7 @@ import com.github.damontecres.dolphin.ui.preferences.PreferencesViewModel
fun CollectionFolderGeneric( fun CollectionFolderGeneric(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
recursive: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
@ -27,6 +28,7 @@ fun CollectionFolderGeneric(
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
destination = destination, destination = destination,
showTitle = showHeader, showTitle = showHeader,
recursive = recursive,
modifier = modifier =
modifier modifier
.padding(start = 16.dp), .padding(start = 16.dp),

View file

@ -156,6 +156,7 @@ fun CollectionFolderMovie(
onClickItem = onClickItem, onClickItem = onClickItem,
destination = destination, destination = destination,
showTitle = false, showTitle = false,
recursive = true,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -155,6 +155,7 @@ fun CollectionFolderTv(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
showTitle = false, showTitle = false,
recursive = true,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -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<Video>(api)
@Composable
fun EpisodeDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: EpisodeViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
EpisodeDetailsContent(
item = item,
backdropImageUrl =
remember {
item.data.parentBackdropItemId?.let {
viewModel.imageUrl(it, ImageType.BACKDROP)
}
},
modifier = modifier,
)
}
}
}
}
@Composable
fun EpisodeDetailsContent(
item: BaseItem,
backdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val dto = item.data
val title = item.name ?: "Unknown"
val subtitle = dto.seriesName
val description = dto.overview
val details =
buildList {
dto.seasonEpisode?.let(::add)
dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let {
add(it)
}
}
Box(
modifier =
modifier
.fillMaxWidth()
// .fillMaxHeight(.33f)
.height(460.dp)
.bringIntoViewRequester(bringIntoViewRequester),
) {
if (backdropImageUrl.isNotNullOrBlank()) {
Timber.v("Banner image url: $backdropImageUrl")
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxSize()
.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(modifier = Modifier.fillMaxWidth(0.8f)) {
Spacer(modifier = Modifier.height(60.dp))
VideoDetailsHeader(
title = title,
subtitle = subtitle,
description = description,
details = details,
moreDetails = sortedMapOf(),
rating = 0f,
resumeTime = dto.userData?.playbackPositionTicks?.ticks ?: 0.seconds,
watched = dto.userData?.played ?: false,
favorite = dto.userData?.isFavorite ?: false,
bringIntoViewRequester = bringIntoViewRequester,
descriptionOnClick = {},
moreOnClick = {},
modifier = Modifier,
)
}
}
}

View file

@ -4,8 +4,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.convertModel
import com.github.damontecres.dolphin.util.LoadingExceptionHandler import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -22,16 +20,15 @@ import java.util.UUID
/** /**
* Basic [ViewModel] for a single fetchable item from the API * Basic [ViewModel] for a single fetchable item from the API
*/ */
abstract class ItemViewModel<T : DolphinModel>( abstract class ItemViewModel(
val api: ApiClient, val api: ApiClient,
) : ViewModel() { ) : ViewModel() {
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val model = MutableLiveData<T?>(null)
suspend fun fetchItem( suspend fun fetchItem(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): BaseItem? = ): BaseItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// val fetchedItem = // val fetchedItem =
// when { // when {
@ -44,11 +41,9 @@ abstract class ItemViewModel<T : DolphinModel>(
// } // }
val it = api.userLibraryApi.getItem(itemId).content val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api) val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem?.let { return@withContext fetchedItem.let {
val modelInstance = convertModel(fetchedItem.data, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
item.value = fetchedItem item.value = fetchedItem
model.value = modelInstance as T
} }
fetchedItem fetchedItem
} }
@ -63,16 +58,17 @@ abstract class ItemViewModel<T : DolphinModel>(
/** /**
* Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred
*/ */
abstract class LoadingItemViewModel<T : DolphinModel>( abstract class LoadingItemViewModel(
api: ApiClient, api: ApiClient,
) : ItemViewModel<T>(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
open fun init( open fun init(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): Job? = ): Job? {
viewModelScope.launch( loading.value = LoadingState.Loading
return viewModelScope.launch(
LoadingExceptionHandler( LoadingExceptionHandler(
loading, loading,
"Error loading item $itemId", "Error loading item $itemId",
@ -80,10 +76,8 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
) { ) {
try { try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api) item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -92,4 +86,5 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
loading.value = LoadingState.Error("Error loading item $itemId", e) loading.value = LoadingState.Error("Error loading item $itemId", e)
} }
} }
}
} }

View file

@ -0,0 +1,453 @@
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
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.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
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.focusProperties
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.text.style.TextAlign
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.R
import com.github.damontecres.dolphin.data.model.BaseItem
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.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
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
import kotlin.time.Duration
@HiltViewModel
class PlaylistViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(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())
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
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,
focusRequester = focusRequester,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = 0L,
startIndex = index,
),
)
},
onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = 0L,
startIndex = 0,
shuffle = shuffle,
),
)
},
onLongClickIndex = { index, item ->
longClickDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
listOf(
DialogItem(
"Go to",
Icons.Default.ArrowForward,
) {
viewModel.navigationManager.navigateTo(item.destination())
},
DialogItem(
"Play from here",
Icons.Default.PlayArrow,
) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = it.resumeMs ?: 0L,
startIndex = index,
),
)
},
),
)
},
modifier = modifier,
)
}
}
longClickDialog?.let { params ->
DialogPopup(
params = params,
onDismissRequest = { longClickDialog = null },
)
}
}
@Composable
fun PlaylistDetailsContent(
playlist: BaseItem,
items: List<BaseItem?>,
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex)
val playButtonFocusRequester = remember { FocusRequester() }
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(top = 16.dp)
.fillMaxSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier =
Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
) {
PlaylistDetailsHeader(
focusedItem = focusedItem,
onClickPlay = onClickPlay,
playButtonFocusRequester = playButtonFocusRequester,
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
.padding(bottom = 32.dp)
.fillMaxHeight()
// .fillMaxWidth(.8f)
.weight(1f)
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(1.dp)
.copy(alpha = .75f),
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
}
}.focusProperties {
left = playButtonFocusRequester
previous = playButtonFocusRequester
},
)
}
}
}
}
}
}
}
@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,
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
OverviewText(
overview = focusedItem?.data?.overview ?: "",
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,
)
}

View file

@ -1,113 +0,0 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
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.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
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.cards.ItemRow
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.ExceptionHandler
import com.github.damontecres.dolphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class SeasonViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : ItemViewModel<Video>(api) {
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
fun init(
itemId: UUID,
potential: BaseItem?,
): Job? =
viewModelScope.launch(ExceptionHandler()) {
fetchItem(itemId, potential)?.let { item ->
val request =
GetItemsRequest(
parentId = item.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CHILD_COUNT),
)
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
)
pager.init()
episodes.value = pager
}
}
}
@Composable
fun SeasonDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeasonViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val episodes by viewModel.episodes.observeAsState(listOf())
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
var focusedChild by remember { mutableIntStateOf(0) }
LazyColumn(modifier = modifier) {
item {
Text(text = item.name ?: "Unknown")
}
item {
}
item {
ItemRow(
title = "Episodes",
items = episodes,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
cardOnFocus = { isFocused, index ->
if (isFocused) focusedChild = index
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}

View file

@ -1,10 +1,5 @@
package com.github.damontecres.dolphin.ui.detail 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
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.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -54,11 +47,15 @@ import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.PersonRow
import com.github.damontecres.dolphin.ui.cards.SeasonCard import com.github.damontecres.dolphin.ui.cards.SeasonCard
import com.github.damontecres.dolphin.ui.components.ConfirmDialog 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.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
import com.github.damontecres.dolphin.ui.components.LoadingPage 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.StarRating
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
@ -68,8 +65,6 @@ import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.nav.Destination 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.rememberPosition
import com.github.damontecres.dolphin.ui.roundMinutes import com.github.damontecres.dolphin.ui.roundMinutes
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
@ -95,6 +90,7 @@ fun SeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) } var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -112,6 +108,20 @@ fun SeriesDetails(
played = played, played = played,
modifier = modifier, modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) }, onClickItem = { viewModel.navigateTo(it.destination()) },
onLongClickItem = { season ->
seasonDialog =
buildDialogForSeason(
season,
onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played ->
viewModel.setWatched(
season.id,
played,
null,
)
},
)
},
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
@ -145,6 +155,15 @@ fun SeriesDetails(
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },
) )
} }
seasonDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
waitToLoad = params.fromLongClick,
onDismissRequest = { seasonDialog = null },
)
}
} }
@Composable @Composable
@ -155,6 +174,7 @@ fun SeriesDetailsContent(
people: List<Person>, people: List<Person>,
played: Boolean, played: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
playOnClick: () -> Unit, playOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
@ -230,7 +250,7 @@ fun SeriesDetailsContent(
title = "Seasons", title = "Seasons",
items = seasons.items, items = seasons.items,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = { }, onLongClickItem = onLongClickItem,
cardOnFocus = { isFocused, index -> cardOnFocus = { isFocused, index ->
// if (isFocused) { // if (isFocused) {
// scope.launch(ExceptionHandler()) { // scope.launch(ExceptionHandler()) {
@ -251,6 +271,7 @@ fun SeriesDetailsContent(
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = Cards.height2x3, imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified, imageWidth = Dp.Unspecified,
showImageOverlay = true,
modifier = modifier =
mod mod
.ifElse( .ifElse(
@ -333,40 +354,12 @@ fun SeriesDetailsHeader(
} }
dto.overview?.let { overview -> dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() } OverviewText(
val isFocused = interactionSource.collectIsFocusedAsState().value overview = overview,
val bgColor = maxLines = 3,
if (isFocused) { onClick = overviewOnClick,
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f) textBoxHeight = Dp.Unspecified,
} 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),
)
}
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -388,3 +381,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,
)
}

View file

@ -13,7 +13,6 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.hilt.AuthOkHttpClient import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
import com.github.damontecres.dolphin.preferences.ThemeSongVolume import com.github.damontecres.dolphin.preferences.ThemeSongVolume
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
@ -58,13 +57,13 @@ class SeriesViewModel
@param:ApplicationContext val context: Context, @param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient, @param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
) : ItemViewModel<Video>(api) { ) : ItemViewModel(api) {
private var player: Player? = null private var player: Player? = null
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty()) val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty()) val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
fun init( fun init(
@ -83,33 +82,25 @@ class SeriesViewModel
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
val item = fetchItem(seriesId, potential) val item = fetchItem(seriesId, potential)
if (item != null) { val seasonsInfo = getSeasons(item)
val seasonsInfo = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season // If a particular season was requested, fetch those episodes, otherwise get the first season
val episodeInfo = val episodeInfo =
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber) (season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum -> ?.let { seasonNum ->
loadEpisodesInternal(seasonNum) loadEpisodesInternal(seasonNum)
} ?: ItemListAndMapping.empty() } ?: EpisodeList.Error("Could not determine season")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
seasons.value = seasonsInfo seasons.value = seasonsInfo
episodes.value = episodeInfo episodes.value = episodeInfo
loading.value = LoadingState.Success loading.value = LoadingState.Success
people.value = people.value =
item.data.people item.data.people
?.letNotEmpty { people -> ?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) } people.map { Person.fromDto(it, api) }
}.orEmpty() }.orEmpty()
}
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
} else {
withContext(Dispatchers.Main) {
seasons.value = ItemListAndMapping.empty()
episodes.value = ItemListAndMapping.empty()
loading.value = LoadingState.Error("Series $seriesId not found")
}
} }
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
} }
} }
@ -204,7 +195,7 @@ class SeriesViewModel
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
} }
private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping { private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
val request = val request =
GetEpisodesRequest( GetEpisodesRequest(
seriesId = item.value!!.id, seriesId = item.value!!.id,
@ -223,35 +214,38 @@ class SeriesViewModel
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init() pager.init()
Timber.Forest.v("Loaded ${pager.size} episodes for season $season") Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
return convertPager(pager) return EpisodeList.Success(convertPager(pager))
} }
fun loadEpisodes(season: Int) = fun loadEpisodes(season: Int) {
this@SeriesViewModel.episodes.value = EpisodeList.Loading
viewModelScope.async(ExceptionHandler(true)) { viewModelScope.async(ExceptionHandler(true)) {
val episodes = val episodes =
try { try {
loadEpisodesInternal(season) loadEpisodesInternal(season)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $season") Timber.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI? EpisodeList.Error(e)
ItemListAndMapping.empty()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes this@SeriesViewModel.episodes.value = episodes
} }
} }
}
fun setWatched( fun setWatched(
itemId: UUID, itemId: UUID,
played: Boolean, played: Boolean,
listIndex: Int, listIndex: Int?,
) = viewModelScope.launch(ExceptionHandler()) { ) = viewModelScope.launch(ExceptionHandler()) {
if (played) { if (played) {
api.playStateApi.markPlayedItem(itemId) api.playStateApi.markPlayedItem(itemId)
} else { } else {
api.playStateApi.markUnplayedItem(itemId) api.playStateApi.markUnplayedItem(itemId)
} }
refreshEpisode(itemId, listIndex) listIndex?.let {
refreshEpisode(itemId, listIndex)
}
} }
fun setWatchedSeries(played: Boolean) = fun setWatchedSeries(played: Boolean) =
@ -271,14 +265,15 @@ class SeriesViewModel
val base = api.userLibraryApi.getItem(itemId).content val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api) val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!! val eps = episodes.value!!
withContext(Dispatchers.Main) { if (eps is EpisodeList.Success) {
episodes.value = val newItems =
eps.copy( eps.episodes.items.toMutableList().apply {
items = this[listIndex] = item
eps.items.toMutableList().apply { }
this[listIndex] = item val newValue = EpisodeList.Success(eps.episodes.copy(items = newItems))
}, withContext(Dispatchers.Main) {
) episodes.value = newValue
}
} }
} }
@ -324,17 +319,32 @@ data class ItemListAndMapping(
} }
/** /**
* Calculate the index<->season number pairings * Calculate the index<->season/ep number pairings
* *
* This allows for handling of missing seasons * This allows for handling of missing seasons
*/ */
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping { private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs = val pairs =
pager.mapIndexed { index, _ -> pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index) val item = pager.getBlocking(index)
Pair(season?.indexNumber!!, index) Pair(item?.indexNumber ?: index, index)
} }
val seasonNumToIndex = mapOf(*pairs.toTypedArray()) val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
} }
sealed interface EpisodeList {
data object Loading : EpisodeList
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : EpisodeList {
constructor(exception: Throwable) : this(null, exception)
}
data class Success(
val episodes: ItemListAndMapping,
) : EpisodeList
}

View file

@ -12,7 +12,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.LoadingPage
@ -32,7 +31,7 @@ class VideoViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : LoadingItemViewModel<Video>(api) ) : LoadingItemViewModel(api)
@Composable @Composable
fun VideoDetails( fun VideoDetails(

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -40,7 +39,6 @@ import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Chapter import com.github.damontecres.dolphin.data.model.Chapter
import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.ChapterRow import com.github.damontecres.dolphin.ui.cards.ChapterRow
import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.PersonRow
@ -77,7 +75,7 @@ class MovieViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : LoadingItemViewModel<Video>(api) { ) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID private lateinit var itemId: UUID
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
@ -177,20 +175,20 @@ fun MovieDetails(
Destination.Playback(movie), Destination.Playback(movie),
) )
}, },
DialogItem( // DialogItem(
"Playback Settings", // "Playback Settings",
Icons.Default.Settings, // Icons.Default.Settings,
// iconColor = Color.Green.copy(alpha = .8f), // // iconColor = Color.Green.copy(alpha = .8f),
) { // ) {
// TODO choose audio or subtitle tracks? // // TODO choose audio or subtitle tracks?
}, // },
DialogItem( // DialogItem(
"Play Version", // "Play Version",
Icons.Default.PlayArrow, // Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), // iconColor = Color.Green.copy(alpha = .8f),
) { // ) {
// TODO only show for multiple files // // TODO only show for multiple files
}, // },
), ),
) )
}, },

View file

@ -1,12 +1,6 @@
package com.github.damontecres.dolphin.ui.detail.movie 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow 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.StarRating
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
import com.github.damontecres.dolphin.ui.components.TitleValueText import com.github.damontecres.dolphin.ui.components.TitleValueText
import com.github.damontecres.dolphin.ui.isNotNullOrBlank 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.roundMinutes
import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.util.formatSubtitleLang import com.github.damontecres.dolphin.util.formatSubtitleLang
@ -120,40 +112,12 @@ fun MovieDetailsHeader(
// Description // Description
dto.overview?.let { overview -> dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() } OverviewText(
val isFocused = interactionSource.collectIsFocusedAsState().value overview = overview,
val bgColor = maxLines = 3,
if (isFocused) { onClick = overviewOnClick,
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f) textBoxHeight = Dp.Unspecified,
} 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),
)
}
} }
movie.data.people movie.data.people
?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() }
@ -161,6 +125,8 @@ fun MovieDetailsHeader(
?.let { ?.let {
Text( Text(
text = "Directed by $it", text = "Directed by $it",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
) )
} }
// Key-Values // Key-Values

View file

@ -1,23 +1,13 @@
package com.github.damontecres.dolphin.ui.detail.series 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height 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.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -25,10 +15,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow 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.StarRating
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision 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.roundMinutes
import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.util.formatDateTime import com.github.damontecres.dolphin.util.formatDateTime
@ -50,6 +39,8 @@ fun FocusedEpisodeHeader(
Text( Text(
text = dto.episodeTitle ?: dto.name ?: "", text = dto.episodeTitle ?: dto.name ?: "",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
Row( Row(
@ -91,42 +82,12 @@ fun FocusedEpisodeHeader(
} else { } else {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
} } ?: 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),
)
} }
OverviewText(
overview = dto.overview ?: "",
maxLines = 3,
onClick = overviewOnClick,
)
} }
} }

View file

@ -3,7 +3,6 @@ package com.github.damontecres.dolphin.ui.detail.series
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -26,6 +25,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.detail.EpisodeList
import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
@ -76,7 +76,8 @@ fun SeriesOverview(
val series by viewModel.item.observeAsState(null) val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty()) val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val episodeList = (episodes as? EpisodeList.Success)?.episodes?.items
var position by rememberSaveable( var position by rememberSaveable(
destination, destination,
@ -90,7 +91,10 @@ fun SeriesOverview(
mutableStateOf( mutableStateOf(
SeriesOverviewPosition( SeriesOverviewPosition(
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0, seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0, (episodes as? EpisodeList.Success)?.episodes?.numberToIndex[
initialSeasonEpisode?.episode
?: 0,
] ?: 0,
), ),
) )
} }
@ -98,12 +102,16 @@ fun SeriesOverview(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
LaunchedEffect(episodes.items) { LaunchedEffect(episodes) {
if (episodes.items.isNotEmpty()) { episodes?.let { episodes ->
// TODO focus on first episode when changing seasons? if (episodes is EpisodeList.Success) {
if (episodes.episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons?
// firstItemFocusRequester.requestFocus() // firstItemFocusRequester.requestFocus()
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex) viewModel.refreshEpisode(it.id, position.episodeRowIndex)
}
}
} }
} }
} }
@ -121,7 +129,7 @@ fun SeriesOverview(
SeriesOverviewContent( SeriesOverviewContent(
series = series, series = series,
seasons = seasons.items, seasons = seasons.items,
episodes = episodes.items, episodes = episodes,
position = position, position = position,
backdropImageUrl = backdropImageUrl =
remember { remember {
@ -141,7 +149,6 @@ fun SeriesOverview(
position = it position = it
}, },
onClick = { onClick = {
viewModel.release()
val resumePosition = val resumePosition =
it.data.userData it.data.userData
?.playbackPositionTicks ?.playbackPositionTicks
@ -158,7 +165,7 @@ fun SeriesOverview(
// TODO // TODO
}, },
playOnClick = { resume -> playOnClick = { resume ->
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release() viewModel.release()
viewModel.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
@ -170,13 +177,13 @@ fun SeriesOverview(
} }
}, },
watchOnClick = { watchOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex) viewModel.setWatched(it.id, !played, position.episodeRowIndex)
} }
}, },
moreOnClick = { moreOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog = moreDialog =
DialogParams( DialogParams(
fromLongClick = false, fromLongClick = false,
@ -188,7 +195,6 @@ fun SeriesOverview(
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
viewModel.release()
viewModel.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
ep.id, ep.id,
@ -202,7 +208,6 @@ fun SeriesOverview(
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
// iconColor = Color.Green.copy(alpha = .8f), // iconColor = Color.Green.copy(alpha = .8f),
) { ) {
viewModel.release()
viewModel.navigateTo( viewModel.navigateTo(
Destination.MediaItem( Destination.MediaItem(
series.id, series.id,
@ -211,26 +216,26 @@ fun SeriesOverview(
), ),
) )
}, },
DialogItem( // DialogItem(
"Playback Settings", // "Playback Settings",
Icons.Default.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), // iconColor = Color.Green.copy(alpha = .8f),
) { // ) {
// TODO choose audio or subtitle tracks? // // TODO only show for multiple files
}, // },
DialogItem(
"Play Version",
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
// TODO only show for multiple files
},
), ),
) )
} }
}, },
overviewOnClick = { overviewOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = it.name ?: "Unknown", title = it.name ?: "Unknown",

View file

@ -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.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
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.MaterialTheme
import androidx.tv.material3.Tab import androidx.tv.material3.Tab
@ -46,16 +46,20 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.aspectRatioFloat import com.github.damontecres.dolphin.data.model.aspectRatioFloat
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.detail.EpisodeList
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.formatDateTime
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
fun SeriesOverviewContent( fun SeriesOverviewContent(
series: BaseItem, series: BaseItem,
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: List<BaseItem?>, episodes: EpisodeList,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
backdropImageUrl: String?, backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester, firstItemFocusRequester: FocusRequester,
@ -76,9 +80,8 @@ fun SeriesOverviewContent(
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
val tabRowFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex) val focusedEpisode =
LaunchedEffect(position) { (episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
}
Box( Box(
modifier = modifier =
@ -184,6 +187,8 @@ fun SeriesOverviewContent(
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
} }
@ -200,56 +205,74 @@ fun SeriesOverviewContent(
} }
item { item {
key(position.seasonTabIndex) { key(position.seasonTabIndex) {
val state = rememberLazyListState() when (val eps = episodes) {
OneTimeLaunchedEffect { EpisodeList.Loading -> LoadingPage()
if (state.firstVisibleItemIndex != position.episodeRowIndex) { is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
state.scrollToItem(position.episodeRowIndex) is EpisodeList.Success -> {
firstItemFocusRequester.tryRequestFocus() val state = rememberLazyListState()
} OneTimeLaunchedEffect {
} if (state.firstVisibleItemIndex != position.episodeRowIndex) {
LazyRow( state.scrollToItem(position.episodeRowIndex)
state = state, }
horizontalArrangement = Arrangement.spacedBy(16.dp), firstItemFocusRequester.tryRequestFocus()
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester),
) {
itemsIndexed(episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
} }
LazyRow(
BannerCard( state = state,
name = episode?.name, horizontalArrangement = Arrangement.spacedBy(16.dp),
imageUrl = episode?.imageUrl, contentPadding = PaddingValues(horizontal = 16.dp),
aspectRatio =
episode
?.data
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(episode.data.aspectRatioFloat ?: (4f / 3f))
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = "E${episode?.data?.indexNumber}",
played = episode?.data?.userData?.played ?: false,
playPercent = episode?.data?.userData?.playedPercentage ?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
modifier = modifier =
Modifier.ifElse( Modifier
episodeIndex == position.episodeRowIndex, .focusRestorer(firstItemFocusRequester)
Modifier.focusRequester(firstItemFocusRequester), .focusRequester(episodeRowFocusRequester),
), ) {
interactionSource = interactionSource, itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
cardHeight = 120.dp, val interactionSource = remember { MutableInteractionSource() }
) if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
}
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard(
name = episode?.name,
imageUrl = episode?.imageUrl,
aspectRatio =
episode
?.data
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(
episode.data.aspectRatioFloat ?: (4f / 3f),
)
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = cornerText,
played = episode?.data?.userData?.played ?: false,
playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
},
modifier =
Modifier.ifElse(
episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester),
),
interactionSource = interactionSource,
cardHeight = 120.dp,
)
}
}
} }
} }
} }

View file

@ -54,6 +54,7 @@ import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import com.github.damontecres.dolphin.util.formatDateTime import com.github.damontecres.dolphin.util.formatDateTime
import com.github.damontecres.dolphin.util.seasonEpisode
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -121,7 +122,7 @@ fun HomePageContent(
Modifier Modifier
.fillMaxHeight(.7f) .fillMaxHeight(.7f)
.fillMaxWidth(.7f) .fillMaxWidth(.7f)
.alpha(.4f) .alpha(.75f)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.drawWithContent { .drawWithContent {
drawContent() drawContent()
@ -231,7 +232,13 @@ fun MainPageHeader(
val details = val details =
buildList { buildList {
if (isEpisode) { if (isEpisode) {
add("S${dto.parentIndexNumber} E${dto.indexNumber}") val se = dto.seasonEpisode
if (se != null) {
add(se)
} else if (dto.parentIndexNumber != null) {
// Maybe a daily episode, so just show season, the date is added below
add("S${dto.parentIndexNumber}")
}
} }
if (isEpisode) { if (isEpisode) {
dto.premiereDate?.let { add(formatDateTime(it)) } dto.premiereDate?.let { add(formatDateTime(it)) }
@ -249,12 +256,16 @@ fun MainPageHeader(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
subtitle?.let { subtitle?.let {
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
if (details.isNotEmpty()) { if (details.isNotEmpty()) {
@ -267,13 +278,13 @@ fun MainPageHeader(
val overviewModifier = val overviewModifier =
Modifier Modifier
.padding(0.dp) .padding(0.dp)
.height(48.dp) .height(48.dp + if (!isEpisode) 12.dp else 0.dp)
if (overview.isNotNullOrBlank()) { if (overview.isNotNullOrBlank()) {
Text( Text(
text = overview, text = overview,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 2, maxLines = if (isEpisode) 2 else 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = overviewModifier, modifier = overviewModifier,
) )

View file

@ -40,7 +40,8 @@ class HomeViewModel
val homeRows = MutableLiveData<List<HomeRow>>() val homeRows = MutableLiveData<List<HomeRow>>()
fun init(preferences: UserPreferences) { fun init(preferences: UserPreferences) {
val limit = preferences.appPreferences.homePagePreferences.maxItemsPerRow val prefs = preferences.appPreferences.homePagePreferences
val limit = prefs.maxItemsPerRow
viewModelScope.launch( viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
@ -78,53 +79,32 @@ class HomeViewModel
// } // }
// TODO data is fetched all together which may be slow for large servers // 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 = val homeRows =
homeSections if (prefs.combineContinueNext) {
.mapNotNull { section -> listOf(
Timber.Forest.v("Loading section: %s", section.name) HomeRow(
when (section) { section = HomeSection.NEXT_UP,
HomeSection.LATEST_MEDIA -> { items = resume + nextUp,
getLatest(user, limit) ),
} *latest.toTypedArray(),
)
HomeSection.RESUME -> { } else {
val items = getResume(user.id, limit) listOf(
listOf( HomeRow(
HomeRow( section = HomeSection.RESUME,
section = section, items = resume,
items = items, ),
), HomeRow(
) section = HomeSection.NEXT_UP,
} items = nextUp,
),
HomeSection.NEXT_UP -> { *latest.toTypedArray(),
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() }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@HomeViewModel.homeRows.value = homeRows this@HomeViewModel.homeRows.value = homeRows
loadingState.value = LoadingState.Success loadingState.value = LoadingState.Success

View file

@ -5,8 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.dolphin.data.JellyfinServer import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinUser import com.github.damontecres.dolphin.data.JellyfinUser
@ -30,11 +29,10 @@ fun ApplicationContent(
) { ) {
NavDisplay( NavDisplay(
backStack = navigationManager.backStack, backStack = navigationManager.backStack,
onBack = { repeat(it) { navigationManager.goBack() } }, onBack = { navigationManager.goBack() },
entryDecorators = entryDecorators =
listOf( listOf(
rememberSceneSetupNavEntryDecorator(), rememberSaveableStateHolderNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(),
), ),
entryProvider = { key -> entryProvider = { key ->

View file

@ -67,6 +67,8 @@ sealed class Destination(
val itemId: UUID, val itemId: UUID,
val positionMs: Long, val positionMs: Long,
@Transient val item: BaseItem? = null, @Transient val item: BaseItem? = null,
val startIndex: Int? = null,
val shuffle: Boolean = false,
) : Destination(true) { ) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"

View file

@ -8,8 +8,7 @@ import com.github.damontecres.dolphin.ui.components.LicenseInfo
import com.github.damontecres.dolphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.dolphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.dolphin.ui.detail.CollectionFolderMovie import com.github.damontecres.dolphin.ui.detail.CollectionFolderMovie
import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv 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.SeriesDetails
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
@ -76,20 +75,6 @@ fun DestinationContent(
modifier, modifier,
) )
BaseItemKind.SEASON ->
SeasonDetails(
preferences,
destination,
modifier,
)
BaseItemKind.EPISODE ->
EpisodeDetails(
preferences,
destination,
modifier,
)
BaseItemKind.MOVIE -> BaseItemKind.MOVIE ->
MovieDetails( MovieDetails(
preferences, preferences,
@ -105,6 +90,14 @@ fun DestinationContent(
modifier, modifier,
) )
BaseItemKind.BOX_SET ->
CollectionFolderGeneric(
preferences,
destination,
false,
modifier,
)
BaseItemKind.COLLECTION_FOLDER -> { BaseItemKind.COLLECTION_FOLDER -> {
when (destination.item?.data?.collectionType) { when (destination.item?.data?.collectionType) {
CollectionType.TVSHOWS -> CollectionType.TVSHOWS ->
@ -121,15 +114,38 @@ fun DestinationContent(
modifier, modifier,
) )
CollectionType.BOXSETS ->
CollectionFolderGeneric(
preferences,
destination,
true,
modifier,
)
else -> else ->
CollectionFolderGeneric( CollectionFolderGeneric(
preferences, preferences,
destination, destination,
false,
modifier, modifier,
) )
} }
} }
BaseItemKind.PLAYLIST ->
PlaylistDetails(
destination = destination,
modifier = modifier,
)
BaseItemKind.USER_VIEW ->
CollectionFolderGeneric(
preferences,
destination,
true,
modifier,
)
else -> { else -> {
Text("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}")
} }

View file

@ -311,7 +311,6 @@ fun NavigationDrawerScope.LibraryNavItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
// TODO
val icon = val icon =
when (library.data.collectionType) { when (library.data.collectionType) {
CollectionType.MOVIES -> R.string.fa_film CollectionType.MOVIES -> R.string.fa_film
@ -319,6 +318,8 @@ fun NavigationDrawerScope.LibraryNavItem(
CollectionType.HOMEVIDEOS -> R.string.fa_video CollectionType.HOMEVIDEOS -> R.string.fa_video
CollectionType.LIVETV -> R.string.fa_tv CollectionType.LIVETV -> R.string.fa_tv
CollectionType.MUSIC -> R.string.fa_music 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 else -> R.string.fa_film
} }
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value

View file

@ -65,6 +65,8 @@ import com.github.damontecres.dolphin.util.ExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -91,6 +93,10 @@ sealed interface PlaybackAction {
data class Scale( data class Scale(
val scale: ContentScale, val scale: ContentScale,
) : PlaybackAction ) : PlaybackAction
data object Previous : PlaybackAction
data object Next : PlaybackAction
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@ -116,6 +122,7 @@ fun PlaybackControls(
seekBack: Duration, seekBack: Duration,
skipBackOnResume: Duration?, skipBackOnResume: Duration?,
seekForward: Duration, seekForward: Duration,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialFocusRequester: FocusRequester = remember { FocusRequester() }, initialFocusRequester: FocusRequester = remember { FocusRequester() },
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@ -152,6 +159,8 @@ fun PlaybackControls(
interactionSource = seekBarInteractionSource, interactionSource = seekBarInteractionSource,
isEnabled = seekEnabled, isEnabled = seekEnabled,
intervals = seekBarIntervals, intervals = seekBarIntervals,
seekBack = seekBack,
seekForward = seekForward,
modifier = modifier =
Modifier Modifier
.padding(vertical = 0.dp) .padding(vertical = 0.dp)
@ -174,6 +183,7 @@ fun PlaybackControls(
player = playerControls, player = playerControls,
initialFocusRequester = initialFocusRequester, initialFocusRequester = initialFocusRequester,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
showPlay = showPlay, showPlay = showPlay,
previousEnabled = previousEnabled, previousEnabled = previousEnabled,
nextEnabled = nextEnabled, nextEnabled = nextEnabled,
@ -182,18 +192,37 @@ fun PlaybackControls(
skipBackOnResume = skipBackOnResume, skipBackOnResume = skipBackOnResume,
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
RightPlaybackButtons( Row(
subtitleStreams = subtitleStreams,
onControllerInteraction = onControllerInteraction,
onControllerInteractionForDialog = onControllerInteractionForDialog,
onPlaybackActionClick = onPlaybackActionClick,
subtitleIndex = subtitleIndex,
audioStreams = audioStreams,
audioIndex = audioIndex,
playbackSpeed = playbackSpeed,
scale = scale,
modifier = Modifier.align(Alignment.CenterEnd), 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,
)
}
} }
} }
} }
@ -206,6 +235,8 @@ fun SeekBar(
intervals: Int, intervals: Int,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
onSeekProgress: (Long) -> Unit, onSeekProgress: (Long) -> Unit,
seekBack: Duration,
seekForward: Duration,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
@ -236,6 +267,8 @@ fun SeekBar(
interactionSource = interactionSource, interactionSource = interactionSource,
enabled = isEnabled, enabled = isEnabled,
durationMs = player.contentDuration, durationMs = player.contentDuration,
seekBack = seekBack,
seekForward = seekForward,
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -443,6 +476,7 @@ fun PlaybackButtons(
player: Player, player: Player,
initialFocusRequester: FocusRequester, initialFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit, onControllerInteraction: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showPlay: Boolean, showPlay: Boolean,
previousEnabled: Boolean, previousEnabled: Boolean,
nextEnabled: Boolean, nextEnabled: Boolean,
@ -459,7 +493,7 @@ fun PlaybackButtons(
iconRes = R.drawable.baseline_skip_previous_24, iconRes = R.drawable.baseline_skip_previous_24,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
player.seekToPrevious() onPlaybackActionClick.invoke(PlaybackAction.Previous)
}, },
enabled = previousEnabled, enabled = previousEnabled,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,
@ -500,7 +534,7 @@ fun PlaybackButtons(
iconRes = R.drawable.baseline_skip_next_24, iconRes = R.drawable.baseline_skip_next_24,
onClick = { onClick = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
player.seekToNext() onPlaybackActionClick.invoke(PlaybackAction.Next)
}, },
enabled = nextEnabled, enabled = nextEnabled,
onControllerInteraction = onControllerInteraction, onControllerInteraction = onControllerInteraction,

View file

@ -6,13 +6,16 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@ -30,7 +33,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.KeyEventType 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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -41,13 +43,17 @@ import androidx.compose.ui.unit.sp
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.Chapter
import com.github.damontecres.dolphin.data.model.Playlist
import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.cards.ChapterCard 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.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration import kotlin.time.Duration
@ -79,10 +85,13 @@ fun PlaybackOverlay(
moreButtonOptions: MoreButtonOptions, moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>, audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
trickplayInfo: TrickplayInfo? = null, trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null }, trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
@ -117,10 +126,17 @@ fun PlaybackOverlay(
enter = slideInVertically() + fadeIn(), enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(), exit = slideOutVertically() + fadeOut(),
) { ) {
val nextState =
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
Controller( Controller(
title = title, title = title,
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
chapters = chapters,
playerControls = playerControls, playerControls = playerControls,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showPlay = showPlay, showPlay = showPlay,
@ -143,18 +159,15 @@ fun PlaybackOverlay(
audioStreams = audioStreams, audioStreams = audioStreams,
subtitle = subtitle, subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource, seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {
nextState?.let { state = it }
},
currentSegment = currentSegment,
modifier = modifier =
Modifier Modifier
.onKeyEvent { e -> // Don't use key events because this control has vertical items so up/down is tough to manage
if (chapters.isNotEmpty() && .onGloballyPositioned {
e.type == KeyEventType.KeyDown && isDown(e) &&
!seekBarFocused
) {
state = OverlayViewState.CHAPTERS
true
}
false
}.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() } controllerHeight = with(density) { it.size.height.toDp() }
}, },
) )
@ -177,8 +190,9 @@ fun PlaybackOverlay(
if (e.type == KeyEventType.KeyUp && isUp(e)) { if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = OverlayViewState.CONTROLLER state = OverlayViewState.CONTROLLER
true true
} else {
false
} }
false
}, },
) { ) {
Text( Text(
@ -221,6 +235,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),
),
)
}
}
} }
} }
} }
@ -291,6 +388,7 @@ fun PlaybackOverlay(
enum class OverlayViewState { enum class OverlayViewState {
CONTROLLER, CONTROLLER,
CHAPTERS, CHAPTERS,
QUEUE,
} }
/** /**
@ -300,7 +398,6 @@ enum class OverlayViewState {
fun Controller( fun Controller(
title: String?, title: String?,
subtitleStreams: List<SubtitleStream>, subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player, playerControls: Player,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
showPlay: Boolean, showPlay: Boolean,
@ -318,9 +415,12 @@ fun Controller(
moreButtonOptions: MoreButtonOptions, moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>, audioStreams: List<AudioStream>,
nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -370,13 +470,34 @@ fun Controller(
seekBack = seekBack, seekBack = seekBack,
seekForward = seekForward, seekForward = seekForward,
skipBackOnResume = skipBackOnResume, skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
) )
if (chapters.isNotEmpty()) { when (nextState) {
Text( OverlayViewState.CHAPTERS ->
text = "Chapters", Text(
style = MaterialTheme.typography.titleLarge, text = "Chapters",
modifier = Modifier.padding(start = 16.dp), 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))
} }
} }
} }

View file

@ -50,14 +50,13 @@ import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale 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.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPresentationState import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.media3.ui.compose.state.rememberPreviousButtonState
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.UserPreferences
import com.github.damontecres.dolphin.preferences.skipBackOnResume import com.github.damontecres.dolphin.preferences.skipBackOnResume
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
@ -101,11 +100,13 @@ fun PlaybackPage(
val chapters by viewModel.chapters.observeAsState(listOf()) val chapters by viewModel.chapters.observeAsState(listOf())
val currentPlayback by viewModel.currentPlayback.observeAsState(null) val currentPlayback by viewModel.currentPlayback.observeAsState(null)
val currentSegment by viewModel.currentSegment.observeAsState(null) val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
var cues by remember { mutableStateOf<List<Cue>>(listOf()) } var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } 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? // TODO move to viewmodel?
val cueListener = val cueListener =
@ -138,8 +139,6 @@ fun PlaybackPage(
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player) val playPauseState = rememberPlayPauseButtonState(player)
val previousState = rememberPreviousButtonState(player)
val nextState = rememberNextButtonState(player)
val seekBarState = rememberSeekBarState(player, scope) val seekBarState = rememberSeekBarState(player, scope)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -181,19 +180,26 @@ fun PlaybackPage(
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
) )
val showSegment =
!segmentCancelled && currentSegment != null &&
!controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
segmentCancelled = true
}
Box( Box(
modifier modifier
.background(Color.Black) .background(Color.Black),
.onKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) { ) {
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f) val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f)
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize(playerSize) .fillMaxSize(playerSize)
.align(Alignment.TopCenter), .align(Alignment.TopCenter)
.onKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) { ) {
PlayerSurface( PlayerSurface(
player = player, player = player,
@ -261,8 +267,8 @@ fun PlaybackPage(
playerControls = player, playerControls = player,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay, showPlay = playPauseState.showPlay,
previousEnabled = previousState.isEnabled, previousEnabled = true,
nextEnabled = nextState.isEnabled, nextEnabled = playlist.hasNext(),
seekEnabled = true, seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
@ -290,6 +296,20 @@ fun PlaybackPage(
is PlaybackAction.ToggleCaptions -> { is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index) 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, onSeekBarChange = seekBarState::onValueChange,
@ -302,6 +322,11 @@ fun PlaybackPage(
trickplayInfo = trickplay, trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl, trickplayUrlFor = viewModel::getTrickplayUrl,
chapters = chapters, chapters = chapters,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment,
) )
} }
@ -329,7 +354,7 @@ fun PlaybackPage(
// Ask to skip intros, etc button // Ask to skip intros, etc button
AnimatedVisibility( AnimatedVisibility(
currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L, showSegment,
modifier = modifier =
Modifier Modifier
.padding(40.dp) .padding(40.dp)
@ -337,7 +362,11 @@ fun PlaybackPage(
) { ) {
currentSegment?.let { segment -> currentSegment?.let { segment ->
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
segmentCancelled = false
}
Button( Button(
onClick = { onClick = {
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
@ -354,7 +383,7 @@ fun PlaybackPage(
// Next up episode // Next up episode
BackHandler(nextUp != null) { BackHandler(nextUp != null) {
viewModel.cancelUpNextEpisode() viewModel.navigationManager.goBack()
} }
AnimatedVisibility( AnimatedVisibility(
nextUp != null, nextUp != null,
@ -374,7 +403,6 @@ fun PlaybackPage(
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
) )
} }
// TODO need extra back press for some reason
BackHandler(timeLeft > 0 && autoPlayEnabled) { BackHandler(timeLeft > 0 && autoPlayEnabled) {
timeLeft = -1 timeLeft = -1
autoPlayEnabled = false autoPlayEnabled = false
@ -382,14 +410,14 @@ fun PlaybackPage(
if (autoPlayEnabled) { if (autoPlayEnabled) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (timeLeft == 0L) { if (timeLeft == 0L) {
viewModel.playUpNextEpisode() viewModel.playUpNextUp()
} else { } else {
while (timeLeft > 0) { while (timeLeft > 0) {
delay(1.seconds) delay(1.seconds)
timeLeft-- timeLeft--
} }
if (timeLeft == 0L && autoPlayEnabled) { if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextEpisode() viewModel.playUpNextUp()
} }
} }
} }
@ -403,7 +431,7 @@ fun PlaybackPage(
description = it.data.overview, description = it.data.overview,
imageUrl = it.imageUrl, imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9), aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
onClick = { viewModel.playUpNextEpisode() }, onClick = { viewModel.playUpNextUp() },
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier = modifier =
Modifier Modifier

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin.ui.playback package com.github.damontecres.dolphin.ui.playback
import android.content.Context import android.content.Context
import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -16,22 +17,23 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Chapter 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.AppPreference
import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.util.ApiRequestPager 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.EqualityMutableLiveData
import com.github.damontecres.dolphin.util.ExceptionHandler 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.TrackActivityPlaybackListener
import com.github.damontecres.dolphin.util.TrackSupport import com.github.damontecres.dolphin.util.TrackSupport
import com.github.damontecres.dolphin.util.checkForSupport import com.github.damontecres.dolphin.util.checkForSupport
import com.github.damontecres.dolphin.util.formatDateTime import com.github.damontecres.dolphin.util.formatDateTime
import com.github.damontecres.dolphin.util.seasonEpisodePadded import com.github.damontecres.dolphin.util.seasonEpisodePadded
import com.github.damontecres.dolphin.util.subtitleMimeTypes import com.github.damontecres.dolphin.util.subtitleMimeTypes
import com.github.damontecres.dolphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -53,10 +55,10 @@ import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType 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.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo 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.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -74,7 +76,7 @@ enum class TranscodeType {
data class StreamDecision( data class StreamDecision(
val itemId: UUID, val itemId: UUID,
val type: TranscodeType, val type: PlayMethod,
val url: String, val url: String,
) )
@ -82,8 +84,10 @@ data class StreamDecision(
class PlaybackViewModel class PlaybackViewModel
@Inject @Inject
constructor( constructor(
@ApplicationContext context: Context, @param:ApplicationContext val context: Context,
val api: ApiClient, val api: ApiClient,
val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
val player: ExoPlayer = val player: ExoPlayer =
ExoPlayer ExoPlayer
@ -111,9 +115,10 @@ class PlaybackViewModel
private lateinit var dto: BaseItemDto private lateinit var dto: BaseItemDto
private var activityListener: TrackActivityPlaybackListener? = null private var activityListener: TrackActivityPlaybackListener? = null
private val episodes = MutableLiveData<ApiRequestPager<GetEpisodesRequest>>() val nextUp = MutableLiveData<BaseItem?>()
private var currentEpisodeIndex = Int.MAX_VALUE private var isPlaylist = false
val nextUpEpisode = MutableLiveData<BaseItem?>()
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
init { init {
addCloseable { this@PlaybackViewModel.activityListener?.release() } addCloseable { this@PlaybackViewModel.activityListener?.release() }
@ -125,14 +130,62 @@ class PlaybackViewModel
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
preferences: UserPreferences, preferences: UserPreferences,
) { ) {
nextUpEpisode.value = null nextUp.value = null
this.preferences = preferences this.preferences = preferences
this.deviceProfile = deviceProfile this.deviceProfile = deviceProfile
val itemId = destination.itemId val itemId = destination.itemId
this.itemId = itemId this.itemId = itemId
val item = destination.item val item = destination.item
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
val base =
if (queriedItem.type == BaseItemKind.PLAYLIST) {
isPlaylist = true
val playlist =
playlistCreator.createFromPlaylistId(
queriedItem.id,
destination.startIndex,
destination.shuffle,
)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
// TODO start index
playlist.items.first().data
} else {
queriedItem
}
val played = play(base, destination.positionMs)
if (!played) {
playUpNextUp()
}
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
val playlist =
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
}
maybeSetupPlaylistListener()
}
}
private suspend fun play(
base: BaseItemDto,
positionMs: Long,
): Boolean =
withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}")
if (base.type !in supportItemKinds) {
showToast(
context,
"Unsupported type '${base.type}', skipping...",
Toast.LENGTH_SHORT,
)
return@withContext false
}
dto = base dto = base
val title = val title =
if (base.type == BaseItemKind.EPISODE) { if (base.type == BaseItemKind.EPISODE) {
@ -188,7 +241,7 @@ class PlaybackViewModel
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) }?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty() .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 audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex = val audioIndex =
if (audioLanguage != null) { if (audioLanguage != null) {
@ -236,48 +289,38 @@ class PlaybackViewModel
SubtitlePlaybackMode.NONE -> null SubtitlePlaybackMode.NONE -> null
} }
Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("base.mediaStreams=${base.mediaStreams}")
Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("subtitleTracks=$subtitleStreams")
Timber.v("audioStreams=$audioStreams") // Timber.v("audioStreams=$audioStreams")
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.audioStreams.value = audioStreams
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams 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( changeStreams(
itemId, base,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET, if (positionMs > 0) positionMs else C.TIME_UNSET,
) )
player.prepare() player.prepare()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api) this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}") Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
}
if (base.type == BaseItemKind.EPISODE) {
base.seriesId?.let(::getEpisodes)
} }
listenForSegments() listenForSegments()
return@withContext true
} }
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun changeStreams( private suspend fun changeStreams(
itemId: UUID, item: BaseItemDto,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET, positionMs: Long = C.TIME_UNSET,
) { ) {
val itemId = item.id
if (currentPlayback.value?.let { if (currentPlayback.value?.let {
it.itemId == itemId && it.itemId == itemId &&
it.audioIndex == audioIndex && it.audioIndex == audioIndex &&
@ -287,10 +330,11 @@ class PlaybackViewModel
Timber.i("No change in playback for changeStreams") Timber.i("No change in playback for changeStreams")
return 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 = val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response = val response by
api.mediaInfoApi api.mediaInfoApi
.getPostedPlaybackInfo( .getPostedPlaybackInfo(
itemId, itemId,
@ -309,7 +353,7 @@ class PlaybackViewModel
alwaysBurnInSubtitleWhenTranscoding = null, alwaysBurnInSubtitleWhenTranscoding = null,
maxStreamingBitrate = maxBitrate.toInt(), maxStreamingBitrate = maxBitrate.toInt(),
), ),
).content )
val source = response.mediaSources.firstOrNull() val source = response.mediaSources.firstOrNull()
source?.let { source -> source?.let { source ->
val mediaUrl = val mediaUrl =
@ -328,9 +372,9 @@ class PlaybackViewModel
} }
val transcodeType = val transcodeType =
when { when {
source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
source.supportsDirectStream -> TranscodeType.DIRECT_STREAM source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
source.supportsTranscoding -> TranscodeType.TRANSCODE source.supportsTranscoding -> PlayMethod.TRANSCODE
else -> throw Exception("No supported playback method") else -> throw Exception("No supported playback method")
} }
val decision = StreamDecision(itemId, transcodeType, mediaUrl) val decision = StreamDecision(itemId, transcodeType, mediaUrl)
@ -342,8 +386,8 @@ class PlaybackViewModel
?.let { ?.let {
it.deliveryUrl?.let { deliveryUrl -> it.deliveryUrl?.let { deliveryUrl ->
var flags = 0 var flags = 0
if (it.isForced) flags = flags.and(C.SELECTION_FLAG_FORCED) if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.and(C.SELECTION_FLAG_DEFAULT) if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration MediaItem.SubtitleConfiguration
.Builder( .Builder(
api.createUrl(deliveryUrl).toUri(), api.createUrl(deliveryUrl).toUri(),
@ -370,9 +414,25 @@ class PlaybackViewModel
subtitleIndex, subtitleIndex,
source.id?.toUUIDOrNull(), source.id?.toUUIDOrNull(),
listOf(), listOf(),
playMethod = transcodeType,
playSessionId = response.playSessionId,
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
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 duration.value = source.runTimeTicks?.ticks
stream.value = decision stream.value = decision
currentPlayback.value = playback currentPlayback.value = playback
@ -381,7 +441,7 @@ class PlaybackViewModel
positionMs, positionMs,
) )
if (audioIndex != null || subtitleIndex != null) { if (audioIndex != null || subtitleIndex != null) {
val trackActivationListener = val onTracksChangedListener =
object : Player.Listener { object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
Timber.v("onTracksChanged: $tracks") Timber.v("onTracksChanged: $tracks")
@ -400,15 +460,15 @@ class PlaybackViewModel
} }
} }
} }
player.addListener(trackActivationListener) player.addListener(onTracksChangedListener)
} }
} }
val trickPlayInfo = val trickPlayInfo =
dto.trickplay item.trickplay
?.get(source.id) ?.get(source.id)
?.values ?.values
?.firstOrNull() ?.firstOrNull()
Timber.v("Trickplay info: $trickPlayInfo") // Timber.v("Trickplay info: $trickPlayInfo")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
trickplay.value = trickPlayInfo trickplay.value = trickPlayInfo
} }
@ -419,7 +479,7 @@ class PlaybackViewModel
val itemId = currentPlayback.value?.itemId ?: return val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
changeStreams( changeStreams(
itemId, dto,
index, index,
currentPlayback.value?.subtitleIndex, currentPlayback.value?.subtitleIndex,
player.currentPosition, player.currentPosition,
@ -431,7 +491,7 @@ class PlaybackViewModel
val itemId = currentPlayback.value?.itemId ?: return val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
changeStreams( changeStreams(
itemId, dto,
currentPlayback.value?.audioIndex, currentPlayback.value?.audioIndex,
index, index,
player.currentPosition, player.currentPosition,
@ -451,56 +511,26 @@ class PlaybackViewModel
) )
} }
fun getEpisodes(seriesId: UUID) { private fun maybeSetupPlaylistListener() {
viewModelScope.launch(Dispatchers.IO) { playlist.value?.let { playlist ->
val request = if (playlist.hasNext()) {
GetEpisodesRequest( Timber.v("Adding lister for playlist with ${playlist.items.size} items")
seriesId = seriesId, val listener =
fields = DefaultItemFields, object : Player.Listener {
startItemId = itemId, override fun onPlaybackStateChanged(playbackState: Int) {
limit = 2, if (playbackState == Player.STATE_ENDED) {
) viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items val nextItem = playlist.peek()
val currentEpisodeIndex = episodes.indexOfFirstOrNull { it.id == itemId } Timber.v("Setting next up to ${nextItem?.id}")
Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}") withContext(Dispatchers.Main) {
if (currentEpisodeIndex != null) { nextUp.value = nextItem
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
}
} }
player.removeListener(this)
} }
} }
} }
player.addListener(listener) }
player.addListener(listener)
// viewModelScope.launch(Dispatchers.IO) { addCloseable { player.removeListener(listener) }
// 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
// }
// }
// }
}
} }
} }
} }
@ -564,14 +594,54 @@ class PlaybackViewModel
} }
} }
fun playUpNextEpisode() { fun playUpNextUp() {
nextUpEpisode.value?.let { playlist.value?.let {
init(Destination.Playback(it.id, 0, it), deviceProfile, preferences) if (it.hasNext()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
val item = it.getAndAdvance()
val played = play(item.data, 0)
if (!played) {
playUpNextUp()
}
}
}
}
}
fun playPrevious() {
playlist.value?.let {
if (it.hasPrevious()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
val item = it.getPreviousAndReverse()
val played = play(item.data, 0)
if (!played) {
playPrevious()
}
}
}
} }
} }
fun cancelUpNextEpisode() { fun cancelUpNextEpisode() {
nextUpEpisode.value = null nextUp.value = null
}
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
}
}
}
} }
} }
@ -581,6 +651,8 @@ data class CurrentPlayback(
val subtitleIndex: Int?, val subtitleIndex: Int?,
val mediaSourceId: UUID?, val mediaSourceId: UUID?,
val tracks: List<TrackSupport>, val tracks: List<TrackSupport>,
val playMethod: PlayMethod,
val playSessionId: String?,
) )
private val Format.idAsInt: Int? private val Format.idAsInt: Int?

View file

@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration
@Composable @Composable
fun SteppedSeekBarImpl( fun SteppedSeekBarImpl(
@ -105,6 +105,8 @@ fun IntervalSeekBarImpl(
bufferedProgress: Float, bufferedProgress: Float,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
seekBack: Duration,
seekForward: Duration,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true, enabled: Boolean = true,
@ -120,21 +122,20 @@ fun IntervalSeekBarImpl(
if (!isFocused) hasSeeked = false if (!isFocused) hasSeeked = false
} }
val offset = 30.seconds.inWholeMilliseconds
SeekBarDisplay( SeekBarDisplay(
enabled = enabled, enabled = enabled,
progress = (progressToUse.toDouble() / durationMs).toFloat(), progress = (progressToUse.toDouble() / durationMs).toFloat(),
bufferedProgress = bufferedProgress, bufferedProgress = bufferedProgress,
onLeft = { onLeft = {
controllerViewState.pulseControls() controllerViewState.pulseControls()
seekPositionMs = (progressToUse - offset).coerceAtLeast(0L) seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
hasSeeked = true hasSeeked = true
onSeek(seekPositionMs) onSeek(seekPositionMs)
}, },
onRight = { onRight = {
controllerViewState.pulseControls() controllerViewState.pulseControls()
seekPositionMs = (progressToUse + offset).coerceAtMost(durationMs) seekPositionMs =
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
hasSeeked = true hasSeeked = true
onSeek(seekPositionMs) onSeek(seekPositionMs)
}, },

View file

@ -39,6 +39,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.preferences.AppPreference import com.github.damontecres.dolphin.preferences.AppPreference
import com.github.damontecres.dolphin.preferences.AppPreferences 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 -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(

View file

@ -16,12 +16,14 @@ import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.itemsApi 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.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest 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.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
@ -271,3 +273,22 @@ val GetSuggestionsRequestHandler =
request: GetSuggestionsRequest, request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
} }
val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> {
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<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}

View file

@ -27,4 +27,6 @@ val supportedCollectionTypes =
CollectionType.MOVIES, CollectionType.MOVIES,
CollectionType.TVSHOWS, CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS, CollectionType.HOMEVIDEOS,
CollectionType.PLAYLISTS,
CollectionType.BOXSETS,
) )

View file

@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter
*/ */
fun formatDateTime(dateTime: LocalDateTime): String = fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
formatter.format(dateTime) formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) { } else if (dateTime.toString().length >= 10) {

View file

@ -3,21 +3,22 @@ package com.github.damontecres.dolphin.util
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.dolphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi 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.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo 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.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber import timber.log.Timber
import java.util.Timer import java.util.Timer
import java.util.TimerTask import java.util.TimerTask
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -29,8 +30,8 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class TrackActivityPlaybackListener( class TrackActivityPlaybackListener(
private val api: ApiClient, private val api: ApiClient,
private val itemId: UUID,
private val player: Player, private val player: Player,
var playback: CurrentPlayback,
) : Player.Listener { ) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask private val task: TimerTask
@ -41,6 +42,21 @@ class TrackActivityPlaybackListener(
private var incrementedPlayCount = AtomicBoolean(false) private var incrementedPlayCount = AtomicBoolean(false)
init { 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 saveActivityInterval = 10.seconds.inWholeMilliseconds
val delay = 1.seconds.inWholeMilliseconds val delay = 1.seconds.inWholeMilliseconds
// Every x seconds, check if the video is playing // Every x seconds, check if the video is playing
@ -74,12 +90,13 @@ class TrackActivityPlaybackListener(
fun release() { fun release() {
task.cancel() task.cancel()
TIMER.purge() TIMER.purge()
coroutineScope.launch(ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = itemId, itemId = playback.itemId,
positionTicks = player.currentPosition.milliseconds.inWholeTicks, positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks },
failed = false, failed = false,
playSessionId = playback.playSessionId,
), ),
) )
} }
@ -98,28 +115,32 @@ class TrackActivityPlaybackListener(
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED) {
Timber.Forest.v("onPlaybackStateChanged STATE_ENDED") Timber.v("onPlaybackStateChanged STATE_ENDED")
// TODO mark as watched saveActivity(player.duration)
} }
} }
private fun saveActivity(position: Long) { private fun saveActivity(position: Long) {
coroutineScope.launch(ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
val totalDuration = totalPlayDurationMilliseconds.get() // val totalDuration = totalPlayDurationMilliseconds.get()
val calcPosition = val calcPosition =
(if (position >= 0) position else player.currentPosition).milliseconds withContext(Dispatchers.Main) {
Timber.Forest.v("saveActivity: itemId=$itemId, pos=$calcPosition") (if (position >= 0) position else player.currentPosition).milliseconds
// TODO parameters }
// Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress( api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo( PlaybackProgressInfo(
itemId = itemId, itemId = playback.itemId,
positionTicks = calcPosition.inWholeTicks, positionTicks = calcPosition.inWholeTicks,
canSeek = true, canSeek = true,
isPaused = !player.isPlaying, isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
isMuted = false, isMuted = false,
playMethod = PlayMethod.DIRECT_PLAY, playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
playSessionId = playback.playSessionId,
audioStreamIndex = playback.audioIndex,
subtitleStreamIndex = playback.subtitleIndex,
), ),
) )
} }

View file

@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.Date import java.util.Date
@ -212,8 +213,18 @@ class UpdateChecker
intent.data = uri intent.data = uri
context.startActivity(intent) context.startActivity(intent)
} else { } else {
Timber.e("Resolver URI is null") Timber.e("Resolver URI is null, trying fallback")
// TODO show error Toast // 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 { } else {
if (ContextCompat.checkSelfPermission( if (ContextCompat.checkSelfPermission(
@ -234,13 +245,7 @@ class UpdateChecker
PERMISSION_REQUEST_CODE, PERMISSION_REQUEST_CODE,
) )
} else { } else {
val downloadDir = val targetFile = fallbackDownload(it)
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
it.body!!.byteStream().copyTo(output)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
@ -261,12 +266,23 @@ class UpdateChecker
} }
} else { } else {
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}") 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 * Delete previously downloaded APKs
*/ */

View file

@ -30,6 +30,7 @@ message PlaybackPreferences {
message HomePagePreferences{ message HomePagePreferences{
int32 max_items_per_row = 1; int32 max_items_per_row = 1;
bool enable_rewatching_next_up = 2; bool enable_rewatching_next_up = 2;
bool combine_continue_next = 3;
} }
enum ThemeSongVolume { enum ThemeSongVolume {

View file

@ -31,4 +31,7 @@
<string name="fa_eye" translatable="false">&#xf06e;</string> <string name="fa_eye" translatable="false">&#xf06e;</string>
<string name="fa_eye_slash" translatable="false">&#xf070;</string> <string name="fa_eye_slash" translatable="false">&#xf070;</string>
<string name="fa_arrow_left_arrow_right" translatable="false">&#xf0ec;</string> <string name="fa_arrow_left_arrow_right" translatable="false">&#xf0ec;</string>
<string name="fa_shuffle" translatable="false">&#xf074;</string>
<string name="fa_open_folder" translatable="false">&#xf07c;</string>
<string name="fa_list_ul" translatable="false">&#xf0ca;</string>
</resources> </resources>

View file

@ -74,5 +74,8 @@
<string name="update_url_summary">URL used to check for app updates</string> <string name="update_url_summary">URL used to check for app updates</string>
<string name="updates">Updates</string> <string name="updates">Updates</string>
<string name="no_update_available">No update available</string> <string name="no_update_available">No update available</string>
<string name="clear_image_cache">Clear image cache</string>
<string name="shuffle">Shuffle</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
</resources> </resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="Download"/>
</paths>

View file

@ -1,13 +1,14 @@
[versions] [versions]
aboutLibraries = "12.2.4" aboutLibraries = "12.2.4"
agp = "8.13.0" agp = "8.13.0"
desugar_jdk_libs = "2.1.5"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
kotlin = "2.2.20" kotlin = "2.2.20"
ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken ksp = "2.2.20-2.0.4"
coreKtx = "1.17.0" coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.09.01" composeBom = "2025.10.00"
compose-runtime = "1.9.2" compose-runtime = "1.9.3"
timber = "5.0.1" timber = "5.0.1"
tvFoundation = "1.0.0-alpha12" tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1" tvMaterial = "1.0.1"
@ -15,9 +16,9 @@ lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0" activityCompose = "1.11.0"
androidx-media3 = "1.8.0" androidx-media3 = "1.8.0"
coil = "3.3.0" coil = "3.3.0"
jellyfin-sdk = "1.7.0" jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0-alpha10" nav3Core = "1.0.0-alpha11"
lifecycleViewmodelNav3 = "2.10.0-alpha04" lifecycleViewmodelNav3 = "2.10.0-alpha05"
material3AdaptiveNav3 = "1.0.0-alpha03" material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.5" protobuf = "0.9.5"
datastore = "1.1.7" datastore = "1.1.7"
@ -25,7 +26,7 @@ kotlinx-coroutines-android = "1.10.2"
kotlinx-serialization = "1.9.0" kotlinx-serialization = "1.9.0"
protobuf-javalite = "4.32.1" protobuf-javalite = "4.32.1"
hilt = "2.57.2" hilt = "2.57.2"
room = "2.8.1" room = "2.8.2"
material3 = "1.4.0" material3 = "1.4.0"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
@ -51,6 +52,7 @@ androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.re
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }