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