mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into icon
This commit is contained in:
commit
bf4dfe4429
62 changed files with 1725 additions and 1131 deletions
24
CONTRIBUTING.md
Normal file
24
CONTRIBUTING.md
Normal 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
26
DEVELOPMENT.md
Normal 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).
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!!,
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class AppPreferencesSerializer
|
|||
.apply {
|
||||
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
|
||||
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
|
||||
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
|
||||
}.build()
|
||||
interfacePreferences =
|
||||
InterfacePreferences
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ val DefaultItemFields =
|
|||
ItemFields.OVERVIEW,
|
||||
ItemFields.TRICKPLAY,
|
||||
ItemFields.SORT_NAME,
|
||||
ItemFields.CHAPTERS,
|
||||
)
|
||||
|
||||
val DefaultButtonPadding =
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ fun CollectionFolderMovie(
|
|||
onClickItem = onClickItem,
|
||||
destination = destination,
|
||||
showTitle = false,
|
||||
recursive = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ fun CollectionFolderTv(
|
|||
preferences = preferences,
|
||||
destination = destination,
|
||||
showTitle = false,
|
||||
recursive = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// },
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,4 +27,6 @@ val supportedCollectionTypes =
|
|||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
CollectionType.PLAYLISTS,
|
||||
CollectionType.BOXSETS,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -31,4 +31,7 @@
|
|||
<string name="fa_eye" translatable="false"></string>
|
||||
<string name="fa_eye_slash" translatable="false"></string>
|
||||
<string name="fa_arrow_left_arrow_right" translatable="false"></string>
|
||||
<string name="fa_shuffle" translatable="false"></string>
|
||||
<string name="fa_open_folder" translatable="false"></string>
|
||||
<string name="fa_list_ul" translatable="false"></string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
app/src/main/res/xml/provider_paths.xml
Normal file
4
app/src/main/res/xml/provider_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path name="external_files" path="Download"/>
|
||||
</paths>
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue