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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "11"
|
||||||
|
|
@ -227,4 +228,5 @@ dependencies {
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,16 @@
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.dolphin
|
package com.github.damontecres.dolphin
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -10,8 +11,25 @@ class DolphinApplication : Application() {
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
// TODO minimal logging for release builds?
|
|
||||||
Timber.plant(Timber.DebugTree())
|
Timber.plant(Timber.DebugTree())
|
||||||
|
} else {
|
||||||
|
Timber.plant(
|
||||||
|
object : Timber.Tree() {
|
||||||
|
override fun isLoggable(
|
||||||
|
tag: String?,
|
||||||
|
priority: Int,
|
||||||
|
): Boolean = priority >= Log.INFO
|
||||||
|
|
||||||
|
override fun log(
|
||||||
|
priority: Int,
|
||||||
|
tag: String?,
|
||||||
|
message: String,
|
||||||
|
t: Throwable?,
|
||||||
|
) {
|
||||||
|
Log.println(priority, tag ?: "Dolphin", message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,17 @@ interface JellyfinServerDao {
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
fun addServer(server: JellyfinServer): Long
|
fun addServer(server: JellyfinServer): Long
|
||||||
|
|
||||||
@Update(onConflict = OnConflictStrategy.ABORT)
|
@Update
|
||||||
fun updateServer(server: JellyfinServer): Int
|
fun updateServer(server: JellyfinServer): Int
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
fun addOrUpdateServer(server: JellyfinServer) {
|
||||||
|
val result = addServer(server)
|
||||||
|
if (result == -1L) {
|
||||||
|
updateServer(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun addUser(user: JellyfinUser)
|
fun addUser(user: JellyfinUser)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
|
@ -42,7 +43,7 @@ class ServerRepository
|
||||||
*/
|
*/
|
||||||
suspend fun addAndChangeServer(server: JellyfinServer) {
|
suspend fun addAndChangeServer(server: JellyfinServer) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
serverDao.addServer(server)
|
serverDao.addOrUpdateServer(server)
|
||||||
}
|
}
|
||||||
changeServer(server)
|
changeServer(server)
|
||||||
}
|
}
|
||||||
|
|
@ -74,14 +75,15 @@ class ServerRepository
|
||||||
apiClient.userApi
|
apiClient.userApi
|
||||||
.getCurrentUser()
|
.getCurrentUser()
|
||||||
.content
|
.content
|
||||||
|
val sysInfo by apiClient.systemApi.getSystemInfo()
|
||||||
|
|
||||||
val updatedServer = server.copy(name = userDto.serverName)
|
val updatedServer = server.copy(name = sysInfo.serverName)
|
||||||
val updatedUser =
|
val updatedUser =
|
||||||
user.copy(
|
user.copy(
|
||||||
id = userDto.id.toString(),
|
id = userDto.id.toString(),
|
||||||
name = userDto.name,
|
name = userDto.name,
|
||||||
)
|
)
|
||||||
serverDao.addServer(updatedServer)
|
serverDao.addOrUpdateServer(updatedServer)
|
||||||
serverDao.addUser(updatedUser)
|
serverDao.addUser(updatedUser)
|
||||||
userPreferencesDataStore.updateData {
|
userPreferencesDataStore.updateData {
|
||||||
it
|
it
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.dolphin.data.model
|
||||||
|
|
||||||
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
|
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.dolphin.util.seasonEpisode
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -24,7 +25,11 @@ data class BaseItem(
|
||||||
@Transient val name = data.name
|
@Transient val name = data.name
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
val indexNumber = data.indexNumber
|
val title = if (type == BaseItemKind.EPISODE) data.seriesName else name
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
val subtitle =
|
||||||
|
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
val resumeMs =
|
val resumeMs =
|
||||||
|
|
@ -33,12 +38,23 @@ data class BaseItem(
|
||||||
?.ticks
|
?.ticks
|
||||||
?.inWholeMilliseconds
|
?.inWholeMilliseconds
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
val indexNumber = data.indexNumber ?: dateAsIndex()
|
||||||
|
|
||||||
|
private fun dateAsIndex(): Int? =
|
||||||
|
data.premiereDate
|
||||||
|
?.let {
|
||||||
|
it.year.toString() +
|
||||||
|
it.monthValue.toString().padStart(2, '0') +
|
||||||
|
it.dayOfMonth.toString().padStart(2, '0')
|
||||||
|
}?.toIntOrNull()
|
||||||
|
|
||||||
fun destination(): Destination {
|
fun destination(): Destination {
|
||||||
val result =
|
val result =
|
||||||
// Redirect episodes & seasons to their series if possible
|
// Redirect episodes & seasons to their series if possible
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.EPISODE -> {
|
BaseItemKind.EPISODE -> {
|
||||||
data.indexNumber?.let { episode ->
|
indexNumber?.let { episode ->
|
||||||
data.parentIndexNumber?.let { season ->
|
data.parentIndexNumber?.let { season ->
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
data.seriesId!!,
|
||||||
|
|
|
||||||
|
|
@ -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() },
|
summarizer = { value -> value?.toString() },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val CombineContinueNext =
|
||||||
|
AppSwitchPreference(
|
||||||
|
title = R.string.combine_continue_next,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.homePagePreferences.combineContinueNext },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateHomePagePreferences { combineContinueNext = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val RewatchNextUp =
|
val RewatchNextUp =
|
||||||
AppSwitchPreference(
|
AppSwitchPreference(
|
||||||
title = R.string.rewatch_next_up,
|
title = R.string.rewatch_next_up,
|
||||||
|
|
@ -439,6 +451,13 @@ sealed interface AppPreference<T> {
|
||||||
indexToValue = { SkipSegmentBehavior.forNumber(it) },
|
indexToValue = { SkipSegmentBehavior.forNumber(it) },
|
||||||
valueToIndex = { it.number },
|
valueToIndex = { it.number },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ClearImageCache =
|
||||||
|
AppClickablePreference(
|
||||||
|
title = R.string.clear_image_cache,
|
||||||
|
getter = { },
|
||||||
|
setter = { prefs, _ -> prefs },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,6 +469,7 @@ val basicPreferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.HomePageItems,
|
AppPreference.HomePageItems,
|
||||||
AppPreference.RewatchNextUp,
|
AppPreference.RewatchNextUp,
|
||||||
|
AppPreference.CombineContinueNext,
|
||||||
AppPreference.PlayThemeMusic,
|
AppPreference.PlayThemeMusic,
|
||||||
AppPreference.RememberSelectedTab,
|
AppPreference.RememberSelectedTab,
|
||||||
AppPreference.ThemeColors,
|
AppPreference.ThemeColors,
|
||||||
|
|
@ -513,6 +533,7 @@ val advancedPreferences =
|
||||||
title = R.string.more,
|
title = R.string.more,
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
|
AppPreference.ClearImageCache,
|
||||||
AppPreference.OssLicenseInfo,
|
AppPreference.OssLicenseInfo,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ class AppPreferencesSerializer
|
||||||
.apply {
|
.apply {
|
||||||
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
|
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
|
||||||
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
|
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
|
||||||
|
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
interfacePreferences =
|
interfacePreferences =
|
||||||
InterfacePreferences
|
InterfacePreferences
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ val DefaultItemFields =
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.TRICKPLAY,
|
ItemFields.TRICKPLAY,
|
||||||
ItemFields.SORT_NAME,
|
ItemFields.SORT_NAME,
|
||||||
|
ItemFields.CHAPTERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
val DefaultButtonPadding =
|
val DefaultButtonPadding =
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ fun GridCard(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item?.name ?: "",
|
text = item?.title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|
@ -109,7 +109,7 @@ fun GridCard(
|
||||||
.enableMarquee(focusedAfterDelay),
|
.enableMarquee(focusedAfterDelay),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item?.data?.productionYear?.toString() ?: "",
|
text = item?.subtitle ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,16 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -29,134 +23,23 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Card
|
|
||||||
import androidx.tv.material3.CardDefaults
|
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.dolphin.R
|
import com.github.damontecres.dolphin.R
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.dolphin.ui.Cards
|
import com.github.damontecres.dolphin.ui.Cards
|
||||||
import com.github.damontecres.dolphin.ui.FontAwesome
|
import com.github.damontecres.dolphin.ui.FontAwesome
|
||||||
import com.github.damontecres.dolphin.ui.enableMarquee
|
|
||||||
import com.github.damontecres.dolphin.ui.ifElse
|
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.logCoilError
|
import com.github.damontecres.dolphin.ui.logCoilError
|
||||||
import com.github.damontecres.dolphin.util.seasonEpisode
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Deprecated("Old style, prefer SeasonCard or other Card")
|
|
||||||
fun ItemCard(
|
|
||||||
item: BaseItem?,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
cardWidth: Dp? = null,
|
|
||||||
cardHeight: Dp? = 200.dp * .85f,
|
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
) {
|
|
||||||
val hideOverlayDelay = 750L
|
|
||||||
|
|
||||||
val focused = interactionSource.collectIsFocusedAsState().value
|
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (focused) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(hideOverlayDelay)
|
|
||||||
if (focused) {
|
|
||||||
focusedAfterDelay = true
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item == null) {
|
|
||||||
NullCard(modifier, cardWidth, cardHeight, interactionSource)
|
|
||||||
} else {
|
|
||||||
val dto = item.data
|
|
||||||
// TODO better aspect ratio handling
|
|
||||||
// val height =
|
|
||||||
// if (dto.primaryImageAspectRatio != null && dto.primaryImageAspectRatio!! > 1) cardWidth else cardHeight
|
|
||||||
Card(
|
|
||||||
modifier = modifier,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
colors =
|
|
||||||
CardDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.ifElse(cardWidth != null, { Modifier.width(cardWidth!!) }),
|
|
||||||
) {
|
|
||||||
ItemCardImage(
|
|
||||||
imageUrl = item.imageUrl,
|
|
||||||
name = item.name,
|
|
||||||
showOverlay = !focusedAfterDelay,
|
|
||||||
favorite = dto.userData?.isFavorite ?: false,
|
|
||||||
watched = dto.userData?.played ?: false,
|
|
||||||
unwatchedCount = dto.userData?.unplayedItemCount ?: -1,
|
|
||||||
watchedPercent = dto.userData?.playedPercentage,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.ifElse(cardHeight != null, { Modifier.height(cardHeight!!) }),
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = item.name ?: "",
|
|
||||||
maxLines = 1,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
.enableMarquee(focusedAfterDelay),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.data.productionYear?.toString() ?: "",
|
|
||||||
maxLines = 1,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
.enableMarquee(focusedAfterDelay),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (dto.type == BaseItemKind.EPISODE) {
|
|
||||||
dto.seasonEpisode?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemCardImage(
|
fun ItemCardImage(
|
||||||
|
|
@ -29,21 +29,14 @@ fun ItemRow(
|
||||||
items: List<BaseItem?>,
|
items: List<BaseItem?>,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
onLongClickItem: (BaseItem) -> Unit,
|
onLongClickItem: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
cardContent: @Composable (
|
cardContent: @Composable (
|
||||||
index: Int,
|
index: Int,
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) -> Unit = { index, item, mod, onClick, onLongClick ->
|
) -> Unit,
|
||||||
ItemCard(
|
modifier: Modifier = Modifier,
|
||||||
item = item,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
modifier = mod,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
focusPair: FocusPair? = null,
|
focusPair: FocusPair? = null,
|
||||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
imageHeight: Dp = Dp.Unspecified,
|
||||||
imageWidth: Dp = Dp.Unspecified,
|
imageWidth: Dp = Dp.Unspecified,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
showImageOverlay: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val dto = item?.data
|
val dto = item?.data
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
|
@ -89,7 +90,7 @@ fun SeasonCard(
|
||||||
ItemCardImage(
|
ItemCardImage(
|
||||||
imageUrl = item?.imageUrl,
|
imageUrl = item?.imageUrl,
|
||||||
name = item?.name,
|
name = item?.name,
|
||||||
showOverlay = false,
|
showOverlay = showImageOverlay,
|
||||||
favorite = dto?.userData?.isFavorite ?: false,
|
favorite = dto?.userData?.isFavorite ?: false,
|
||||||
watched = dto?.userData?.played ?: false,
|
watched = dto?.userData?.played ?: false,
|
||||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||||
|
|
@ -109,7 +110,7 @@ fun SeasonCard(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = dto?.name ?: "",
|
text = item?.title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -119,7 +120,7 @@ fun SeasonCard(
|
||||||
.enableMarquee(focusedAfterDelay),
|
.enableMarquee(focusedAfterDelay),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item?.data?.productionYear?.toString() ?: "",
|
text = item?.subtitle ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Library
|
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
||||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||||
|
|
@ -63,7 +62,7 @@ class CollectionFolderViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
) : ItemViewModel<Library>(api) {
|
) : ItemViewModel(api) {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val pager = MutableLiveData<List<BaseItem?>>(listOf())
|
val pager = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
||||||
|
|
@ -72,6 +71,7 @@ class CollectionFolderViewModel
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
potential: BaseItem?,
|
potential: BaseItem?,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
|
recursive: Boolean,
|
||||||
): Job =
|
): Job =
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
|
|
@ -80,10 +80,13 @@ class CollectionFolderViewModel
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
fetchItem(itemId, potential)
|
fetchItem(itemId, potential)
|
||||||
loadResults(sortAndDirection)
|
loadResults(sortAndDirection, recursive)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadResults(sortAndDirection: SortAndDirection) {
|
fun loadResults(
|
||||||
|
sortAndDirection: SortAndDirection,
|
||||||
|
recursive: Boolean,
|
||||||
|
) {
|
||||||
item.value?.let { item ->
|
item.value?.let { item ->
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -96,28 +99,47 @@ class CollectionFolderViewModel
|
||||||
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
|
||||||
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
|
||||||
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
|
||||||
|
CollectionType.MUSIC ->
|
||||||
|
listOf(
|
||||||
|
BaseItemKind.AUDIO,
|
||||||
|
BaseItemKind.MUSIC_ARTIST,
|
||||||
|
BaseItemKind.MUSIC_ALBUM,
|
||||||
|
)
|
||||||
|
|
||||||
|
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
|
||||||
|
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
|
||||||
|
|
||||||
else -> listOf()
|
else -> listOf()
|
||||||
}
|
}
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = item.id,
|
parentId = item.id,
|
||||||
isSeries = true,
|
|
||||||
mediaTypes = null,
|
|
||||||
// recursive = true,
|
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
|
recursive = recursive,
|
||||||
|
excludeItemIds = listOf(item.id),
|
||||||
sortBy =
|
sortBy =
|
||||||
listOf(
|
listOf(
|
||||||
sortAndDirection.sort,
|
sortAndDirection.sort,
|
||||||
ItemSortBy.SORT_NAME,
|
ItemSortBy.SORT_NAME,
|
||||||
ItemSortBy.PRODUCTION_YEAR,
|
ItemSortBy.PRODUCTION_YEAR,
|
||||||
),
|
),
|
||||||
sortOrder = listOf(sortAndDirection.direction),
|
sortOrder =
|
||||||
|
listOf(
|
||||||
|
sortAndDirection.direction,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
|
),
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
)
|
)
|
||||||
val newPager =
|
val newPager =
|
||||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = true,
|
||||||
|
)
|
||||||
newPager.init()
|
newPager.init()
|
||||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -145,6 +167,7 @@ class CollectionFolderViewModel
|
||||||
nameLessThan = letter.toString(),
|
nameLessThan = letter.toString(),
|
||||||
limit = 0,
|
limit = 0,
|
||||||
enableTotalRecordCount = true,
|
enableTotalRecordCount = true,
|
||||||
|
recursive = true,
|
||||||
)
|
)
|
||||||
val result by GetItemsRequestHandler.execute(api, request)
|
val result by GetItemsRequestHandler.execute(api, request)
|
||||||
result.totalRecordCount
|
result.totalRecordCount
|
||||||
|
|
@ -160,6 +183,7 @@ class CollectionFolderViewModel
|
||||||
fun CollectionFolderGrid(
|
fun CollectionFolderGrid(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
|
recursive: Boolean,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
viewModel: CollectionFolderViewModel = hiltViewModel(),
|
||||||
|
|
@ -172,12 +196,11 @@ fun CollectionFolderGrid(
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init(destination.itemId, destination.item, initialSortAndDirection)
|
viewModel.init(destination.itemId, destination.item, initialSortAndDirection, recursive)
|
||||||
}
|
}
|
||||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
|
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
val library by viewModel.model.observeAsState()
|
|
||||||
val pager by viewModel.pager.observeAsState()
|
val pager by viewModel.pager.observeAsState()
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
|
|
@ -189,14 +212,13 @@ fun CollectionFolderGrid(
|
||||||
pager?.let { pager ->
|
pager?.let { pager ->
|
||||||
CollectionFolderGridContent(
|
CollectionFolderGridContent(
|
||||||
preferences,
|
preferences,
|
||||||
library!!,
|
|
||||||
item!!,
|
item!!,
|
||||||
pager,
|
pager,
|
||||||
sortAndDirection = sortAndDirection,
|
sortAndDirection = sortAndDirection,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onSortChange = {
|
onSortChange = {
|
||||||
viewModel.loadResults(it)
|
viewModel.loadResults(it, recursive)
|
||||||
},
|
},
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
positionCallback = positionCallback,
|
positionCallback = positionCallback,
|
||||||
|
|
@ -210,7 +232,6 @@ fun CollectionFolderGrid(
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionFolderGridContent(
|
fun CollectionFolderGridContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
library: Library,
|
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
pager: List<BaseItem?>,
|
pager: List<BaseItem?>,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
|
|
@ -221,7 +242,7 @@ fun CollectionFolderGridContent(
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection"
|
val title = item.name ?: item.data.collectionType?.name ?: "Collection"
|
||||||
val sortOptions =
|
val sortOptions =
|
||||||
when (item.data.collectionType) {
|
when (item.data.collectionType) {
|
||||||
CollectionType.MOVIES -> MovieSortOptions
|
CollectionType.MOVIES -> MovieSortOptions
|
||||||
|
|
@ -262,7 +283,7 @@ fun CollectionFolderGridContent(
|
||||||
CardGrid(
|
CardGrid(
|
||||||
pager = pager,
|
pager = pager,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
longClicker = {},
|
onLongClickItem = {},
|
||||||
letterPosition = letterPosition,
|
letterPosition = letterPosition,
|
||||||
gridFocusRequester = gridFocusRequester,
|
gridFocusRequester = gridFocusRequester,
|
||||||
showJumpButtons = false, // TODO add preference
|
showJumpButtons = false, // TODO add preference
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,24 @@ fun DialogPopup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DialogPopup(
|
||||||
|
params: DialogParams,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
dismissOnClick: Boolean = true,
|
||||||
|
properties: DialogProperties = DialogProperties(),
|
||||||
|
elevation: Dp = 3.dp,
|
||||||
|
) = DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissOnClick = dismissOnClick,
|
||||||
|
properties = properties,
|
||||||
|
elevation = elevation,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dialog that can be scrolled, typically for longer text content
|
* A dialog that can be scrolled, typically for longer text content
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.FocusState
|
import androidx.compose.ui.focus.FocusState
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
|
@ -29,7 +28,6 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.R
|
import com.github.damontecres.dolphin.R
|
||||||
import com.github.damontecres.dolphin.ui.PreviewTvSpec
|
import com.github.damontecres.dolphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
|
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -139,14 +137,10 @@ fun ExpandablePlayButtons(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
.focusProperties {
|
.focusRestorer(firstFocus),
|
||||||
onEnter = {
|
|
||||||
firstFocus.tryRequestFocus()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
if (resumePosition > Duration.ZERO) {
|
if (resumePosition > Duration.ZERO) {
|
||||||
item {
|
item("play") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.resume,
|
R.string.resume,
|
||||||
resumePosition,
|
resumePosition,
|
||||||
|
|
@ -157,7 +151,7 @@ fun ExpandablePlayButtons(
|
||||||
.focusRequester(firstFocus),
|
.focusRequester(firstFocus),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item("restart") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.restart,
|
R.string.restart,
|
||||||
Duration.ZERO,
|
Duration.ZERO,
|
||||||
|
|
@ -168,7 +162,7 @@ fun ExpandablePlayButtons(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item {
|
item("play") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.play,
|
R.string.play,
|
||||||
Duration.ZERO,
|
Duration.ZERO,
|
||||||
|
|
@ -182,7 +176,7 @@ fun ExpandablePlayButtons(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watched button
|
// Watched button
|
||||||
item {
|
item("watched") {
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
|
||||||
|
|
@ -192,7 +186,7 @@ fun ExpandablePlayButtons(
|
||||||
}
|
}
|
||||||
|
|
||||||
// More button
|
// More button
|
||||||
item {
|
item("more") {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
R.string.more,
|
R.string.more,
|
||||||
Duration.ZERO,
|
Duration.ZERO,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ private const val DEBUG = false
|
||||||
fun CardGrid(
|
fun CardGrid(
|
||||||
pager: List<BaseItem?>,
|
pager: List<BaseItem?>,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
longClicker: (BaseItem) -> Unit,
|
onLongClickItem: (BaseItem) -> Unit,
|
||||||
letterPosition: suspend (Char) -> Int,
|
letterPosition: suspend (Char) -> Int,
|
||||||
gridFocusRequester: FocusRequester,
|
gridFocusRequester: FocusRequester,
|
||||||
showJumpButtons: Boolean,
|
showJumpButtons: Boolean,
|
||||||
|
|
@ -257,7 +257,7 @@ fun CardGrid(
|
||||||
onClickItem.invoke(item)
|
onClickItem.invoke(item)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClick = { if (item != null) longClicker.invoke(item) },
|
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
|
||||||
modifier =
|
modifier =
|
||||||
mod
|
mod
|
||||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import com.github.damontecres.dolphin.ui.preferences.PreferencesViewModel
|
||||||
fun CollectionFolderGeneric(
|
fun CollectionFolderGeneric(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
|
recursive: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -27,6 +28,7 @@ fun CollectionFolderGeneric(
|
||||||
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
|
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
|
||||||
destination = destination,
|
destination = destination,
|
||||||
showTitle = showHeader,
|
showTitle = showHeader,
|
||||||
|
recursive = recursive,
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.padding(start = 16.dp),
|
.padding(start = 16.dp),
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ fun CollectionFolderMovie(
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
showTitle = false,
|
showTitle = false,
|
||||||
|
recursive = true,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ fun CollectionFolderTv(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
showTitle = false,
|
showTitle = false,
|
||||||
|
recursive = true,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
|
|
|
||||||
|
|
@ -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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.DolphinModel
|
|
||||||
import com.github.damontecres.dolphin.data.model.convertModel
|
|
||||||
import com.github.damontecres.dolphin.util.LoadingExceptionHandler
|
import com.github.damontecres.dolphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.dolphin.util.LoadingState
|
import com.github.damontecres.dolphin.util.LoadingState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -22,16 +20,15 @@ import java.util.UUID
|
||||||
/**
|
/**
|
||||||
* Basic [ViewModel] for a single fetchable item from the API
|
* Basic [ViewModel] for a single fetchable item from the API
|
||||||
*/
|
*/
|
||||||
abstract class ItemViewModel<T : DolphinModel>(
|
abstract class ItemViewModel(
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
val item = MutableLiveData<BaseItem?>(null)
|
||||||
val model = MutableLiveData<T?>(null)
|
|
||||||
|
|
||||||
suspend fun fetchItem(
|
suspend fun fetchItem(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
potential: BaseItem?,
|
potential: BaseItem?,
|
||||||
): BaseItem? =
|
): BaseItem =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// val fetchedItem =
|
// val fetchedItem =
|
||||||
// when {
|
// when {
|
||||||
|
|
@ -44,11 +41,9 @@ abstract class ItemViewModel<T : DolphinModel>(
|
||||||
// }
|
// }
|
||||||
val it = api.userLibraryApi.getItem(itemId).content
|
val it = api.userLibraryApi.getItem(itemId).content
|
||||||
val fetchedItem = BaseItem.from(it, api)
|
val fetchedItem = BaseItem.from(it, api)
|
||||||
return@withContext fetchedItem?.let {
|
return@withContext fetchedItem.let {
|
||||||
val modelInstance = convertModel(fetchedItem.data, api)
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
item.value = fetchedItem
|
item.value = fetchedItem
|
||||||
model.value = modelInstance as T
|
|
||||||
}
|
}
|
||||||
fetchedItem
|
fetchedItem
|
||||||
}
|
}
|
||||||
|
|
@ -63,16 +58,17 @@ abstract class ItemViewModel<T : DolphinModel>(
|
||||||
/**
|
/**
|
||||||
* Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred
|
* Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred
|
||||||
*/
|
*/
|
||||||
abstract class LoadingItemViewModel<T : DolphinModel>(
|
abstract class LoadingItemViewModel(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
) : ItemViewModel<T>(api) {
|
) : ItemViewModel(api) {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
|
||||||
open fun init(
|
open fun init(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
potential: BaseItem?,
|
potential: BaseItem?,
|
||||||
): Job? =
|
): Job? {
|
||||||
viewModelScope.launch(
|
loading.value = LoadingState.Loading
|
||||||
|
return viewModelScope.launch(
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
loading,
|
||||||
"Error loading item $itemId",
|
"Error loading item $itemId",
|
||||||
|
|
@ -80,10 +76,8 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val fetchedItem = api.userLibraryApi.getItem(itemId).content
|
val fetchedItem = api.userLibraryApi.getItem(itemId).content
|
||||||
val modelInstance = convertModel(fetchedItem, api)
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
item.value = BaseItem.from(fetchedItem, api)
|
item.value = BaseItem.from(fetchedItem, api)
|
||||||
model.value = modelInstance as T
|
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -92,4 +86,5 @@ abstract class LoadingItemViewModel<T : DolphinModel>(
|
||||||
loading.value = LoadingState.Error("Error loading item $itemId", e)
|
loading.value = LoadingState.Error("Error loading item $itemId", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.github.damontecres.dolphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -18,7 +13,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -38,7 +32,6 @@ import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
|
@ -54,11 +47,15 @@ import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.dolphin.ui.cards.PersonRow
|
import com.github.damontecres.dolphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.dolphin.ui.cards.SeasonCard
|
import com.github.damontecres.dolphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.dolphin.ui.components.ConfirmDialog
|
import com.github.damontecres.dolphin.ui.components.ConfirmDialog
|
||||||
|
import com.github.damontecres.dolphin.ui.components.DialogItem
|
||||||
|
import com.github.damontecres.dolphin.ui.components.DialogParams
|
||||||
|
import com.github.damontecres.dolphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||||
import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
|
||||||
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.dolphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.dolphin.ui.components.StarRating
|
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||||
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||||
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
|
||||||
|
|
@ -68,8 +65,6 @@ import com.github.damontecres.dolphin.ui.ifElse
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.letNotEmpty
|
import com.github.damontecres.dolphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.ui.playOnClickSound
|
|
||||||
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
|
||||||
import com.github.damontecres.dolphin.ui.rememberPosition
|
import com.github.damontecres.dolphin.ui.rememberPosition
|
||||||
import com.github.damontecres.dolphin.ui.roundMinutes
|
import com.github.damontecres.dolphin.ui.roundMinutes
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
|
|
@ -95,6 +90,7 @@ fun SeriesDetails(
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||||
|
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> ErrorMessage(state)
|
is LoadingState.Error -> ErrorMessage(state)
|
||||||
|
|
@ -112,6 +108,20 @@ fun SeriesDetails(
|
||||||
played = played,
|
played = played,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||||
|
onLongClickItem = { season ->
|
||||||
|
seasonDialog =
|
||||||
|
buildDialogForSeason(
|
||||||
|
season,
|
||||||
|
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||||
|
markPlayed = { played ->
|
||||||
|
viewModel.setWatched(
|
||||||
|
season.id,
|
||||||
|
played,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
overviewDialog =
|
overviewDialog =
|
||||||
ItemDetailsDialogInfo(
|
ItemDetailsDialogInfo(
|
||||||
|
|
@ -145,6 +155,15 @@ fun SeriesDetails(
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
seasonDialog?.let { params ->
|
||||||
|
DialogPopup(
|
||||||
|
showDialog = true,
|
||||||
|
title = params.title,
|
||||||
|
dialogItems = params.items,
|
||||||
|
waitToLoad = params.fromLongClick,
|
||||||
|
onDismissRequest = { seasonDialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -155,6 +174,7 @@ fun SeriesDetailsContent(
|
||||||
people: List<Person>,
|
people: List<Person>,
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
onLongClickItem: (BaseItem) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
playOnClick: () -> Unit,
|
playOnClick: () -> Unit,
|
||||||
watchOnClick: () -> Unit,
|
watchOnClick: () -> Unit,
|
||||||
|
|
@ -230,7 +250,7 @@ fun SeriesDetailsContent(
|
||||||
title = "Seasons",
|
title = "Seasons",
|
||||||
items = seasons.items,
|
items = seasons.items,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = { },
|
onLongClickItem = onLongClickItem,
|
||||||
cardOnFocus = { isFocused, index ->
|
cardOnFocus = { isFocused, index ->
|
||||||
// if (isFocused) {
|
// if (isFocused) {
|
||||||
// scope.launch(ExceptionHandler()) {
|
// scope.launch(ExceptionHandler()) {
|
||||||
|
|
@ -251,6 +271,7 @@ fun SeriesDetailsContent(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageHeight = Cards.height2x3,
|
imageHeight = Cards.height2x3,
|
||||||
imageWidth = Dp.Unspecified,
|
imageWidth = Dp.Unspecified,
|
||||||
|
showImageOverlay = true,
|
||||||
modifier =
|
modifier =
|
||||||
mod
|
mod
|
||||||
.ifElse(
|
.ifElse(
|
||||||
|
|
@ -333,40 +354,12 @@ fun SeriesDetailsHeader(
|
||||||
}
|
}
|
||||||
|
|
||||||
dto.overview?.let { overview ->
|
dto.overview?.let { overview ->
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
OverviewText(
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
overview = overview,
|
||||||
val bgColor =
|
maxLines = 3,
|
||||||
if (isFocused) {
|
onClick = overviewOnClick,
|
||||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
|
textBoxHeight = Dp.Unspecified,
|
||||||
} else {
|
)
|
||||||
Color.Unspecified
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
|
||||||
.playSoundOnFocus(true)
|
|
||||||
.clickable(
|
|
||||||
enabled = true,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
) {
|
|
||||||
playOnClickSound(context)
|
|
||||||
overviewOnClick.invoke()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = overview,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.height(60.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|
@ -388,3 +381,36 @@ fun SeriesDetailsHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun buildDialogForSeason(
|
||||||
|
s: BaseItem,
|
||||||
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
markPlayed: (Boolean) -> Unit,
|
||||||
|
): DialogParams {
|
||||||
|
val items =
|
||||||
|
buildList {
|
||||||
|
add(
|
||||||
|
DialogItem("Go to", Icons.Default.PlayArrow) {
|
||||||
|
onClickItem.invoke(s)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (s.data.userData?.played == true) {
|
||||||
|
add(
|
||||||
|
DialogItem("Mark as unplayed", R.string.fa_eye) {
|
||||||
|
markPlayed.invoke(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
add(
|
||||||
|
DialogItem("Mark as played", R.string.fa_eye_slash) {
|
||||||
|
markPlayed.invoke(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DialogParams(
|
||||||
|
title = s.name ?: "Season",
|
||||||
|
fromLongClick = true,
|
||||||
|
items = items,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Person
|
import com.github.damontecres.dolphin.data.model.Person
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
|
||||||
import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
|
import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.dolphin.preferences.ThemeSongVolume
|
import com.github.damontecres.dolphin.preferences.ThemeSongVolume
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
|
|
@ -58,13 +57,13 @@ class SeriesViewModel
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
|
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
) : ItemViewModel<Video>(api) {
|
) : ItemViewModel(api) {
|
||||||
private var player: Player? = null
|
private var player: Player? = null
|
||||||
private lateinit var seriesId: UUID
|
private lateinit var seriesId: UUID
|
||||||
private lateinit var prefs: UserPreferences
|
private lateinit var prefs: UserPreferences
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
||||||
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
||||||
val people = MutableLiveData<List<Person>>(listOf())
|
val people = MutableLiveData<List<Person>>(listOf())
|
||||||
|
|
||||||
fun init(
|
fun init(
|
||||||
|
|
@ -83,33 +82,25 @@ class SeriesViewModel
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
val item = fetchItem(seriesId, potential)
|
val item = fetchItem(seriesId, potential)
|
||||||
if (item != null) {
|
val seasonsInfo = getSeasons(item)
|
||||||
val seasonsInfo = getSeasons(item)
|
|
||||||
|
|
||||||
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
||||||
val episodeInfo =
|
val episodeInfo =
|
||||||
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
|
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
|
||||||
?.let { seasonNum ->
|
?.let { seasonNum ->
|
||||||
loadEpisodesInternal(seasonNum)
|
loadEpisodesInternal(seasonNum)
|
||||||
} ?: ItemListAndMapping.empty()
|
} ?: EpisodeList.Error("Could not determine season")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
seasons.value = seasonsInfo
|
seasons.value = seasonsInfo
|
||||||
episodes.value = episodeInfo
|
episodes.value = episodeInfo
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
people.value =
|
people.value =
|
||||||
item.data.people
|
item.data.people
|
||||||
?.letNotEmpty { people ->
|
?.letNotEmpty { people ->
|
||||||
people.map { Person.fromDto(it, api) }
|
people.map { Person.fromDto(it, api) }
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
}
|
|
||||||
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
|
|
||||||
} else {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
seasons.value = ItemListAndMapping.empty()
|
|
||||||
episodes.value = ItemListAndMapping.empty()
|
|
||||||
loading.value = LoadingState.Error("Series $seriesId not found")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +195,7 @@ class SeriesViewModel
|
||||||
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping {
|
private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
|
||||||
val request =
|
val request =
|
||||||
GetEpisodesRequest(
|
GetEpisodesRequest(
|
||||||
seriesId = item.value!!.id,
|
seriesId = item.value!!.id,
|
||||||
|
|
@ -223,35 +214,38 @@ class SeriesViewModel
|
||||||
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
||||||
pager.init()
|
pager.init()
|
||||||
Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
|
Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
|
||||||
return convertPager(pager)
|
return EpisodeList.Success(convertPager(pager))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadEpisodes(season: Int) =
|
fun loadEpisodes(season: Int) {
|
||||||
|
this@SeriesViewModel.episodes.value = EpisodeList.Loading
|
||||||
viewModelScope.async(ExceptionHandler(true)) {
|
viewModelScope.async(ExceptionHandler(true)) {
|
||||||
val episodes =
|
val episodes =
|
||||||
try {
|
try {
|
||||||
loadEpisodesInternal(season)
|
loadEpisodesInternal(season)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error loading episodes for $seriesId for season $season")
|
Timber.e(e, "Error loading episodes for $seriesId for season $season")
|
||||||
// TODO show error in UI?
|
EpisodeList.Error(e)
|
||||||
ItemListAndMapping.empty()
|
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@SeriesViewModel.episodes.value = episodes
|
this@SeriesViewModel.episodes.value = episodes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setWatched(
|
fun setWatched(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
listIndex: Int,
|
listIndex: Int?,
|
||||||
) = viewModelScope.launch(ExceptionHandler()) {
|
) = viewModelScope.launch(ExceptionHandler()) {
|
||||||
if (played) {
|
if (played) {
|
||||||
api.playStateApi.markPlayedItem(itemId)
|
api.playStateApi.markPlayedItem(itemId)
|
||||||
} else {
|
} else {
|
||||||
api.playStateApi.markUnplayedItem(itemId)
|
api.playStateApi.markUnplayedItem(itemId)
|
||||||
}
|
}
|
||||||
refreshEpisode(itemId, listIndex)
|
listIndex?.let {
|
||||||
|
refreshEpisode(itemId, listIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWatchedSeries(played: Boolean) =
|
fun setWatchedSeries(played: Boolean) =
|
||||||
|
|
@ -271,14 +265,15 @@ class SeriesViewModel
|
||||||
val base = api.userLibraryApi.getItem(itemId).content
|
val base = api.userLibraryApi.getItem(itemId).content
|
||||||
val item = BaseItem.Companion.from(base, api)
|
val item = BaseItem.Companion.from(base, api)
|
||||||
val eps = episodes.value!!
|
val eps = episodes.value!!
|
||||||
withContext(Dispatchers.Main) {
|
if (eps is EpisodeList.Success) {
|
||||||
episodes.value =
|
val newItems =
|
||||||
eps.copy(
|
eps.episodes.items.toMutableList().apply {
|
||||||
items =
|
this[listIndex] = item
|
||||||
eps.items.toMutableList().apply {
|
}
|
||||||
this[listIndex] = item
|
val newValue = EpisodeList.Success(eps.episodes.copy(items = newItems))
|
||||||
},
|
withContext(Dispatchers.Main) {
|
||||||
)
|
episodes.value = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,17 +319,32 @@ data class ItemListAndMapping(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the index<->season number pairings
|
* Calculate the index<->season/ep number pairings
|
||||||
*
|
*
|
||||||
* This allows for handling of missing seasons
|
* This allows for handling of missing seasons
|
||||||
*/
|
*/
|
||||||
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
|
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
|
||||||
val pairs =
|
val pairs =
|
||||||
pager.mapIndexed { index, _ ->
|
pager.mapIndexed { index, _ ->
|
||||||
val season = pager.getBlocking(index)
|
val item = pager.getBlocking(index)
|
||||||
Pair(season?.indexNumber!!, index)
|
Pair(item?.indexNumber ?: index, index)
|
||||||
}
|
}
|
||||||
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
|
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
|
||||||
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
|
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
|
||||||
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface EpisodeList {
|
||||||
|
data object Loading : EpisodeList
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String? = null,
|
||||||
|
val exception: Throwable? = null,
|
||||||
|
) : EpisodeList {
|
||||||
|
constructor(exception: Throwable) : this(null, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
val episodes: ItemListAndMapping,
|
||||||
|
) : EpisodeList
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.Button
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
||||||
|
|
@ -32,7 +31,7 @@ class VideoViewModel
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : LoadingItemViewModel<Video>(api)
|
) : LoadingItemViewModel(api)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoDetails(
|
fun VideoDetails(
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -40,7 +39,6 @@ import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Chapter
|
import com.github.damontecres.dolphin.data.model.Chapter
|
||||||
import com.github.damontecres.dolphin.data.model.Person
|
import com.github.damontecres.dolphin.data.model.Person
|
||||||
import com.github.damontecres.dolphin.data.model.Video
|
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.cards.ChapterRow
|
import com.github.damontecres.dolphin.ui.cards.ChapterRow
|
||||||
import com.github.damontecres.dolphin.ui.cards.PersonRow
|
import com.github.damontecres.dolphin.ui.cards.PersonRow
|
||||||
|
|
@ -77,7 +75,7 @@ class MovieViewModel
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : LoadingItemViewModel<Video>(api) {
|
) : LoadingItemViewModel(api) {
|
||||||
private lateinit var itemId: UUID
|
private lateinit var itemId: UUID
|
||||||
val people = MutableLiveData<List<Person>>(listOf())
|
val people = MutableLiveData<List<Person>>(listOf())
|
||||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
||||||
|
|
@ -177,20 +175,20 @@ fun MovieDetails(
|
||||||
Destination.Playback(movie),
|
Destination.Playback(movie),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
DialogItem(
|
// DialogItem(
|
||||||
"Playback Settings",
|
// "Playback Settings",
|
||||||
Icons.Default.Settings,
|
// Icons.Default.Settings,
|
||||||
// iconColor = Color.Green.copy(alpha = .8f),
|
// // iconColor = Color.Green.copy(alpha = .8f),
|
||||||
) {
|
// ) {
|
||||||
// TODO choose audio or subtitle tracks?
|
// // TODO choose audio or subtitle tracks?
|
||||||
},
|
// },
|
||||||
DialogItem(
|
// DialogItem(
|
||||||
"Play Version",
|
// "Play Version",
|
||||||
Icons.Default.PlayArrow,
|
// Icons.Default.PlayArrow,
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
// iconColor = Color.Green.copy(alpha = .8f),
|
||||||
) {
|
// ) {
|
||||||
// TODO only show for multiple files
|
// // TODO only show for multiple files
|
||||||
},
|
// },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
package com.github.damontecres.dolphin.ui.detail.movie
|
package com.github.damontecres.dolphin.ui.detail.movie
|
||||||
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -15,9 +9,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
|
@ -27,18 +19,18 @@ import androidx.compose.ui.graphics.Shadow
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.R
|
import com.github.damontecres.dolphin.R
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||||
|
import com.github.damontecres.dolphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.dolphin.ui.components.StarRating
|
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||||
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||||
import com.github.damontecres.dolphin.ui.components.TitleValueText
|
import com.github.damontecres.dolphin.ui.components.TitleValueText
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.playOnClickSound
|
|
||||||
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
|
||||||
import com.github.damontecres.dolphin.ui.roundMinutes
|
import com.github.damontecres.dolphin.ui.roundMinutes
|
||||||
import com.github.damontecres.dolphin.ui.timeRemaining
|
import com.github.damontecres.dolphin.ui.timeRemaining
|
||||||
import com.github.damontecres.dolphin.util.formatSubtitleLang
|
import com.github.damontecres.dolphin.util.formatSubtitleLang
|
||||||
|
|
@ -120,40 +112,12 @@ fun MovieDetailsHeader(
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
dto.overview?.let { overview ->
|
dto.overview?.let { overview ->
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
OverviewText(
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
overview = overview,
|
||||||
val bgColor =
|
maxLines = 3,
|
||||||
if (isFocused) {
|
onClick = overviewOnClick,
|
||||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
|
textBoxHeight = Dp.Unspecified,
|
||||||
} else {
|
)
|
||||||
Color.Unspecified
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
|
||||||
.playSoundOnFocus(true)
|
|
||||||
.clickable(
|
|
||||||
enabled = true,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
) {
|
|
||||||
playOnClickSound(context)
|
|
||||||
overviewOnClick.invoke()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = overview,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.height(60.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
movie.data.people
|
movie.data.people
|
||||||
?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() }
|
?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() }
|
||||||
|
|
@ -161,6 +125,8 @@ fun MovieDetailsHeader(
|
||||||
?.let {
|
?.let {
|
||||||
Text(
|
Text(
|
||||||
text = "Directed by $it",
|
text = "Directed by $it",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Key-Values
|
// Key-Values
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
package com.github.damontecres.dolphin.ui.detail.series
|
package com.github.damontecres.dolphin.ui.detail.series
|
||||||
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -25,10 +15,9 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||||
|
import com.github.damontecres.dolphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.dolphin.ui.components.StarRating
|
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||||
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||||
import com.github.damontecres.dolphin.ui.playOnClickSound
|
|
||||||
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
|
||||||
import com.github.damontecres.dolphin.ui.roundMinutes
|
import com.github.damontecres.dolphin.ui.roundMinutes
|
||||||
import com.github.damontecres.dolphin.ui.timeRemaining
|
import com.github.damontecres.dolphin.ui.timeRemaining
|
||||||
import com.github.damontecres.dolphin.util.formatDateTime
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
|
|
@ -50,6 +39,8 @@ fun FocusedEpisodeHeader(
|
||||||
Text(
|
Text(
|
||||||
text = dto.episodeTitle ?: dto.name ?: "",
|
text = dto.episodeTitle ?: dto.name ?: "",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -91,42 +82,12 @@ fun FocusedEpisodeHeader(
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
} ?: Spacer(Modifier.height(24.dp))
|
||||||
}
|
|
||||||
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
|
||||||
val bgColor =
|
|
||||||
if (isFocused) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
|
|
||||||
} else {
|
|
||||||
Color.Unspecified
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
|
||||||
.playSoundOnFocus(true)
|
|
||||||
.clickable(
|
|
||||||
enabled = true,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
) {
|
|
||||||
playOnClickSound(context)
|
|
||||||
overviewOnClick.invoke()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = dto.overview ?: "",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.height(60.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
OverviewText(
|
||||||
|
overview = dto.overview ?: "",
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.dolphin.ui.detail.series
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -26,6 +25,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.EpisodeList
|
||||||
import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping
|
import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping
|
||||||
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
|
@ -76,7 +76,8 @@ fun SeriesOverview(
|
||||||
|
|
||||||
val series by viewModel.item.observeAsState(null)
|
val series by viewModel.item.observeAsState(null)
|
||||||
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
|
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
|
||||||
val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty())
|
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
|
||||||
|
val episodeList = (episodes as? EpisodeList.Success)?.episodes?.items
|
||||||
|
|
||||||
var position by rememberSaveable(
|
var position by rememberSaveable(
|
||||||
destination,
|
destination,
|
||||||
|
|
@ -90,7 +91,10 @@ fun SeriesOverview(
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
SeriesOverviewPosition(
|
SeriesOverviewPosition(
|
||||||
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
|
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
|
||||||
episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0,
|
(episodes as? EpisodeList.Success)?.episodes?.numberToIndex[
|
||||||
|
initialSeasonEpisode?.episode
|
||||||
|
?: 0,
|
||||||
|
] ?: 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -98,12 +102,16 @@ fun SeriesOverview(
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(episodes.items) {
|
LaunchedEffect(episodes) {
|
||||||
if (episodes.items.isNotEmpty()) {
|
episodes?.let { episodes ->
|
||||||
// TODO focus on first episode when changing seasons?
|
if (episodes is EpisodeList.Success) {
|
||||||
|
if (episodes.episodes.items.isNotEmpty()) {
|
||||||
|
// TODO focus on first episode when changing seasons?
|
||||||
// firstItemFocusRequester.requestFocus()
|
// firstItemFocusRequester.requestFocus()
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
||||||
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
|
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +129,7 @@ fun SeriesOverview(
|
||||||
SeriesOverviewContent(
|
SeriesOverviewContent(
|
||||||
series = series,
|
series = series,
|
||||||
seasons = seasons.items,
|
seasons = seasons.items,
|
||||||
episodes = episodes.items,
|
episodes = episodes,
|
||||||
position = position,
|
position = position,
|
||||||
backdropImageUrl =
|
backdropImageUrl =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -141,7 +149,6 @@ fun SeriesOverview(
|
||||||
position = it
|
position = it
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.release()
|
|
||||||
val resumePosition =
|
val resumePosition =
|
||||||
it.data.userData
|
it.data.userData
|
||||||
?.playbackPositionTicks
|
?.playbackPositionTicks
|
||||||
|
|
@ -158,7 +165,7 @@ fun SeriesOverview(
|
||||||
// TODO
|
// TODO
|
||||||
},
|
},
|
||||||
playOnClick = { resume ->
|
playOnClick = { resume ->
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
viewModel.navigateTo(
|
viewModel.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
|
|
@ -170,13 +177,13 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watchOnClick = {
|
watchOnClick = {
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
val played = it.data.userData?.played ?: false
|
val played = it.data.userData?.played ?: false
|
||||||
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let { ep ->
|
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||||
moreDialog =
|
moreDialog =
|
||||||
DialogParams(
|
DialogParams(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
|
|
@ -188,7 +195,6 @@ fun SeriesOverview(
|
||||||
Icons.Default.PlayArrow,
|
Icons.Default.PlayArrow,
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
iconColor = Color.Green.copy(alpha = .8f),
|
||||||
) {
|
) {
|
||||||
viewModel.release()
|
|
||||||
viewModel.navigateTo(
|
viewModel.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
ep.id,
|
ep.id,
|
||||||
|
|
@ -202,7 +208,6 @@ fun SeriesOverview(
|
||||||
Icons.AutoMirrored.Filled.ArrowForward,
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
// iconColor = Color.Green.copy(alpha = .8f),
|
// iconColor = Color.Green.copy(alpha = .8f),
|
||||||
) {
|
) {
|
||||||
viewModel.release()
|
|
||||||
viewModel.navigateTo(
|
viewModel.navigateTo(
|
||||||
Destination.MediaItem(
|
Destination.MediaItem(
|
||||||
series.id,
|
series.id,
|
||||||
|
|
@ -211,26 +216,26 @@ fun SeriesOverview(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
DialogItem(
|
// DialogItem(
|
||||||
"Playback Settings",
|
// "Playback Settings",
|
||||||
Icons.Default.Settings,
|
// Icons.Default.Settings,
|
||||||
|
// // iconColor = Color.Green.copy(alpha = .8f),
|
||||||
|
// ) {
|
||||||
|
// // TODO choose audio or subtitle tracks?
|
||||||
|
// },
|
||||||
|
// DialogItem(
|
||||||
|
// "Play Version",
|
||||||
|
// Icons.Default.PlayArrow,
|
||||||
// iconColor = Color.Green.copy(alpha = .8f),
|
// iconColor = Color.Green.copy(alpha = .8f),
|
||||||
) {
|
// ) {
|
||||||
// TODO choose audio or subtitle tracks?
|
// // TODO only show for multiple files
|
||||||
},
|
// },
|
||||||
DialogItem(
|
|
||||||
"Play Version",
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
iconColor = Color.Green.copy(alpha = .8f),
|
|
||||||
) {
|
|
||||||
// TODO only show for multiple files
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
overviewDialog =
|
overviewDialog =
|
||||||
ItemDetailsDialogInfo(
|
ItemDetailsDialogInfo(
|
||||||
title = it.name ?: "Unknown",
|
title = it.name ?: "Unknown",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -34,6 +33,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Tab
|
import androidx.tv.material3.Tab
|
||||||
|
|
@ -46,16 +46,20 @@ import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.aspectRatioFloat
|
import com.github.damontecres.dolphin.data.model.aspectRatioFloat
|
||||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.dolphin.ui.cards.BannerCard
|
import com.github.damontecres.dolphin.ui.cards.BannerCard
|
||||||
|
import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.dolphin.ui.detail.EpisodeList
|
||||||
import com.github.damontecres.dolphin.ui.ifElse
|
import com.github.damontecres.dolphin.ui.ifElse
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesOverviewContent(
|
fun SeriesOverviewContent(
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem?>,
|
seasons: List<BaseItem?>,
|
||||||
episodes: List<BaseItem?>,
|
episodes: EpisodeList,
|
||||||
position: SeriesOverviewPosition,
|
position: SeriesOverviewPosition,
|
||||||
backdropImageUrl: String?,
|
backdropImageUrl: String?,
|
||||||
firstItemFocusRequester: FocusRequester,
|
firstItemFocusRequester: FocusRequester,
|
||||||
|
|
@ -76,9 +80,8 @@ fun SeriesOverviewContent(
|
||||||
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
||||||
val tabRowFocusRequester = remember { FocusRequester() }
|
val tabRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
|
val focusedEpisode =
|
||||||
LaunchedEffect(position) {
|
(episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -184,6 +187,8 @@ fun SeriesOverviewContent(
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -200,56 +205,74 @@ fun SeriesOverviewContent(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
key(position.seasonTabIndex) {
|
key(position.seasonTabIndex) {
|
||||||
val state = rememberLazyListState()
|
when (val eps = episodes) {
|
||||||
OneTimeLaunchedEffect {
|
EpisodeList.Loading -> LoadingPage()
|
||||||
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
|
||||||
state.scrollToItem(position.episodeRowIndex)
|
is EpisodeList.Success -> {
|
||||||
firstItemFocusRequester.tryRequestFocus()
|
val state = rememberLazyListState()
|
||||||
}
|
OneTimeLaunchedEffect {
|
||||||
}
|
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
||||||
LazyRow(
|
state.scrollToItem(position.episodeRowIndex)
|
||||||
state = state,
|
}
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
firstItemFocusRequester.tryRequestFocus()
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.focusRestorer(firstItemFocusRequester)
|
|
||||||
.focusRequester(episodeRowFocusRequester),
|
|
||||||
) {
|
|
||||||
itemsIndexed(episodes) { episodeIndex, episode ->
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
if (interactionSource.collectIsFocusedAsState().value) {
|
|
||||||
onFocus.invoke(
|
|
||||||
SeriesOverviewPosition(
|
|
||||||
selectedTabIndex,
|
|
||||||
episodeIndex,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
LazyRow(
|
||||||
BannerCard(
|
state = state,
|
||||||
name = episode?.name,
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
imageUrl = episode?.imageUrl,
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
aspectRatio =
|
|
||||||
episode
|
|
||||||
?.data
|
|
||||||
?.primaryImageAspectRatio
|
|
||||||
?.toFloat()
|
|
||||||
?.coerceAtLeast(episode.data.aspectRatioFloat ?: (4f / 3f))
|
|
||||||
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
|
|
||||||
cornerText = "E${episode?.data?.indexNumber}",
|
|
||||||
played = episode?.data?.userData?.played ?: false,
|
|
||||||
playPercent = episode?.data?.userData?.playedPercentage ?: 0.0,
|
|
||||||
onClick = { if (episode != null) onClick.invoke(episode) },
|
|
||||||
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
episodeIndex == position.episodeRowIndex,
|
.focusRestorer(firstItemFocusRequester)
|
||||||
Modifier.focusRequester(firstItemFocusRequester),
|
.focusRequester(episodeRowFocusRequester),
|
||||||
),
|
) {
|
||||||
interactionSource = interactionSource,
|
itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
|
||||||
cardHeight = 120.dp,
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
)
|
if (interactionSource.collectIsFocusedAsState().value) {
|
||||||
|
onFocus.invoke(
|
||||||
|
SeriesOverviewPosition(
|
||||||
|
selectedTabIndex,
|
||||||
|
episodeIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val cornerText =
|
||||||
|
episode?.data?.indexNumber?.let { "E$it" }
|
||||||
|
?: episode?.data?.premiereDate?.let(::formatDateTime)
|
||||||
|
BannerCard(
|
||||||
|
name = episode?.name,
|
||||||
|
imageUrl = episode?.imageUrl,
|
||||||
|
aspectRatio =
|
||||||
|
episode
|
||||||
|
?.data
|
||||||
|
?.primaryImageAspectRatio
|
||||||
|
?.toFloat()
|
||||||
|
?.coerceAtLeast(
|
||||||
|
episode.data.aspectRatioFloat ?: (4f / 3f),
|
||||||
|
)
|
||||||
|
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
|
||||||
|
cornerText = cornerText,
|
||||||
|
played = episode?.data?.userData?.played ?: false,
|
||||||
|
playPercent =
|
||||||
|
episode?.data?.userData?.playedPercentage
|
||||||
|
?: 0.0,
|
||||||
|
onClick = { if (episode != null) onClick.invoke(episode) },
|
||||||
|
onLongClick = {
|
||||||
|
if (episode != null) {
|
||||||
|
onLongClick.invoke(
|
||||||
|
episode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
episodeIndex == position.episodeRowIndex,
|
||||||
|
Modifier.focusRequester(firstItemFocusRequester),
|
||||||
|
),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
cardHeight = 120.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import com.github.damontecres.dolphin.ui.timeRemaining
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.dolphin.util.LoadingState
|
import com.github.damontecres.dolphin.util.LoadingState
|
||||||
import com.github.damontecres.dolphin.util.formatDateTime
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
|
import com.github.damontecres.dolphin.util.seasonEpisode
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
|
|
@ -121,7 +122,7 @@ fun HomePageContent(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight(.7f)
|
.fillMaxHeight(.7f)
|
||||||
.fillMaxWidth(.7f)
|
.fillMaxWidth(.7f)
|
||||||
.alpha(.4f)
|
.alpha(.75f)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.drawWithContent {
|
.drawWithContent {
|
||||||
drawContent()
|
drawContent()
|
||||||
|
|
@ -231,7 +232,13 @@ fun MainPageHeader(
|
||||||
val details =
|
val details =
|
||||||
buildList {
|
buildList {
|
||||||
if (isEpisode) {
|
if (isEpisode) {
|
||||||
add("S${dto.parentIndexNumber} E${dto.indexNumber}")
|
val se = dto.seasonEpisode
|
||||||
|
if (se != null) {
|
||||||
|
add(se)
|
||||||
|
} else if (dto.parentIndexNumber != null) {
|
||||||
|
// Maybe a daily episode, so just show season, the date is added below
|
||||||
|
add("S${dto.parentIndexNumber}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isEpisode) {
|
if (isEpisode) {
|
||||||
dto.premiereDate?.let { add(formatDateTime(it)) }
|
dto.premiereDate?.let { add(formatDateTime(it)) }
|
||||||
|
|
@ -249,12 +256,16 @@ fun MainPageHeader(
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (details.isNotEmpty()) {
|
if (details.isNotEmpty()) {
|
||||||
|
|
@ -267,13 +278,13 @@ fun MainPageHeader(
|
||||||
val overviewModifier =
|
val overviewModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(0.dp)
|
.padding(0.dp)
|
||||||
.height(48.dp)
|
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
|
||||||
if (overview.isNotNullOrBlank()) {
|
if (overview.isNotNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = overview,
|
text = overview,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 2,
|
maxLines = if (isEpisode) 2 else 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = overviewModifier,
|
modifier = overviewModifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ class HomeViewModel
|
||||||
val homeRows = MutableLiveData<List<HomeRow>>()
|
val homeRows = MutableLiveData<List<HomeRow>>()
|
||||||
|
|
||||||
fun init(preferences: UserPreferences) {
|
fun init(preferences: UserPreferences) {
|
||||||
val limit = preferences.appPreferences.homePagePreferences.maxItemsPerRow
|
val prefs = preferences.appPreferences.homePagePreferences
|
||||||
|
val limit = prefs.maxItemsPerRow
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
Dispatchers.IO +
|
Dispatchers.IO +
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
|
|
@ -78,53 +79,32 @@ class HomeViewModel
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// TODO data is fetched all together which may be slow for large servers
|
// TODO data is fetched all together which may be slow for large servers
|
||||||
|
val resume = getResume(user.id, limit)
|
||||||
|
val nextUp = getNextUp(user.id, limit, prefs.enableRewatchingNextUp)
|
||||||
|
val latest = getLatest(user, limit)
|
||||||
|
|
||||||
val homeRows =
|
val homeRows =
|
||||||
homeSections
|
if (prefs.combineContinueNext) {
|
||||||
.mapNotNull { section ->
|
listOf(
|
||||||
Timber.Forest.v("Loading section: %s", section.name)
|
HomeRow(
|
||||||
when (section) {
|
section = HomeSection.NEXT_UP,
|
||||||
HomeSection.LATEST_MEDIA -> {
|
items = resume + nextUp,
|
||||||
getLatest(user, limit)
|
),
|
||||||
}
|
*latest.toTypedArray(),
|
||||||
|
)
|
||||||
HomeSection.RESUME -> {
|
} else {
|
||||||
val items = getResume(user.id, limit)
|
listOf(
|
||||||
listOf(
|
HomeRow(
|
||||||
HomeRow(
|
section = HomeSection.RESUME,
|
||||||
section = section,
|
items = resume,
|
||||||
items = items,
|
),
|
||||||
),
|
HomeRow(
|
||||||
)
|
section = HomeSection.NEXT_UP,
|
||||||
}
|
items = nextUp,
|
||||||
|
),
|
||||||
HomeSection.NEXT_UP -> {
|
*latest.toTypedArray(),
|
||||||
val nextUp =
|
)
|
||||||
getNextUp(
|
}
|
||||||
user.id,
|
|
||||||
limit,
|
|
||||||
preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
|
|
||||||
)
|
|
||||||
listOf(
|
|
||||||
HomeRow(
|
|
||||||
section = section,
|
|
||||||
items = nextUp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
HomeSection.LIVE_TV -> null
|
|
||||||
HomeSection.ACTIVE_RECORDINGS -> null
|
|
||||||
|
|
||||||
// TODO Not supported?
|
|
||||||
HomeSection.LIBRARY_TILES_SMALL -> null
|
|
||||||
HomeSection.LIBRARY_BUTTONS -> null
|
|
||||||
HomeSection.RESUME_AUDIO -> null
|
|
||||||
HomeSection.RESUME_BOOK -> null
|
|
||||||
HomeSection.NONE -> null
|
|
||||||
}
|
|
||||||
}.flatten()
|
|
||||||
.filter { it.items.isNotEmpty() }
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@HomeViewModel.homeRows.value = homeRows
|
this@HomeViewModel.homeRows.value = homeRows
|
||||||
loadingState.value = LoadingState.Success
|
loadingState.value = LoadingState.Success
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.github.damontecres.dolphin.data.JellyfinServer
|
import com.github.damontecres.dolphin.data.JellyfinServer
|
||||||
import com.github.damontecres.dolphin.data.JellyfinUser
|
import com.github.damontecres.dolphin.data.JellyfinUser
|
||||||
|
|
@ -30,11 +29,10 @@ fun ApplicationContent(
|
||||||
) {
|
) {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = navigationManager.backStack,
|
backStack = navigationManager.backStack,
|
||||||
onBack = { repeat(it) { navigationManager.goBack() } },
|
onBack = { navigationManager.goBack() },
|
||||||
entryDecorators =
|
entryDecorators =
|
||||||
listOf(
|
listOf(
|
||||||
rememberSceneSetupNavEntryDecorator(),
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberSavedStateNavEntryDecorator(),
|
|
||||||
rememberViewModelStoreNavEntryDecorator(),
|
rememberViewModelStoreNavEntryDecorator(),
|
||||||
),
|
),
|
||||||
entryProvider = { key ->
|
entryProvider = { key ->
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ sealed class Destination(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
|
val startIndex: Int? = null,
|
||||||
|
val shuffle: Boolean = false,
|
||||||
) : Destination(true) {
|
) : Destination(true) {
|
||||||
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ import com.github.damontecres.dolphin.ui.components.LicenseInfo
|
||||||
import com.github.damontecres.dolphin.ui.detail.CollectionFolderGeneric
|
import com.github.damontecres.dolphin.ui.detail.CollectionFolderGeneric
|
||||||
import com.github.damontecres.dolphin.ui.detail.CollectionFolderMovie
|
import com.github.damontecres.dolphin.ui.detail.CollectionFolderMovie
|
||||||
import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv
|
import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv
|
||||||
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
|
import com.github.damontecres.dolphin.ui.detail.PlaylistDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
|
|
||||||
import com.github.damontecres.dolphin.ui.detail.SeriesDetails
|
import com.github.damontecres.dolphin.ui.detail.SeriesDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
|
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
|
||||||
|
|
@ -76,20 +75,6 @@ fun DestinationContent(
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseItemKind.SEASON ->
|
|
||||||
SeasonDetails(
|
|
||||||
preferences,
|
|
||||||
destination,
|
|
||||||
modifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
BaseItemKind.EPISODE ->
|
|
||||||
EpisodeDetails(
|
|
||||||
preferences,
|
|
||||||
destination,
|
|
||||||
modifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
BaseItemKind.MOVIE ->
|
BaseItemKind.MOVIE ->
|
||||||
MovieDetails(
|
MovieDetails(
|
||||||
preferences,
|
preferences,
|
||||||
|
|
@ -105,6 +90,14 @@ fun DestinationContent(
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BaseItemKind.BOX_SET ->
|
||||||
|
CollectionFolderGeneric(
|
||||||
|
preferences,
|
||||||
|
destination,
|
||||||
|
false,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
BaseItemKind.COLLECTION_FOLDER -> {
|
BaseItemKind.COLLECTION_FOLDER -> {
|
||||||
when (destination.item?.data?.collectionType) {
|
when (destination.item?.data?.collectionType) {
|
||||||
CollectionType.TVSHOWS ->
|
CollectionType.TVSHOWS ->
|
||||||
|
|
@ -121,15 +114,38 @@ fun DestinationContent(
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CollectionType.BOXSETS ->
|
||||||
|
CollectionFolderGeneric(
|
||||||
|
preferences,
|
||||||
|
destination,
|
||||||
|
true,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
else ->
|
else ->
|
||||||
CollectionFolderGeneric(
|
CollectionFolderGeneric(
|
||||||
preferences,
|
preferences,
|
||||||
destination,
|
destination,
|
||||||
|
false,
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItemKind.PLAYLIST ->
|
||||||
|
PlaylistDetails(
|
||||||
|
destination = destination,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
BaseItemKind.USER_VIEW ->
|
||||||
|
CollectionFolderGeneric(
|
||||||
|
preferences,
|
||||||
|
destination,
|
||||||
|
true,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Text("Unsupported item type: ${destination.type}")
|
Text("Unsupported item type: ${destination.type}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,6 @@ fun NavigationDrawerScope.LibraryNavItem(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
// TODO
|
|
||||||
val icon =
|
val icon =
|
||||||
when (library.data.collectionType) {
|
when (library.data.collectionType) {
|
||||||
CollectionType.MOVIES -> R.string.fa_film
|
CollectionType.MOVIES -> R.string.fa_film
|
||||||
|
|
@ -319,6 +318,8 @@ fun NavigationDrawerScope.LibraryNavItem(
|
||||||
CollectionType.HOMEVIDEOS -> R.string.fa_video
|
CollectionType.HOMEVIDEOS -> R.string.fa_video
|
||||||
CollectionType.LIVETV -> R.string.fa_tv
|
CollectionType.LIVETV -> R.string.fa_tv
|
||||||
CollectionType.MUSIC -> R.string.fa_music
|
CollectionType.MUSIC -> R.string.fa_music
|
||||||
|
CollectionType.BOXSETS -> R.string.fa_open_folder
|
||||||
|
CollectionType.PLAYLISTS -> R.string.fa_list_ul
|
||||||
else -> R.string.fa_film
|
else -> R.string.fa_film
|
||||||
}
|
}
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ import com.github.damontecres.dolphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -91,6 +93,10 @@ sealed interface PlaybackAction {
|
||||||
data class Scale(
|
data class Scale(
|
||||||
val scale: ContentScale,
|
val scale: ContentScale,
|
||||||
) : PlaybackAction
|
) : PlaybackAction
|
||||||
|
|
||||||
|
data object Previous : PlaybackAction
|
||||||
|
|
||||||
|
data object Next : PlaybackAction
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
@ -116,6 +122,7 @@ fun PlaybackControls(
|
||||||
seekBack: Duration,
|
seekBack: Duration,
|
||||||
skipBackOnResume: Duration?,
|
skipBackOnResume: Duration?,
|
||||||
seekForward: Duration,
|
seekForward: Duration,
|
||||||
|
currentSegment: MediaSegmentDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialFocusRequester: FocusRequester = remember { FocusRequester() },
|
initialFocusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
|
@ -152,6 +159,8 @@ fun PlaybackControls(
|
||||||
interactionSource = seekBarInteractionSource,
|
interactionSource = seekBarInteractionSource,
|
||||||
isEnabled = seekEnabled,
|
isEnabled = seekEnabled,
|
||||||
intervals = seekBarIntervals,
|
intervals = seekBarIntervals,
|
||||||
|
seekBack = seekBack,
|
||||||
|
seekForward = seekForward,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(vertical = 0.dp)
|
.padding(vertical = 0.dp)
|
||||||
|
|
@ -174,6 +183,7 @@ fun PlaybackControls(
|
||||||
player = playerControls,
|
player = playerControls,
|
||||||
initialFocusRequester = initialFocusRequester,
|
initialFocusRequester = initialFocusRequester,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
showPlay = showPlay,
|
showPlay = showPlay,
|
||||||
previousEnabled = previousEnabled,
|
previousEnabled = previousEnabled,
|
||||||
nextEnabled = nextEnabled,
|
nextEnabled = nextEnabled,
|
||||||
|
|
@ -182,18 +192,37 @@ fun PlaybackControls(
|
||||||
skipBackOnResume = skipBackOnResume,
|
skipBackOnResume = skipBackOnResume,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
)
|
)
|
||||||
RightPlaybackButtons(
|
Row(
|
||||||
subtitleStreams = subtitleStreams,
|
|
||||||
onControllerInteraction = onControllerInteraction,
|
|
||||||
onControllerInteractionForDialog = onControllerInteractionForDialog,
|
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
|
||||||
subtitleIndex = subtitleIndex,
|
|
||||||
audioStreams = audioStreams,
|
|
||||||
audioIndex = audioIndex,
|
|
||||||
playbackSpeed = playbackSpeed,
|
|
||||||
scale = scale,
|
|
||||||
modifier = Modifier.align(Alignment.CenterEnd),
|
modifier = Modifier.align(Alignment.CenterEnd),
|
||||||
)
|
) {
|
||||||
|
currentSegment?.let { segment ->
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
.padding(end = 32.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Skip ${segment.type.serialName}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RightPlaybackButtons(
|
||||||
|
subtitleStreams = subtitleStreams,
|
||||||
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
onControllerInteractionForDialog = onControllerInteractionForDialog,
|
||||||
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
subtitleIndex = subtitleIndex,
|
||||||
|
audioStreams = audioStreams,
|
||||||
|
audioIndex = audioIndex,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
|
scale = scale,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +235,8 @@ fun SeekBar(
|
||||||
intervals: Int,
|
intervals: Int,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
onSeekProgress: (Long) -> Unit,
|
onSeekProgress: (Long) -> Unit,
|
||||||
|
seekBack: Duration,
|
||||||
|
seekForward: Duration,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
|
|
@ -236,6 +267,8 @@ fun SeekBar(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
enabled = isEnabled,
|
enabled = isEnabled,
|
||||||
durationMs = player.contentDuration,
|
durationMs = player.contentDuration,
|
||||||
|
seekBack = seekBack,
|
||||||
|
seekForward = seekForward,
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -443,6 +476,7 @@ fun PlaybackButtons(
|
||||||
player: Player,
|
player: Player,
|
||||||
initialFocusRequester: FocusRequester,
|
initialFocusRequester: FocusRequester,
|
||||||
onControllerInteraction: () -> Unit,
|
onControllerInteraction: () -> Unit,
|
||||||
|
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||||
showPlay: Boolean,
|
showPlay: Boolean,
|
||||||
previousEnabled: Boolean,
|
previousEnabled: Boolean,
|
||||||
nextEnabled: Boolean,
|
nextEnabled: Boolean,
|
||||||
|
|
@ -459,7 +493,7 @@ fun PlaybackButtons(
|
||||||
iconRes = R.drawable.baseline_skip_previous_24,
|
iconRes = R.drawable.baseline_skip_previous_24,
|
||||||
onClick = {
|
onClick = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
player.seekToPrevious()
|
onPlaybackActionClick.invoke(PlaybackAction.Previous)
|
||||||
},
|
},
|
||||||
enabled = previousEnabled,
|
enabled = previousEnabled,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
|
@ -500,7 +534,7 @@ fun PlaybackButtons(
|
||||||
iconRes = R.drawable.baseline_skip_next_24,
|
iconRes = R.drawable.baseline_skip_next_24,
|
||||||
onClick = {
|
onClick = {
|
||||||
onControllerInteraction.invoke()
|
onControllerInteraction.invoke()
|
||||||
player.seekToNext()
|
onPlaybackActionClick.invoke(PlaybackAction.Next)
|
||||||
},
|
},
|
||||||
enabled = nextEnabled,
|
enabled = nextEnabled,
|
||||||
onControllerInteraction = onControllerInteraction,
|
onControllerInteraction = onControllerInteraction,
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,16 @@ import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
|
@ -30,7 +33,6 @@ import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
@ -41,13 +43,17 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Chapter
|
import com.github.damontecres.dolphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.dolphin.data.model.Playlist
|
||||||
import com.github.damontecres.dolphin.ui.AppColors
|
import com.github.damontecres.dolphin.ui.AppColors
|
||||||
import com.github.damontecres.dolphin.ui.cards.ChapterCard
|
import com.github.damontecres.dolphin.ui.cards.ChapterCard
|
||||||
|
import com.github.damontecres.dolphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.dolphin.ui.ifElse
|
import com.github.damontecres.dolphin.ui.ifElse
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.letNotEmpty
|
import com.github.damontecres.dolphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -79,10 +85,13 @@ fun PlaybackOverlay(
|
||||||
moreButtonOptions: MoreButtonOptions,
|
moreButtonOptions: MoreButtonOptions,
|
||||||
currentPlayback: CurrentPlayback?,
|
currentPlayback: CurrentPlayback?,
|
||||||
audioStreams: List<AudioStream>,
|
audioStreams: List<AudioStream>,
|
||||||
|
currentSegment: MediaSegmentDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
trickplayInfo: TrickplayInfo? = null,
|
trickplayInfo: TrickplayInfo? = null,
|
||||||
trickplayUrlFor: (Int) -> String? = { null },
|
trickplayUrlFor: (Int) -> String? = { null },
|
||||||
|
playlist: Playlist = Playlist(listOf(), 0),
|
||||||
|
onClickPlaylist: (BaseItem) -> Unit = {},
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||||
|
|
@ -117,10 +126,17 @@ fun PlaybackOverlay(
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = slideInVertically() + fadeIn(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = slideOutVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
val nextState =
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
OverlayViewState.CHAPTERS
|
||||||
|
} else if (playlist.hasNext()) {
|
||||||
|
OverlayViewState.QUEUE
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
Controller(
|
Controller(
|
||||||
title = title,
|
title = title,
|
||||||
subtitleStreams = subtitleStreams,
|
subtitleStreams = subtitleStreams,
|
||||||
chapters = chapters,
|
|
||||||
playerControls = playerControls,
|
playerControls = playerControls,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = showPlay,
|
showPlay = showPlay,
|
||||||
|
|
@ -143,18 +159,15 @@ fun PlaybackOverlay(
|
||||||
audioStreams = audioStreams,
|
audioStreams = audioStreams,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
seekBarInteractionSource = seekBarInteractionSource,
|
seekBarInteractionSource = seekBarInteractionSource,
|
||||||
|
nextState = nextState,
|
||||||
|
onNextStateFocus = {
|
||||||
|
nextState?.let { state = it }
|
||||||
|
},
|
||||||
|
currentSegment = currentSegment,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.onKeyEvent { e ->
|
// Don't use key events because this control has vertical items so up/down is tough to manage
|
||||||
if (chapters.isNotEmpty() &&
|
.onGloballyPositioned {
|
||||||
e.type == KeyEventType.KeyDown && isDown(e) &&
|
|
||||||
!seekBarFocused
|
|
||||||
) {
|
|
||||||
state = OverlayViewState.CHAPTERS
|
|
||||||
true
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}.onGloballyPositioned {
|
|
||||||
controllerHeight = with(density) { it.size.height.toDp() }
|
controllerHeight = with(density) { it.size.height.toDp() }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -177,8 +190,9 @@ fun PlaybackOverlay(
|
||||||
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
||||||
state = OverlayViewState.CONTROLLER
|
state = OverlayViewState.CONTROLLER
|
||||||
true
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
false
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -221,6 +235,89 @@ fun PlaybackOverlay(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (playlist.hasNext()) {
|
||||||
|
Text(
|
||||||
|
text = "Queue",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp, top = 16.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) state = OverlayViewState.QUEUE
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
state == OverlayViewState.QUEUE,
|
||||||
|
enter = slideInVertically { it / 2 } + fadeIn(),
|
||||||
|
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||||
|
) {
|
||||||
|
if (playlist.hasNext()) {
|
||||||
|
val items = remember { playlist.upcomingItems() }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
.onPreviewKeyEvent { e ->
|
||||||
|
if (e.type == KeyEventType.KeyUp && isUp(e)) {
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
state = OverlayViewState.CHAPTERS
|
||||||
|
} else {
|
||||||
|
state = OverlayViewState.CONTROLLER
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Queue",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRestorer(focusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
itemsIndexed(items) { index, item ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
SeasonCard(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
onClickPlaylist.invoke(item)
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
},
|
||||||
|
onLongClick = {},
|
||||||
|
imageHeight = 140.dp,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -291,6 +388,7 @@ fun PlaybackOverlay(
|
||||||
enum class OverlayViewState {
|
enum class OverlayViewState {
|
||||||
CONTROLLER,
|
CONTROLLER,
|
||||||
CHAPTERS,
|
CHAPTERS,
|
||||||
|
QUEUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -300,7 +398,6 @@ enum class OverlayViewState {
|
||||||
fun Controller(
|
fun Controller(
|
||||||
title: String?,
|
title: String?,
|
||||||
subtitleStreams: List<SubtitleStream>,
|
subtitleStreams: List<SubtitleStream>,
|
||||||
chapters: List<Chapter>,
|
|
||||||
playerControls: Player,
|
playerControls: Player,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
showPlay: Boolean,
|
showPlay: Boolean,
|
||||||
|
|
@ -318,9 +415,12 @@ fun Controller(
|
||||||
moreButtonOptions: MoreButtonOptions,
|
moreButtonOptions: MoreButtonOptions,
|
||||||
currentPlayback: CurrentPlayback?,
|
currentPlayback: CurrentPlayback?,
|
||||||
audioStreams: List<AudioStream>,
|
audioStreams: List<AudioStream>,
|
||||||
|
nextState: OverlayViewState?,
|
||||||
|
currentSegment: MediaSegmentDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
onNextStateFocus: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
@ -370,13 +470,34 @@ fun Controller(
|
||||||
seekBack = seekBack,
|
seekBack = seekBack,
|
||||||
seekForward = seekForward,
|
seekForward = seekForward,
|
||||||
skipBackOnResume = skipBackOnResume,
|
skipBackOnResume = skipBackOnResume,
|
||||||
|
currentSegment = currentSegment,
|
||||||
)
|
)
|
||||||
if (chapters.isNotEmpty()) {
|
when (nextState) {
|
||||||
Text(
|
OverlayViewState.CHAPTERS ->
|
||||||
text = "Chapters",
|
Text(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
text = "Chapters",
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp, top = 16.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onNextStateFocus.invoke()
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OverlayViewState.QUEUE ->
|
||||||
|
Text(
|
||||||
|
text = "Queue",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp, top = 16.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onNextStateFocus.invoke()
|
||||||
|
}.focusable(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,13 @@ import androidx.media3.ui.SubtitleView
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||||
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||||
import androidx.media3.ui.compose.state.rememberNextButtonState
|
|
||||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||||
import androidx.media3.ui.compose.state.rememberPreviousButtonState
|
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.Button
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import com.github.damontecres.dolphin.data.model.Playlist
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.preferences.skipBackOnResume
|
import com.github.damontecres.dolphin.preferences.skipBackOnResume
|
||||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||||
|
|
@ -101,11 +100,13 @@ fun PlaybackPage(
|
||||||
val chapters by viewModel.chapters.observeAsState(listOf())
|
val chapters by viewModel.chapters.observeAsState(listOf())
|
||||||
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
|
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
|
||||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
||||||
|
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
|
var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
||||||
val nextUp by viewModel.nextUpEpisode.observeAsState(null)
|
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||||
|
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||||
|
|
||||||
// TODO move to viewmodel?
|
// TODO move to viewmodel?
|
||||||
val cueListener =
|
val cueListener =
|
||||||
|
|
@ -138,8 +139,6 @@ fun PlaybackPage(
|
||||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val playPauseState = rememberPlayPauseButtonState(player)
|
val playPauseState = rememberPlayPauseButtonState(player)
|
||||||
val previousState = rememberPreviousButtonState(player)
|
|
||||||
val nextState = rememberNextButtonState(player)
|
|
||||||
val seekBarState = rememberSeekBarState(player, scope)
|
val seekBarState = rememberSeekBarState(player, scope)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -181,19 +180,26 @@ fun PlaybackPage(
|
||||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val showSegment =
|
||||||
|
!segmentCancelled && currentSegment != null &&
|
||||||
|
!controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||||
|
BackHandler(showSegment) {
|
||||||
|
segmentCancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.background(Color.Black)
|
.background(Color.Black),
|
||||||
.onKeyEvent(keyHandler::onKeyEvent)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.focusable(),
|
|
||||||
) {
|
) {
|
||||||
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f)
|
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f)
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize(playerSize)
|
.fillMaxSize(playerSize)
|
||||||
.align(Alignment.TopCenter),
|
.align(Alignment.TopCenter)
|
||||||
|
.onKeyEvent(keyHandler::onKeyEvent)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable(),
|
||||||
) {
|
) {
|
||||||
PlayerSurface(
|
PlayerSurface(
|
||||||
player = player,
|
player = player,
|
||||||
|
|
@ -261,8 +267,8 @@ fun PlaybackPage(
|
||||||
playerControls = player,
|
playerControls = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
previousEnabled = previousState.isEnabled,
|
previousEnabled = true,
|
||||||
nextEnabled = nextState.isEnabled,
|
nextEnabled = playlist.hasNext(),
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
|
@ -290,6 +296,20 @@ fun PlaybackPage(
|
||||||
is PlaybackAction.ToggleCaptions -> {
|
is PlaybackAction.ToggleCaptions -> {
|
||||||
viewModel.changeSubtitleStream(it.index)
|
viewModel.changeSubtitleStream(it.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackAction.Next -> {
|
||||||
|
// TODO focus is lost
|
||||||
|
viewModel.playUpNextUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.Previous -> {
|
||||||
|
val pos = player.currentPosition
|
||||||
|
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
||||||
|
viewModel.playPrevious()
|
||||||
|
} else {
|
||||||
|
player.seekToPrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSeekBarChange = seekBarState::onValueChange,
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
|
|
@ -302,6 +322,11 @@ fun PlaybackPage(
|
||||||
trickplayInfo = trickplay,
|
trickplayInfo = trickplay,
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
|
playlist = playlist,
|
||||||
|
onClickPlaylist = {
|
||||||
|
viewModel.playItemInPlaylist(it)
|
||||||
|
},
|
||||||
|
currentSegment = currentSegment,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,7 +354,7 @@ fun PlaybackPage(
|
||||||
|
|
||||||
// Ask to skip intros, etc button
|
// Ask to skip intros, etc button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L,
|
showSegment,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(40.dp)
|
.padding(40.dp)
|
||||||
|
|
@ -337,7 +362,11 @@ fun PlaybackPage(
|
||||||
) {
|
) {
|
||||||
currentSegment?.let { segment ->
|
currentSegment?.let { segment ->
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
delay(10.seconds)
|
||||||
|
segmentCancelled = false
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||||
|
|
@ -354,7 +383,7 @@ fun PlaybackPage(
|
||||||
|
|
||||||
// Next up episode
|
// Next up episode
|
||||||
BackHandler(nextUp != null) {
|
BackHandler(nextUp != null) {
|
||||||
viewModel.cancelUpNextEpisode()
|
viewModel.navigationManager.goBack()
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
nextUp != null,
|
nextUp != null,
|
||||||
|
|
@ -374,7 +403,6 @@ fun PlaybackPage(
|
||||||
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
|
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// TODO need extra back press for some reason
|
|
||||||
BackHandler(timeLeft > 0 && autoPlayEnabled) {
|
BackHandler(timeLeft > 0 && autoPlayEnabled) {
|
||||||
timeLeft = -1
|
timeLeft = -1
|
||||||
autoPlayEnabled = false
|
autoPlayEnabled = false
|
||||||
|
|
@ -382,14 +410,14 @@ fun PlaybackPage(
|
||||||
if (autoPlayEnabled) {
|
if (autoPlayEnabled) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (timeLeft == 0L) {
|
if (timeLeft == 0L) {
|
||||||
viewModel.playUpNextEpisode()
|
viewModel.playUpNextUp()
|
||||||
} else {
|
} else {
|
||||||
while (timeLeft > 0) {
|
while (timeLeft > 0) {
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
timeLeft--
|
timeLeft--
|
||||||
}
|
}
|
||||||
if (timeLeft == 0L && autoPlayEnabled) {
|
if (timeLeft == 0L && autoPlayEnabled) {
|
||||||
viewModel.playUpNextEpisode()
|
viewModel.playUpNextUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -403,7 +431,7 @@ fun PlaybackPage(
|
||||||
description = it.data.overview,
|
description = it.data.overview,
|
||||||
imageUrl = it.imageUrl,
|
imageUrl = it.imageUrl,
|
||||||
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
|
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
|
||||||
onClick = { viewModel.playUpNextEpisode() },
|
onClick = { viewModel.playUpNextUp() },
|
||||||
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.dolphin.ui.playback
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
|
@ -16,22 +17,23 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
import com.github.damontecres.dolphin.data.model.Chapter
|
import com.github.damontecres.dolphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.dolphin.data.model.Playlist
|
||||||
|
import com.github.damontecres.dolphin.data.model.PlaylistCreator
|
||||||
import com.github.damontecres.dolphin.preferences.AppPreference
|
import com.github.damontecres.dolphin.preferences.AppPreference
|
||||||
import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior
|
import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.DefaultItemFields
|
|
||||||
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
|
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import com.github.damontecres.dolphin.util.ApiRequestPager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
|
import com.github.damontecres.dolphin.ui.showToast
|
||||||
import com.github.damontecres.dolphin.util.EqualityMutableLiveData
|
import com.github.damontecres.dolphin.util.EqualityMutableLiveData
|
||||||
import com.github.damontecres.dolphin.util.ExceptionHandler
|
import com.github.damontecres.dolphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
|
|
||||||
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
||||||
import com.github.damontecres.dolphin.util.TrackSupport
|
import com.github.damontecres.dolphin.util.TrackSupport
|
||||||
import com.github.damontecres.dolphin.util.checkForSupport
|
import com.github.damontecres.dolphin.util.checkForSupport
|
||||||
import com.github.damontecres.dolphin.util.formatDateTime
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
import com.github.damontecres.dolphin.util.seasonEpisodePadded
|
import com.github.damontecres.dolphin.util.seasonEpisodePadded
|
||||||
import com.github.damontecres.dolphin.util.subtitleMimeTypes
|
import com.github.damontecres.dolphin.util.subtitleMimeTypes
|
||||||
|
import com.github.damontecres.dolphin.util.supportItemKinds
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -53,10 +55,10 @@ import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -74,7 +76,7 @@ enum class TranscodeType {
|
||||||
|
|
||||||
data class StreamDecision(
|
data class StreamDecision(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val type: TranscodeType,
|
val type: PlayMethod,
|
||||||
val url: String,
|
val url: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,8 +84,10 @@ data class StreamDecision(
|
||||||
class PlaybackViewModel
|
class PlaybackViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
|
val playlistCreator: PlaylistCreator,
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val player: ExoPlayer =
|
val player: ExoPlayer =
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
|
|
@ -111,9 +115,10 @@ class PlaybackViewModel
|
||||||
private lateinit var dto: BaseItemDto
|
private lateinit var dto: BaseItemDto
|
||||||
private var activityListener: TrackActivityPlaybackListener? = null
|
private var activityListener: TrackActivityPlaybackListener? = null
|
||||||
|
|
||||||
private val episodes = MutableLiveData<ApiRequestPager<GetEpisodesRequest>>()
|
val nextUp = MutableLiveData<BaseItem?>()
|
||||||
private var currentEpisodeIndex = Int.MAX_VALUE
|
private var isPlaylist = false
|
||||||
val nextUpEpisode = MutableLiveData<BaseItem?>()
|
|
||||||
|
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable { this@PlaybackViewModel.activityListener?.release() }
|
addCloseable { this@PlaybackViewModel.activityListener?.release() }
|
||||||
|
|
@ -125,14 +130,62 @@ class PlaybackViewModel
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
) {
|
) {
|
||||||
nextUpEpisode.value = null
|
nextUp.value = null
|
||||||
this.preferences = preferences
|
this.preferences = preferences
|
||||||
this.deviceProfile = deviceProfile
|
this.deviceProfile = deviceProfile
|
||||||
val itemId = destination.itemId
|
val itemId = destination.itemId
|
||||||
this.itemId = itemId
|
this.itemId = itemId
|
||||||
val item = destination.item
|
val item = destination.item
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||||
|
val base =
|
||||||
|
if (queriedItem.type == BaseItemKind.PLAYLIST) {
|
||||||
|
isPlaylist = true
|
||||||
|
val playlist =
|
||||||
|
playlistCreator.createFromPlaylistId(
|
||||||
|
queriedItem.id,
|
||||||
|
destination.startIndex,
|
||||||
|
destination.shuffle,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@PlaybackViewModel.playlist.value = playlist
|
||||||
|
}
|
||||||
|
// TODO start index
|
||||||
|
playlist.items.first().data
|
||||||
|
} else {
|
||||||
|
queriedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
val played = play(base, destination.positionMs)
|
||||||
|
if (!played) {
|
||||||
|
playUpNextUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
|
||||||
|
val playlist =
|
||||||
|
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@PlaybackViewModel.playlist.value = playlist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maybeSetupPlaylistListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun play(
|
||||||
|
base: BaseItemDto,
|
||||||
|
positionMs: Long,
|
||||||
|
): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Timber.i("Playing ${base.id}")
|
||||||
|
if (base.type !in supportItemKinds) {
|
||||||
|
showToast(
|
||||||
|
context,
|
||||||
|
"Unsupported type '${base.type}', skipping...",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
)
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
dto = base
|
dto = base
|
||||||
val title =
|
val title =
|
||||||
if (base.type == BaseItemKind.EPISODE) {
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
|
|
@ -188,7 +241,7 @@ class PlaybackViewModel
|
||||||
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
// TODO audio selection based on channel layout, etc
|
// TODO audio selection based on channel layout or preferences or default
|
||||||
val audioLanguage = preferences.userConfig.audioLanguagePreference
|
val audioLanguage = preferences.userConfig.audioLanguagePreference
|
||||||
val audioIndex =
|
val audioIndex =
|
||||||
if (audioLanguage != null) {
|
if (audioLanguage != null) {
|
||||||
|
|
@ -236,48 +289,38 @@ class PlaybackViewModel
|
||||||
SubtitlePlaybackMode.NONE -> null
|
SubtitlePlaybackMode.NONE -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.v("base.mediaStreams=${base.mediaStreams}")
|
// Timber.v("base.mediaStreams=${base.mediaStreams}")
|
||||||
Timber.v("subtitleTracks=$subtitleStreams")
|
// Timber.v("subtitleTracks=$subtitleStreams")
|
||||||
Timber.v("audioStreams=$audioStreams")
|
// Timber.v("audioStreams=$audioStreams")
|
||||||
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@PlaybackViewModel.audioStreams.value = audioStreams
|
this@PlaybackViewModel.audioStreams.value = audioStreams
|
||||||
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
|
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
|
||||||
this@PlaybackViewModel.activityListener?.let {
|
|
||||||
it.release()
|
|
||||||
player.removeListener(it)
|
|
||||||
}
|
|
||||||
val activityListener =
|
|
||||||
TrackActivityPlaybackListener(api, itemId, player)
|
|
||||||
player.addListener(activityListener)
|
|
||||||
this@PlaybackViewModel.activityListener = activityListener
|
|
||||||
|
|
||||||
changeStreams(
|
changeStreams(
|
||||||
itemId,
|
base,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
|
if (positionMs > 0) positionMs else C.TIME_UNSET,
|
||||||
)
|
)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
|
|
||||||
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api)
|
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
|
||||||
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}")
|
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
|
||||||
}
|
|
||||||
if (base.type == BaseItemKind.EPISODE) {
|
|
||||||
base.seriesId?.let(::getEpisodes)
|
|
||||||
}
|
}
|
||||||
listenForSegments()
|
listenForSegments()
|
||||||
|
return@withContext true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private suspend fun changeStreams(
|
private suspend fun changeStreams(
|
||||||
itemId: UUID,
|
item: BaseItemDto,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
positionMs: Long = C.TIME_UNSET,
|
positionMs: Long = C.TIME_UNSET,
|
||||||
) {
|
) {
|
||||||
|
val itemId = item.id
|
||||||
if (currentPlayback.value?.let {
|
if (currentPlayback.value?.let {
|
||||||
it.itemId == itemId &&
|
it.itemId == itemId &&
|
||||||
it.audioIndex == audioIndex &&
|
it.audioIndex == audioIndex &&
|
||||||
|
|
@ -287,10 +330,11 @@ class PlaybackViewModel
|
||||||
Timber.i("No change in playback for changeStreams")
|
Timber.i("No change in playback for changeStreams")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
|
||||||
val maxBitrate =
|
val maxBitrate =
|
||||||
preferences.appPreferences.playbackPreferences.maxBitrate
|
preferences.appPreferences.playbackPreferences.maxBitrate
|
||||||
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
|
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
|
||||||
val response =
|
val response by
|
||||||
api.mediaInfoApi
|
api.mediaInfoApi
|
||||||
.getPostedPlaybackInfo(
|
.getPostedPlaybackInfo(
|
||||||
itemId,
|
itemId,
|
||||||
|
|
@ -309,7 +353,7 @@ class PlaybackViewModel
|
||||||
alwaysBurnInSubtitleWhenTranscoding = null,
|
alwaysBurnInSubtitleWhenTranscoding = null,
|
||||||
maxStreamingBitrate = maxBitrate.toInt(),
|
maxStreamingBitrate = maxBitrate.toInt(),
|
||||||
),
|
),
|
||||||
).content
|
)
|
||||||
val source = response.mediaSources.firstOrNull()
|
val source = response.mediaSources.firstOrNull()
|
||||||
source?.let { source ->
|
source?.let { source ->
|
||||||
val mediaUrl =
|
val mediaUrl =
|
||||||
|
|
@ -328,9 +372,9 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
val transcodeType =
|
val transcodeType =
|
||||||
when {
|
when {
|
||||||
source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
|
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
|
||||||
source.supportsDirectStream -> TranscodeType.DIRECT_STREAM
|
source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
|
||||||
source.supportsTranscoding -> TranscodeType.TRANSCODE
|
source.supportsTranscoding -> PlayMethod.TRANSCODE
|
||||||
else -> throw Exception("No supported playback method")
|
else -> throw Exception("No supported playback method")
|
||||||
}
|
}
|
||||||
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
||||||
|
|
@ -342,8 +386,8 @@ class PlaybackViewModel
|
||||||
?.let {
|
?.let {
|
||||||
it.deliveryUrl?.let { deliveryUrl ->
|
it.deliveryUrl?.let { deliveryUrl ->
|
||||||
var flags = 0
|
var flags = 0
|
||||||
if (it.isForced) flags = flags.and(C.SELECTION_FLAG_FORCED)
|
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
|
||||||
if (it.isDefault) flags = flags.and(C.SELECTION_FLAG_DEFAULT)
|
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
|
||||||
MediaItem.SubtitleConfiguration
|
MediaItem.SubtitleConfiguration
|
||||||
.Builder(
|
.Builder(
|
||||||
api.createUrl(deliveryUrl).toUri(),
|
api.createUrl(deliveryUrl).toUri(),
|
||||||
|
|
@ -370,9 +414,25 @@ class PlaybackViewModel
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
source.id?.toUUIDOrNull(),
|
source.id?.toUUIDOrNull(),
|
||||||
listOf(),
|
listOf(),
|
||||||
|
playMethod = transcodeType,
|
||||||
|
playSessionId = response.playSessionId,
|
||||||
)
|
)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
// TODO, don't need to release & recreate when switching streams
|
||||||
|
this@PlaybackViewModel.activityListener?.let {
|
||||||
|
it.release()
|
||||||
|
player.removeListener(it)
|
||||||
|
}
|
||||||
|
val activityListener =
|
||||||
|
TrackActivityPlaybackListener(
|
||||||
|
api = api,
|
||||||
|
player = player,
|
||||||
|
playback = playback,
|
||||||
|
)
|
||||||
|
player.addListener(activityListener)
|
||||||
|
this@PlaybackViewModel.activityListener = activityListener
|
||||||
|
|
||||||
duration.value = source.runTimeTicks?.ticks
|
duration.value = source.runTimeTicks?.ticks
|
||||||
stream.value = decision
|
stream.value = decision
|
||||||
currentPlayback.value = playback
|
currentPlayback.value = playback
|
||||||
|
|
@ -381,7 +441,7 @@ class PlaybackViewModel
|
||||||
positionMs,
|
positionMs,
|
||||||
)
|
)
|
||||||
if (audioIndex != null || subtitleIndex != null) {
|
if (audioIndex != null || subtitleIndex != null) {
|
||||||
val trackActivationListener =
|
val onTracksChangedListener =
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
Timber.v("onTracksChanged: $tracks")
|
Timber.v("onTracksChanged: $tracks")
|
||||||
|
|
@ -400,15 +460,15 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addListener(trackActivationListener)
|
player.addListener(onTracksChangedListener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val trickPlayInfo =
|
val trickPlayInfo =
|
||||||
dto.trickplay
|
item.trickplay
|
||||||
?.get(source.id)
|
?.get(source.id)
|
||||||
?.values
|
?.values
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
Timber.v("Trickplay info: $trickPlayInfo")
|
// Timber.v("Trickplay info: $trickPlayInfo")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
trickplay.value = trickPlayInfo
|
trickplay.value = trickPlayInfo
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +479,7 @@ class PlaybackViewModel
|
||||||
val itemId = currentPlayback.value?.itemId ?: return
|
val itemId = currentPlayback.value?.itemId ?: return
|
||||||
viewModelScope.launch(ExceptionHandler()) {
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
changeStreams(
|
changeStreams(
|
||||||
itemId,
|
dto,
|
||||||
index,
|
index,
|
||||||
currentPlayback.value?.subtitleIndex,
|
currentPlayback.value?.subtitleIndex,
|
||||||
player.currentPosition,
|
player.currentPosition,
|
||||||
|
|
@ -431,7 +491,7 @@ class PlaybackViewModel
|
||||||
val itemId = currentPlayback.value?.itemId ?: return
|
val itemId = currentPlayback.value?.itemId ?: return
|
||||||
viewModelScope.launch(ExceptionHandler()) {
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
changeStreams(
|
changeStreams(
|
||||||
itemId,
|
dto,
|
||||||
currentPlayback.value?.audioIndex,
|
currentPlayback.value?.audioIndex,
|
||||||
index,
|
index,
|
||||||
player.currentPosition,
|
player.currentPosition,
|
||||||
|
|
@ -451,56 +511,26 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getEpisodes(seriesId: UUID) {
|
private fun maybeSetupPlaylistListener() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
playlist.value?.let { playlist ->
|
||||||
val request =
|
if (playlist.hasNext()) {
|
||||||
GetEpisodesRequest(
|
Timber.v("Adding lister for playlist with ${playlist.items.size} items")
|
||||||
seriesId = seriesId,
|
val listener =
|
||||||
fields = DefaultItemFields,
|
object : Player.Listener {
|
||||||
startItemId = itemId,
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
limit = 2,
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
)
|
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
|
val nextItem = playlist.peek()
|
||||||
val currentEpisodeIndex = episodes.indexOfFirstOrNull { it.id == itemId }
|
Timber.v("Setting next up to ${nextItem?.id}")
|
||||||
Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}")
|
withContext(Dispatchers.Main) {
|
||||||
if (currentEpisodeIndex != null) {
|
nextUp.value = nextItem
|
||||||
val nextIndex = currentEpisodeIndex + 1
|
|
||||||
if (nextIndex < episodes.size) {
|
|
||||||
val listener =
|
|
||||||
object : Player.Listener {
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val nextItem = BaseItem.from(episodes[nextIndex], api)
|
|
||||||
Timber.v("Setting next up episode to ${nextItem.id}")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
nextUpEpisode.value = nextItem
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
player.removeListener(this)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addListener(listener)
|
}
|
||||||
|
player.addListener(listener)
|
||||||
// viewModelScope.launch(Dispatchers.IO) {
|
addCloseable { player.removeListener(listener) }
|
||||||
// while (this.isActive) {
|
|
||||||
// delay(5.seconds)
|
|
||||||
// val remaining =
|
|
||||||
// withContext(Dispatchers.Main) {
|
|
||||||
// (player.duration - player.currentPosition).milliseconds
|
|
||||||
// }
|
|
||||||
// if (remaining < 2.minutes) { // TODO time & preference
|
|
||||||
// val nextItem = episodes.getBlocking(nextIndex)
|
|
||||||
// Timber.v("Setting next up episode to ${nextItem?.id}")
|
|
||||||
// withContext(Dispatchers.Main) {
|
|
||||||
// nextUpEpisode.value = nextItem
|
|
||||||
// }
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -564,14 +594,54 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playUpNextEpisode() {
|
fun playUpNextUp() {
|
||||||
nextUpEpisode.value?.let {
|
playlist.value?.let {
|
||||||
init(Destination.Playback(it.id, 0, it), deviceProfile, preferences)
|
if (it.hasNext()) {
|
||||||
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
|
cancelUpNextEpisode()
|
||||||
|
val item = it.getAndAdvance()
|
||||||
|
val played = play(item.data, 0)
|
||||||
|
if (!played) {
|
||||||
|
playUpNextUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playPrevious() {
|
||||||
|
playlist.value?.let {
|
||||||
|
if (it.hasPrevious()) {
|
||||||
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
|
cancelUpNextEpisode()
|
||||||
|
val item = it.getPreviousAndReverse()
|
||||||
|
val played = play(item.data, 0)
|
||||||
|
if (!played) {
|
||||||
|
playPrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelUpNextEpisode() {
|
fun cancelUpNextEpisode() {
|
||||||
nextUpEpisode.value = null
|
nextUp.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playItemInPlaylist(item: BaseItem) {
|
||||||
|
playlist.value?.let { playlist ->
|
||||||
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
|
val toPlay = playlist.advanceTo(item.id)
|
||||||
|
if (toPlay != null) {
|
||||||
|
val played = play(toPlay.data, 0)
|
||||||
|
if (!played) {
|
||||||
|
playUpNextUp()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,6 +651,8 @@ data class CurrentPlayback(
|
||||||
val subtitleIndex: Int?,
|
val subtitleIndex: Int?,
|
||||||
val mediaSourceId: UUID?,
|
val mediaSourceId: UUID?,
|
||||||
val tracks: List<TrackSupport>,
|
val tracks: List<TrackSupport>,
|
||||||
|
val playMethod: PlayMethod,
|
||||||
|
val playSessionId: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val Format.idAsInt: Int?
|
private val Format.idAsInt: Int?
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
|
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SteppedSeekBarImpl(
|
fun SteppedSeekBarImpl(
|
||||||
|
|
@ -105,6 +105,8 @@ fun IntervalSeekBarImpl(
|
||||||
bufferedProgress: Float,
|
bufferedProgress: Float,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
|
seekBack: Duration,
|
||||||
|
seekForward: Duration,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
|
@ -120,21 +122,20 @@ fun IntervalSeekBarImpl(
|
||||||
if (!isFocused) hasSeeked = false
|
if (!isFocused) hasSeeked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val offset = 30.seconds.inWholeMilliseconds
|
|
||||||
|
|
||||||
SeekBarDisplay(
|
SeekBarDisplay(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
progress = (progressToUse.toDouble() / durationMs).toFloat(),
|
progress = (progressToUse.toDouble() / durationMs).toFloat(),
|
||||||
bufferedProgress = bufferedProgress,
|
bufferedProgress = bufferedProgress,
|
||||||
onLeft = {
|
onLeft = {
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
seekPositionMs = (progressToUse - offset).coerceAtLeast(0L)
|
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
|
||||||
hasSeeked = true
|
hasSeeked = true
|
||||||
onSeek(seekPositionMs)
|
onSeek(seekPositionMs)
|
||||||
},
|
},
|
||||||
onRight = {
|
onRight = {
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
seekPositionMs = (progressToUse + offset).coerceAtMost(durationMs)
|
seekPositionMs =
|
||||||
|
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
|
||||||
hasSeeked = true
|
hasSeeked = true
|
||||||
onSeek(seekPositionMs)
|
onSeek(seekPositionMs)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
import com.github.damontecres.dolphin.R
|
import com.github.damontecres.dolphin.R
|
||||||
import com.github.damontecres.dolphin.preferences.AppPreference
|
import com.github.damontecres.dolphin.preferences.AppPreference
|
||||||
import com.github.damontecres.dolphin.preferences.AppPreferences
|
import com.github.damontecres.dolphin.preferences.AppPreferences
|
||||||
|
|
@ -245,6 +246,22 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppPreference.ClearImageCache -> {
|
||||||
|
ClickPreference(
|
||||||
|
title = stringResource(pref.title),
|
||||||
|
onClick = {
|
||||||
|
SingletonImageLoader.get(context).let {
|
||||||
|
it.memoryCache?.clear()
|
||||||
|
it.diskCache?.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier,
|
||||||
|
summary = null,
|
||||||
|
onLongClick = {},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val value = pref.getter.invoke(preferences)
|
val value = pref.getter.invoke(preferences)
|
||||||
ComposablePreference(
|
ComposablePreference(
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ import kotlinx.coroutines.sync.withLock
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.Response
|
import org.jellyfin.sdk.api.client.Response
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -271,3 +273,22 @@ val GetSuggestionsRequestHandler =
|
||||||
request: GetSuggestionsRequest,
|
request: GetSuggestionsRequest,
|
||||||
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
|
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val GetPlaylistItemsRequestHandler =
|
||||||
|
object : RequestHandler<GetPlaylistItemsRequest> {
|
||||||
|
override fun prepare(
|
||||||
|
request: GetPlaylistItemsRequest,
|
||||||
|
startIndex: Int,
|
||||||
|
limit: Int,
|
||||||
|
enableTotalRecordCount: Boolean,
|
||||||
|
): GetPlaylistItemsRequest =
|
||||||
|
request.copy(
|
||||||
|
startIndex = startIndex,
|
||||||
|
limit = limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun execute(
|
||||||
|
api: ApiClient,
|
||||||
|
request: GetPlaylistItemsRequest,
|
||||||
|
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,6 @@ val supportedCollectionTypes =
|
||||||
CollectionType.MOVIES,
|
CollectionType.MOVIES,
|
||||||
CollectionType.TVSHOWS,
|
CollectionType.TVSHOWS,
|
||||||
CollectionType.HOMEVIDEOS,
|
CollectionType.HOMEVIDEOS,
|
||||||
|
CollectionType.PLAYLISTS,
|
||||||
|
CollectionType.BOXSETS,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter
|
||||||
*/
|
*/
|
||||||
fun formatDateTime(dateTime: LocalDateTime): String =
|
fun formatDateTime(dateTime: LocalDateTime): String =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// TODO server returns in UTC, but sdk converts to local time
|
||||||
|
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
|
||||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||||
formatter.format(dateTime)
|
formatter.format(dateTime)
|
||||||
} else if (dateTime.toString().length >= 10) {
|
} else if (dateTime.toString().length >= 10) {
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,22 @@ package com.github.damontecres.dolphin.util
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.github.damontecres.dolphin.ui.playback.CurrentPlayback
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
|
||||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||||
|
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||||
import org.jellyfin.sdk.model.api.RepeatMode
|
import org.jellyfin.sdk.model.api.RepeatMode
|
||||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Timer
|
import java.util.Timer
|
||||||
import java.util.TimerTask
|
import java.util.TimerTask
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
@ -29,8 +30,8 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class TrackActivityPlaybackListener(
|
class TrackActivityPlaybackListener(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val itemId: UUID,
|
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
|
var playback: CurrentPlayback,
|
||||||
) : Player.Listener {
|
) : Player.Listener {
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
private val coroutineScope = CoroutineScope(Dispatchers.Main)
|
||||||
private val task: TimerTask
|
private val task: TimerTask
|
||||||
|
|
@ -41,6 +42,21 @@ class TrackActivityPlaybackListener(
|
||||||
private var incrementedPlayCount = AtomicBoolean(false)
|
private var incrementedPlayCount = AtomicBoolean(false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
|
api.playStateApi.reportPlaybackStart(
|
||||||
|
PlaybackStartInfo(
|
||||||
|
canSeek = true,
|
||||||
|
itemId = playback.itemId,
|
||||||
|
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
|
||||||
|
playMethod = playback.playMethod,
|
||||||
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
|
isMuted = false,
|
||||||
|
audioStreamIndex = playback.audioIndex,
|
||||||
|
subtitleStreamIndex = playback.subtitleIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
val saveActivityInterval = 10.seconds.inWholeMilliseconds
|
val saveActivityInterval = 10.seconds.inWholeMilliseconds
|
||||||
val delay = 1.seconds.inWholeMilliseconds
|
val delay = 1.seconds.inWholeMilliseconds
|
||||||
// Every x seconds, check if the video is playing
|
// Every x seconds, check if the video is playing
|
||||||
|
|
@ -74,12 +90,13 @@ class TrackActivityPlaybackListener(
|
||||||
fun release() {
|
fun release() {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
coroutineScope.launch(ExceptionHandler()) {
|
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
api.playStateApi.reportPlaybackStopped(
|
api.playStateApi.reportPlaybackStopped(
|
||||||
PlaybackStopInfo(
|
PlaybackStopInfo(
|
||||||
itemId = itemId,
|
itemId = playback.itemId,
|
||||||
positionTicks = player.currentPosition.milliseconds.inWholeTicks,
|
positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks },
|
||||||
failed = false,
|
failed = false,
|
||||||
|
playSessionId = playback.playSessionId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -98,28 +115,32 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
Timber.Forest.v("onPlaybackStateChanged STATE_ENDED")
|
Timber.v("onPlaybackStateChanged STATE_ENDED")
|
||||||
// TODO mark as watched
|
saveActivity(player.duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
coroutineScope.launch(ExceptionHandler()) {
|
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
val totalDuration = totalPlayDurationMilliseconds.get()
|
// val totalDuration = totalPlayDurationMilliseconds.get()
|
||||||
val calcPosition =
|
val calcPosition =
|
||||||
(if (position >= 0) position else player.currentPosition).milliseconds
|
withContext(Dispatchers.Main) {
|
||||||
Timber.Forest.v("saveActivity: itemId=$itemId, pos=$calcPosition")
|
(if (position >= 0) position else player.currentPosition).milliseconds
|
||||||
// TODO parameters
|
}
|
||||||
|
// Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
|
||||||
api.playStateApi.reportPlaybackProgress(
|
api.playStateApi.reportPlaybackProgress(
|
||||||
PlaybackProgressInfo(
|
PlaybackProgressInfo(
|
||||||
itemId = itemId,
|
itemId = playback.itemId,
|
||||||
positionTicks = calcPosition.inWholeTicks,
|
positionTicks = calcPosition.inWholeTicks,
|
||||||
canSeek = true,
|
canSeek = true,
|
||||||
isPaused = !player.isPlaying,
|
isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
playMethod = PlayMethod.DIRECT_PLAY,
|
playMethod = playback.playMethod,
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
|
playSessionId = playback.playSessionId,
|
||||||
|
audioStreamIndex = playback.audioIndex,
|
||||||
|
subtitleStreamIndex = playback.subtitleIndex,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
@ -212,8 +213,18 @@ class UpdateChecker
|
||||||
intent.data = uri
|
intent.data = uri
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
} else {
|
} else {
|
||||||
Timber.e("Resolver URI is null")
|
Timber.e("Resolver URI is null, trying fallback")
|
||||||
// TODO show error Toast
|
// showToast(context, "Unable to download the apk")
|
||||||
|
val targetFile = fallbackDownload(it)
|
||||||
|
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
intent.data =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
activity,
|
||||||
|
activity.packageName + ".provider",
|
||||||
|
targetFile,
|
||||||
|
)
|
||||||
|
activity.startActivity(intent)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (ContextCompat.checkSelfPermission(
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
|
@ -234,13 +245,7 @@ class UpdateChecker
|
||||||
PERMISSION_REQUEST_CODE,
|
PERMISSION_REQUEST_CODE,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val downloadDir =
|
val targetFile = fallbackDownload(it)
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
downloadDir.mkdirs()
|
|
||||||
val targetFile = File(downloadDir, ASSET_NAME)
|
|
||||||
targetFile.outputStream().use { output ->
|
|
||||||
it.body!!.byteStream().copyTo(output)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
|
@ -261,12 +266,23 @@ class UpdateChecker
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
|
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
|
||||||
// TODO show error toast
|
showToast(context, "Error downloading the apk: ${it.code}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fallbackDownload(response: Response): File {
|
||||||
|
val downloadDir =
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
downloadDir.mkdirs()
|
||||||
|
val targetFile = File(downloadDir, ASSET_NAME)
|
||||||
|
targetFile.outputStream().use { output ->
|
||||||
|
response.body!!.byteStream().copyTo(output)
|
||||||
|
}
|
||||||
|
return targetFile
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete previously downloaded APKs
|
* Delete previously downloaded APKs
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ message PlaybackPreferences {
|
||||||
message HomePagePreferences{
|
message HomePagePreferences{
|
||||||
int32 max_items_per_row = 1;
|
int32 max_items_per_row = 1;
|
||||||
bool enable_rewatching_next_up = 2;
|
bool enable_rewatching_next_up = 2;
|
||||||
|
bool combine_continue_next = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ThemeSongVolume {
|
enum ThemeSongVolume {
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,7 @@
|
||||||
<string name="fa_eye" translatable="false"></string>
|
<string name="fa_eye" translatable="false"></string>
|
||||||
<string name="fa_eye_slash" translatable="false"></string>
|
<string name="fa_eye_slash" translatable="false"></string>
|
||||||
<string name="fa_arrow_left_arrow_right" 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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -74,5 +74,8 @@
|
||||||
<string name="update_url_summary">URL used to check for app updates</string>
|
<string name="update_url_summary">URL used to check for app updates</string>
|
||||||
<string name="updates">Updates</string>
|
<string name="updates">Updates</string>
|
||||||
<string name="no_update_available">No update available</string>
|
<string name="no_update_available">No update available</string>
|
||||||
|
<string name="clear_image_cache">Clear image cache</string>
|
||||||
|
<string name="shuffle">Shuffle</string>
|
||||||
|
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
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]
|
[versions]
|
||||||
aboutLibraries = "12.2.4"
|
aboutLibraries = "12.2.4"
|
||||||
agp = "8.13.0"
|
agp = "8.13.0"
|
||||||
|
desugar_jdk_libs = "2.1.5"
|
||||||
hiltNavigationCompose = "1.3.0"
|
hiltNavigationCompose = "1.3.0"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.20"
|
||||||
ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken
|
ksp = "2.2.20-2.0.4"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
composeBom = "2025.09.01"
|
composeBom = "2025.10.00"
|
||||||
compose-runtime = "1.9.2"
|
compose-runtime = "1.9.3"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
tvFoundation = "1.0.0-alpha12"
|
tvFoundation = "1.0.0-alpha12"
|
||||||
tvMaterial = "1.0.1"
|
tvMaterial = "1.0.1"
|
||||||
|
|
@ -15,9 +16,9 @@ lifecycleRuntimeKtx = "2.9.4"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
androidx-media3 = "1.8.0"
|
androidx-media3 = "1.8.0"
|
||||||
coil = "3.3.0"
|
coil = "3.3.0"
|
||||||
jellyfin-sdk = "1.7.0"
|
jellyfin-sdk = "1.7.1"
|
||||||
nav3Core = "1.0.0-alpha10"
|
nav3Core = "1.0.0-alpha11"
|
||||||
lifecycleViewmodelNav3 = "2.10.0-alpha04"
|
lifecycleViewmodelNav3 = "2.10.0-alpha05"
|
||||||
material3AdaptiveNav3 = "1.0.0-alpha03"
|
material3AdaptiveNav3 = "1.0.0-alpha03"
|
||||||
protobuf = "0.9.5"
|
protobuf = "0.9.5"
|
||||||
datastore = "1.1.7"
|
datastore = "1.1.7"
|
||||||
|
|
@ -25,7 +26,7 @@ kotlinx-coroutines-android = "1.10.2"
|
||||||
kotlinx-serialization = "1.9.0"
|
kotlinx-serialization = "1.9.0"
|
||||||
protobuf-javalite = "4.32.1"
|
protobuf-javalite = "4.32.1"
|
||||||
hilt = "2.57.2"
|
hilt = "2.57.2"
|
||||||
room = "2.8.1"
|
room = "2.8.2"
|
||||||
material3 = "1.4.0"
|
material3 = "1.4.0"
|
||||||
preferenceKtx = "1.2.1"
|
preferenceKtx = "1.2.1"
|
||||||
|
|
||||||
|
|
@ -51,6 +52,7 @@ androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.re
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||||
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
|
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue