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 {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "11"
@ -227,4 +228,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs)
}

View file

@ -41,6 +41,16 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</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>
</manifest>

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin
import android.app.Application
import android.util.Log
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@ -10,8 +11,25 @@ class DolphinApplication : Application() {
instance = this
if (BuildConfig.DEBUG) {
// TODO minimal logging for release builds?
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(
object : Timber.Tree() {
override fun isLoggable(
tag: String?,
priority: Int,
): Boolean = priority >= Log.INFO
override fun log(
priority: Int,
tag: String?,
message: String,
t: Throwable?,
) {
Log.println(priority, tag ?: "Dolphin", message)
}
},
)
}
}

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -5,22 +5,16 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -29,134 +23,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.Cards
import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.logCoilError
import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
@Deprecated("Old style, prefer SeasonCard or other Card")
fun ItemCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp? = null,
cardHeight: Dp? = 200.dp * .85f,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val hideOverlayDelay = 750L
val focused = interactionSource.collectIsFocusedAsState().value
var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
if (item == null) {
NullCard(modifier, cardWidth, cardHeight, interactionSource)
} else {
val dto = item.data
// TODO better aspect ratio handling
// val height =
// if (dto.primaryImageAspectRatio != null && dto.primaryImageAspectRatio!! > 1) cardWidth else cardHeight
Card(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.ifElse(cardWidth != null, { Modifier.width(cardWidth!!) }),
) {
ItemCardImage(
imageUrl = item.imageUrl,
name = item.name,
showOverlay = !focusedAfterDelay,
favorite = dto.userData?.isFavorite ?: false,
watched = dto.userData?.played ?: false,
unwatchedCount = dto.userData?.unplayedItemCount ?: -1,
watchedPercent = dto.userData?.playedPercentage,
modifier =
Modifier
.fillMaxWidth()
.ifElse(cardHeight != null, { Modifier.height(cardHeight!!) }),
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = 4.dp),
) {
Text(
text = item.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item.data.productionYear?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
if (dto.type == BaseItemKind.EPISODE) {
dto.seasonEpisode?.let {
Text(
text = it,
)
}
}
}
}
}
}
@Composable
fun ItemCardImage(

View file

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

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

View file

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

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
*/

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

View file

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

View file

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

View file

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

View file

@ -155,6 +155,7 @@ fun CollectionFolderTv(
preferences = preferences,
destination = destination,
showTitle = false,
recursive = true,
modifier =
Modifier
.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.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.convertModel
import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
@ -22,16 +20,15 @@ import java.util.UUID
/**
* Basic [ViewModel] for a single fetchable item from the API
*/
abstract class ItemViewModel<T : DolphinModel>(
abstract class ItemViewModel(
val api: ApiClient,
) : ViewModel() {
val item = MutableLiveData<BaseItem?>(null)
val model = MutableLiveData<T?>(null)
suspend fun fetchItem(
itemId: UUID,
potential: BaseItem?,
): BaseItem? =
): BaseItem =
withContext(Dispatchers.IO) {
// val fetchedItem =
// when {
@ -44,11 +41,9 @@ abstract class ItemViewModel<T : DolphinModel>(
// }
val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem?.let {
val modelInstance = convertModel(fetchedItem.data, api)
return@withContext fetchedItem.let {
withContext(Dispatchers.Main) {
item.value = fetchedItem
model.value = modelInstance as T
}
fetchedItem
}
@ -63,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
*/
abstract class LoadingItemViewModel<T : DolphinModel>(
abstract class LoadingItemViewModel(
api: ApiClient,
) : ItemViewModel<T>(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
open fun init(
itemId: UUID,
potential: BaseItem?,
): Job? =
viewModelScope.launch(
): Job? {
loading.value = LoadingState.Loading
return viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
@ -80,10 +76,8 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
) {
try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api)
withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T
loading.value = LoadingState.Success
}
} catch (e: Exception) {
@ -92,4 +86,5 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
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
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -18,7 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
@ -38,7 +32,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -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.SeasonCard
import com.github.damontecres.dolphin.ui.components.ConfirmDialog
import com.github.damontecres.dolphin.ui.components.DialogItem
import com.github.damontecres.dolphin.ui.components.DialogParams
import com.github.damontecres.dolphin.ui.components.DialogPopup
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.components.OverviewText
import com.github.damontecres.dolphin.ui.components.StarRating
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
@ -68,8 +65,6 @@ import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.rememberPosition
import com.github.damontecres.dolphin.ui.roundMinutes
import com.github.damontecres.dolphin.ui.tryRequestFocus
@ -95,6 +90,7 @@ fun SeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
@ -112,6 +108,20 @@ fun SeriesDetails(
played = played,
modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) },
onLongClickItem = { season ->
seasonDialog =
buildDialogForSeason(
season,
onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played ->
viewModel.setWatched(
season.id,
played,
null,
)
},
)
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
@ -145,6 +155,15 @@ fun SeriesDetails(
onDismissRequest = { overviewDialog = null },
)
}
seasonDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
waitToLoad = params.fromLongClick,
onDismissRequest = { seasonDialog = null },
)
}
}
@Composable
@ -155,6 +174,7 @@ fun SeriesDetailsContent(
people: List<Person>,
played: Boolean,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
@ -230,7 +250,7 @@ fun SeriesDetailsContent(
title = "Seasons",
items = seasons.items,
onClickItem = onClickItem,
onLongClickItem = { },
onLongClickItem = onLongClickItem,
cardOnFocus = { isFocused, index ->
// if (isFocused) {
// scope.launch(ExceptionHandler()) {
@ -251,6 +271,7 @@ fun SeriesDetailsContent(
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
showImageOverlay = true,
modifier =
mod
.ifElse(
@ -333,40 +354,12 @@ fun SeriesDetailsHeader(
}
dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.background(bgColor, shape = RoundedCornerShape(8.dp))
.playSoundOnFocus(true)
.clickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
playOnClickSound(context)
overviewOnClick.invoke()
},
) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(8.dp)
.height(60.dp),
)
}
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -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 com.github.damontecres.dolphin.data.model.BaseItem
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.preferences.ThemeSongVolume
import com.github.damontecres.dolphin.preferences.UserPreferences
@ -58,13 +57,13 @@ class SeriesViewModel
@param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
private val navigationManager: NavigationManager,
) : ItemViewModel<Video>(api) {
) : ItemViewModel(api) {
private var player: Player? = null
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val people = MutableLiveData<List<Person>>(listOf())
fun init(
@ -83,33 +82,25 @@ class SeriesViewModel
) + Dispatchers.IO,
) {
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
val episodeInfo =
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum ->
loadEpisodesInternal(seasonNum)
} ?: ItemListAndMapping.empty()
withContext(Dispatchers.Main) {
seasons.value = seasonsInfo
episodes.value = episodeInfo
loading.value = LoadingState.Success
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.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")
}
// If a particular season was requested, fetch those episodes, otherwise get the first season
val episodeInfo =
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum ->
loadEpisodesInternal(seasonNum)
} ?: EpisodeList.Error("Could not determine season")
withContext(Dispatchers.Main) {
seasons.value = seasonsInfo
episodes.value = episodeInfo
loading.value = LoadingState.Success
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
}
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
}
}
@ -204,7 +195,7 @@ class SeriesViewModel
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
}
private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping {
private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
val request =
GetEpisodesRequest(
seriesId = item.value!!.id,
@ -223,35 +214,38 @@ class SeriesViewModel
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init()
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)) {
val episodes =
try {
loadEpisodesInternal(season)
} catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI?
ItemListAndMapping.empty()
EpisodeList.Error(e)
}
withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes
}
}
}
fun setWatched(
itemId: UUID,
played: Boolean,
listIndex: Int,
listIndex: Int?,
) = viewModelScope.launch(ExceptionHandler()) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
refreshEpisode(itemId, listIndex)
listIndex?.let {
refreshEpisode(itemId, listIndex)
}
}
fun setWatchedSeries(played: Boolean) =
@ -271,14 +265,15 @@ class SeriesViewModel
val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!!
withContext(Dispatchers.Main) {
episodes.value =
eps.copy(
items =
eps.items.toMutableList().apply {
this[listIndex] = item
},
)
if (eps is EpisodeList.Success) {
val newItems =
eps.episodes.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
*/
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs =
pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index)
val item = pager.getBlocking(index)
Pair(item?.indexNumber ?: index, index)
}
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
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.tv.material3.Button
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.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
@ -32,7 +31,7 @@ class VideoViewModel
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : LoadingItemViewModel<Video>(api)
) : LoadingItemViewModel(api)
@Composable
fun VideoDetails(

View file

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

View file

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

View file

@ -1,23 +1,13 @@
package com.github.damontecres.dolphin.ui.detail.series
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -25,10 +15,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.OverviewText
import com.github.damontecres.dolphin.ui.components.StarRating
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.roundMinutes
import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.util.formatDateTime
@ -50,6 +39,8 @@ fun FocusedEpisodeHeader(
Text(
text = dto.episodeTitle ?: dto.name ?: "",
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
Row(
@ -91,42 +82,12 @@ fun FocusedEpisodeHeader(
} else {
Spacer(Modifier.height(24.dp))
}
}
}
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.background(bgColor, shape = RoundedCornerShape(8.dp))
.playSoundOnFocus(true)
.clickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
playOnClickSound(context)
overviewOnClick.invoke()
},
) {
Text(
text = dto.overview ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(8.dp)
.height(60.dp),
)
} ?: Spacer(Modifier.height(24.dp))
}
OverviewText(
overview = dto.overview ?: "",
maxLines = 3,
onClick = overviewOnClick,
)
}
}

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.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -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.data.ItemDetailsDialog
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.SeriesViewModel
import com.github.damontecres.dolphin.ui.nav.Destination
@ -76,7 +76,8 @@ fun SeriesOverview(
val series by viewModel.item.observeAsState(null)
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(
destination,
@ -90,7 +91,10 @@ fun SeriesOverview(
mutableStateOf(
SeriesOverviewPosition(
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 moreDialog by remember { mutableStateOf<DialogParams?>(null) }
LaunchedEffect(episodes.items) {
if (episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons?
LaunchedEffect(episodes) {
episodes?.let { episodes ->
if (episodes is EpisodeList.Success) {
if (episodes.episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons?
// firstItemFocusRequester.requestFocus()
episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
}
}
}
}
}
@ -121,7 +129,7 @@ fun SeriesOverview(
SeriesOverviewContent(
series = series,
seasons = seasons.items,
episodes = episodes.items,
episodes = episodes,
position = position,
backdropImageUrl =
remember {
@ -141,7 +149,6 @@ fun SeriesOverview(
position = it
},
onClick = {
viewModel.release()
val resumePosition =
it.data.userData
?.playbackPositionTicks
@ -158,7 +165,7 @@ fun SeriesOverview(
// TODO
},
playOnClick = { resume ->
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
viewModel.navigateTo(
Destination.Playback(
@ -170,13 +177,13 @@ fun SeriesOverview(
}
},
watchOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
moreOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { ep ->
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog =
DialogParams(
fromLongClick = false,
@ -188,7 +195,6 @@ fun SeriesOverview(
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
viewModel.release()
viewModel.navigateTo(
Destination.Playback(
ep.id,
@ -202,7 +208,6 @@ fun SeriesOverview(
Icons.AutoMirrored.Filled.ArrowForward,
// iconColor = Color.Green.copy(alpha = .8f),
) {
viewModel.release()
viewModel.navigateTo(
Destination.MediaItem(
series.id,
@ -211,26 +216,26 @@ fun SeriesOverview(
),
)
},
DialogItem(
"Playback Settings",
Icons.Default.Settings,
// DialogItem(
// "Playback Settings",
// Icons.Default.Settings,
// // iconColor = Color.Green.copy(alpha = .8f),
// ) {
// // TODO choose audio or subtitle tracks?
// },
// DialogItem(
// "Play Version",
// Icons.Default.PlayArrow,
// iconColor = Color.Green.copy(alpha = .8f),
) {
// TODO choose audio or subtitle tracks?
},
DialogItem(
"Play Version",
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
// TODO only show for multiple files
},
// ) {
// // TODO only show for multiple files
// },
),
)
}
},
overviewOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog =
ItemDetailsDialogInfo(
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
@ -34,6 +33,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
@ -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.ui.OneTimeLaunchedEffect
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.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.formatDateTime
import kotlin.time.Duration
@Composable
fun SeriesOverviewContent(
series: BaseItem,
seasons: List<BaseItem?>,
episodes: List<BaseItem?>,
episodes: EpisodeList,
position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
@ -76,9 +80,8 @@ fun SeriesOverviewContent(
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
LaunchedEffect(position) {
}
val focusedEpisode =
(episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
Box(
modifier =
@ -184,6 +187,8 @@ fun SeriesOverviewContent(
Text(
text = it,
style = MaterialTheme.typography.headlineMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
}
@ -200,56 +205,74 @@ fun SeriesOverviewContent(
}
item {
key(position.seasonTabIndex) {
val state = rememberLazyListState()
OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex)
firstItemFocusRequester.tryRequestFocus()
}
}
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
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,
),
)
when (val eps = episodes) {
EpisodeList.Loading -> LoadingPage()
is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
is EpisodeList.Success -> {
val state = rememberLazyListState()
OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex)
}
firstItemFocusRequester.tryRequestFocus()
}
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 = "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) },
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
Modifier.ifElse(
episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester),
),
interactionSource = interactionSource,
cardHeight = 120.dp,
)
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester),
) {
itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
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.util.LoadingState
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.extensions.ticks
@ -121,7 +122,7 @@ fun HomePageContent(
Modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.4f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
@ -231,7 +232,13 @@ fun MainPageHeader(
val details =
buildList {
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) {
dto.premiereDate?.let { add(formatDateTime(it)) }
@ -249,12 +256,16 @@ fun MainPageHeader(
Text(
text = title,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
subtitle?.let {
Text(
text = subtitle,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (details.isNotEmpty()) {
@ -267,13 +278,13 @@ fun MainPageHeader(
val overviewModifier =
Modifier
.padding(0.dp)
.height(48.dp)
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
if (overview.isNotNullOrBlank()) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
maxLines = if (isEpisode) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = overviewModifier,
)

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.preferences.AppPreference
import com.github.damontecres.dolphin.preferences.AppPreferences
@ -245,6 +246,22 @@ fun PreferencesContent(
)
}
AppPreference.ClearImageCache -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
SingletonImageLoader.get(context).let {
it.memoryCache?.clear()
it.diskCache?.clear()
}
},
modifier = Modifier,
summary = null,
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(

View file

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

View file

@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter
*/
fun formatDateTime(dateTime: LocalDateTime): String =
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")
formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) {

View file

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

View file

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

View file

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

View file

@ -31,4 +31,7 @@
<string name="fa_eye" translatable="false">&#xf06e;</string>
<string name="fa_eye_slash" translatable="false">&#xf070;</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>

View file

@ -74,5 +74,8 @@
<string name="update_url_summary">URL used to check for app updates</string>
<string name="updates">Updates</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>

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]
aboutLibraries = "12.2.4"
agp = "8.13.0"
desugar_jdk_libs = "2.1.5"
hiltNavigationCompose = "1.3.0"
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"
appcompat = "1.7.1"
composeBom = "2025.09.01"
compose-runtime = "1.9.2"
composeBom = "2025.10.00"
compose-runtime = "1.9.3"
timber = "5.0.1"
tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1"
@ -15,9 +16,9 @@ lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
androidx-media3 = "1.8.0"
coil = "3.3.0"
jellyfin-sdk = "1.7.0"
nav3Core = "1.0.0-alpha10"
lifecycleViewmodelNav3 = "2.10.0-alpha04"
jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0-alpha11"
lifecycleViewmodelNav3 = "2.10.0-alpha05"
material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.5"
datastore = "1.1.7"
@ -25,7 +26,7 @@ kotlinx-coroutines-android = "1.10.2"
kotlinx-serialization = "1.9.0"
protobuf-javalite = "4.32.1"
hilt = "2.57.2"
room = "2.8.1"
room = "2.8.2"
material3 = "1.4.0"
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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
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-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" }