This commit is contained in:
Damontecres 2025-09-23 16:14:01 -04:00
parent 611da0d60e
commit 6e185b25b7
No known key found for this signature in database
15 changed files with 955 additions and 44 deletions

View file

@ -2,13 +2,14 @@ package com.github.damontecres.dolphin.ui.cards
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.github.damontecres.dolphin.data.model.DolphinModel
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.data.model.Video
import org.jellyfin.sdk.model.api.BaseItemDto
@Composable
fun DolphinCard(
item: DolphinModel?,
item: Any?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -21,6 +22,10 @@ fun DolphinCard(
)
is Library -> TODO()
null -> TODO()
is BaseItemDto -> {
Text(item.id.toString())
}
null -> NullCard(modifier = modifier)
}
}

View file

@ -0,0 +1,56 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
@Composable
fun ItemCard(
item: BaseItemDto?,
imageUrlBuilder: (UUID) -> String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 150.dp,
cardHeight: Dp = 200.dp,
) {
if (item == null) {
NullCard(modifier, cardWidth, cardHeight)
} else {
Card(
modifier = modifier,
onClick = onClick,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
) {
AsyncImage(
model = imageUrlBuilder.invoke(item.id),
contentDescription = item.name,
contentScale = ContentScale.Fit,
modifier =
Modifier
.fillMaxWidth()
.height(cardHeight),
)
Text(
text = item.name ?: "",
)
Text(text = item.primaryImageAspectRatio.toString())
}
}
}
}

View file

@ -0,0 +1,30 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
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
@Composable
fun NullCard(
modifier: Modifier = Modifier,
cardWidth: Dp = 150.dp,
cardHeight: Dp = 200.dp,
) {
Card(
modifier = modifier,
onClick = {},
) {
Column(
modifier = Modifier.size(cardWidth, cardHeight),
) {
Text(
text = "Loading...",
)
}
}
}

View file

@ -1,9 +1,13 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxWidth
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.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
@ -24,14 +28,20 @@ fun VideoCard(
onClick = onClick,
) {
Column(
modifier = Modifier.size(cardWidth, cardHeight),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
) {
Text(
text = item.name ?: "",
)
AsyncImage(
model = item.imageUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
modifier =
Modifier
.fillMaxWidth()
.height(cardHeight),
)
Text(
text = item.name ?: "",
)
}
}

View file

@ -0,0 +1,445 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ifElse
import com.github.damontecres.dolphin.tryRequestFocus
import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.cards.ItemCard
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.playback.isBackwardButton
import com.github.damontecres.dolphin.ui.playback.isForwardButton
import com.github.damontecres.dolphin.ui.playback.isPlayKeyUp
import com.github.damontecres.dolphin.util.DolphinPager
import kotlinx.coroutines.launch
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.ImageType
import timber.log.Timber
import kotlin.math.max
private val DEBUG = false
@Composable
fun CardGrid(
api: ApiClient,
pager: DolphinPager,
itemOnClick: (BaseItemDto) -> Unit,
longClicker: (BaseItemDto) -> Unit,
letterPosition: suspend (Char) -> Int,
requestFocus: Boolean,
gridFocusRequester: FocusRequester,
navigationManager: NavigationManager,
modifier: Modifier = Modifier,
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 5
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() }
val zeroFocus = remember { FocusRequester() }
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
var focusedIndexOnExit by rememberSaveable { mutableIntStateOf(-1) }
// Tracks whether the very first requestFocus has run, if the caller isn't requesting focus,
// then the first time will never run
var hasRequestFocusRun by rememberSaveable { mutableStateOf(!requestFocus) }
var savedFocusedIndex by rememberSaveable { mutableIntStateOf(-1) }
if (DEBUG) {
Timber.d(
"StashGrid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex",
)
}
LaunchedEffect(Unit) {
if (!hasRequestFocusRun) {
// On very first composition, if parent wants to focus on the grid, scroll to the item
if (requestFocus && initialPosition >= 0) {
if (DEBUG) {
Timber.d(
"focus on startPosition=$startPosition, from initialPosition=$initialPosition",
)
}
focusedIndex = startPosition
gridState.scrollToItem(startPosition, 0)
firstFocus.tryRequestFocus()
}
} else {
val index = savedFocusedIndex
if (DEBUG) Timber.d("savedFocusedIndex=$index")
if (index in 0..<pager.size) {
// If this is a recomposition, but not the first
// focus on the restored index
// gridState.scrollToItem(index, -columns)
firstFocus.tryRequestFocus()
}
savedFocusedIndex = -1
}
// hasRun = true
}
val context = LocalContext.current
val showJumpButtons = true
var alphabetFocus by remember { mutableStateOf(false) }
val focusOn = { index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index")
if (index != focusedIndex) {
previouslyFocusedIndex = focusedIndex
}
focusedIndex = index
}
// Wait for a recomposition to focus
LaunchedEffect(alphabetFocus) {
if (alphabetFocus) {
firstFocus.tryRequestFocus()
}
alphabetFocus = false
}
val useBackToJump = true // uiConfig.preferences.interfacePreferences.scrollTopOnBack
val showFooter = true // uiConfig.preferences.interfacePreferences.showPositionFooter
val useJumpRemoteButtons = true // uiConfig.preferences.interfacePreferences.pageWithRemoteButtons
val jump2 =
remember {
if (pager.size >= 25_000) {
columns * 2000
} else if (pager.size >= 7_000) {
columns * 200
} else if (pager.size >= 2_000) {
columns * 50
} else {
columns * 20
}
}
val jump1 =
remember {
if (pager.size >= 25_000) {
columns * 500
} else if (pager.size >= 7_000) {
columns * 50
} else if (pager.size >= 2_000) {
columns * 15
} else {
columns * 6
}
}
val jump = { jump: Int ->
scope.launch {
val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition")
savedFocusedIndex = newPosition
focusOn(newPosition)
gridState.scrollToItem(newPosition, 0)
}
}
val jumpToTop = {
scope.launch {
if (focusedIndex < (columns * 6)) {
// If close, animate the scroll
gridState.animateScrollToItem(0, 0)
} else {
gridState.scrollToItem(0, 0)
}
focusOn(0)
zeroFocus.tryRequestFocus()
}
}
var longPressing by remember { mutableStateOf(false) }
Row(
// horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxSize()
.onKeyEvent {
if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}")
if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) {
longPressing = true
val newPosition = previouslyFocusedIndex
if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition")
if (newPosition > 0) {
focusOn(newPosition)
scope.launch {
gridState.scrollToItem(newPosition, -columns)
firstFocus.tryRequestFocus()
}
}
return@onKeyEvent true
} else if (it.type == KeyEventType.KeyUp) {
if (longPressing && it.key == Key.Back) {
longPressing = false
return@onKeyEvent true
}
longPressing = false
}
if (it.type != KeyEventType.KeyUp) {
return@onKeyEvent false
} else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) {
jumpToTop()
return@onKeyEvent true
} else if (isPlayKeyUp(it)) {
// TODO play the focused item
return@onKeyEvent true
} else if (useJumpRemoteButtons && isForwardButton(it)) {
jump(jump1)
return@onKeyEvent true
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
jump(-jump1)
return@onKeyEvent true
} else {
return@onKeyEvent false
}
},
) {
if (showJumpButtons && pager.size > 0) {
JumpButtons(
jump1 = jump1,
jump2 = jump2,
jumpClick = { jump(it) },
modifier = Modifier.align(Alignment.CenterVertically),
)
}
Box(
modifier = Modifier.weight(1f),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
state = gridState,
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.fillMaxSize()
.focusGroup()
.focusRequester(gridFocusRequester)
.focusProperties {
onExit = {
// Leaving the grid, so "forget" the position
focusedIndexOnExit = focusedIndex
focusedIndex = -1
savedFocusedIndex = -1
}
onEnter = {
focusedIndexOnExit = -1
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
focusedIndex = startPosition
firstFocus.tryRequestFocus()
}
}
},
) {
items(pager.size) { index ->
val mod =
if (index == savedFocusedIndex) {
if (DEBUG) Timber.d("Adding firstFocus to itemClickedIndex $index")
Modifier.focusRequester(firstFocus)
} else if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier.focusRequester(firstFocus)
} else {
Modifier
}
// TODO
val item = pager[index] // ?.let { convertModel(it, api) }
if (!hasRequestFocusRun && requestFocus && initialPosition >= 0) {
// On very first composition, if parent wants to focus on the grid, do so
LaunchedEffect(Unit) {
if (DEBUG) {
Timber.d(
"non-null focus on startPosition=$startPosition, from initialPosition=$initialPosition",
)
}
// focus on startPosition
gridState.scrollToItem(startPosition, 0)
firstFocus.tryRequestFocus()
hasRequestFocusRun = true
}
}
ItemCard(
modifier =
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
savedFocusedIndex = index
// Was focused on this, so mark unfocused
focusedIndex = -1
}
},
item = item,
onClick = { if (item != null) itemOnClick.invoke(item) },
onLongClick = { if (item != null) longClicker.invoke(item) },
imageUrlBuilder = { api.imageApi.getItemImageUrl(it, ImageType.PRIMARY) },
)
}
}
if (pager.size == 0) {
// focusedIndex = -1
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = "No results",
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.Center),
)
}
}
if (showFooter) {
// Footer
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.background(AppColors.TransparentBlack50),
) {
val index =
if (focusedIndex >= 0) {
focusedIndex + 1
} else {
max(savedFocusedIndex, focusedIndexOnExit) + 1
}
Text(
modifier = Modifier.padding(4.dp),
color = MaterialTheme.colorScheme.onBackground,
text = "$index / ${pager.size}",
)
}
}
}
// Letters
if (pager.isNotEmpty() && false) {
// TODO
AlphabetButtons(
modifier = Modifier.align(Alignment.CenterVertically),
letterClicked = { letter ->
scope.launch {
val jumpPosition = letterPosition.invoke(letter)
Timber.d("Alphabet jump to $jumpPosition")
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
// firstFocus.tryRequestFocus()
}
},
)
}
}
}
@Composable
fun JumpButtons(
jump1: Int,
jump2: Int,
jumpClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
JumpButton(R.string.fa_angles_up, -jump2, jumpClick)
JumpButton(R.string.fa_angle_up, -jump1, jumpClick)
JumpButton(R.string.fa_angle_down, jump1, jumpClick)
JumpButton(R.string.fa_angles_down, jump2, jumpClick)
}
}
@Composable
fun JumpButton(
@StringRes stringRes: Int,
jumpAmount: Int,
jumpClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Button(
modifier = modifier.width(40.dp),
contentPadding = PaddingValues(4.dp),
onClick = {
jumpClick.invoke(jumpAmount)
},
) {
Text(text = stringResource(stringRes), fontFamily = FontAwesome)
}
}
@Composable
fun AlphabetButtons(
letterClicked: (Char) -> Unit,
modifier: Modifier = Modifier,
) {
// LazyColumn(modifier = modifier) {
// items(
// AlphabetSearchUtils.LETTERS.length,
// key = { AlphabetSearchUtils.LETTERS[it] },
// ) { index ->
// Button(
// modifier =
// Modifier.size(24.dp),
// contentPadding = PaddingValues(2.dp),
// onClick = {
// letterClicked.invoke(AlphabetSearchUtils.LETTERS[index])
// },
// ) {
// Text(text = AlphabetSearchUtils.LETTERS[index].toString())
// }
// }
// }
}

View file

@ -0,0 +1,138 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
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.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.DolphinPager
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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
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 CollectionFolderViewModel
@Inject
constructor(
api: ApiClient,
) : ItemViewModel<Library>(api) {
val pager = MutableLiveData<DolphinPager?>()
override fun init(
itemId: UUID,
potential: BaseItemDto?,
): Job =
viewModelScope.launch {
super.init(itemId, potential)?.join()
setup()
}
private suspend fun setup() {
if (!pager.isInitialized) {
item.value?.let { item ->
val request =
GetItemsRequest(
parentId = item.id,
isSeries = true,
mediaTypes = null,
// recursive = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = listOf(BaseItemKind.SERIES),
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO),
)
val newPager = DolphinPager(api, request, viewModelScope)
newPager.init()
pager.value = newPager
}
}
}
}
@Composable
fun CollectionFolderDetails(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(),
) {
OneTimeLaunchedEffect {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val library by viewModel.model.observeAsState()
val pager by viewModel.pager.observeAsState()
if (library == null) {
Text("Loading library...")
} else {
pager?.let { pager ->
when (library!!.collectionType) {
CollectionType.UNKNOWN -> TODO()
CollectionType.MOVIES -> TODO()
CollectionType.TVSHOWS -> {
TVShowCollectionDetails(viewModel.api, preferences, navigationManager, library!!, item!!, pager, modifier)
}
CollectionType.MUSIC -> TODO()
CollectionType.MUSICVIDEOS -> TODO()
CollectionType.TRAILERS -> TODO()
CollectionType.HOMEVIDEOS -> TODO()
CollectionType.BOXSETS -> TODO()
CollectionType.BOOKS -> TODO()
CollectionType.PHOTOS -> TODO()
CollectionType.LIVETV -> TODO()
CollectionType.PLAYLISTS -> TODO()
CollectionType.FOLDERS -> TODO()
}
}
}
}
@Composable
fun TVShowCollectionDetails(
api: ApiClient,
preferences: UserPreferences,
navigationManager: NavigationManager,
library: Library,
item: BaseItemDto,
pager: DolphinPager,
modifier: Modifier = Modifier,
) {
val gridFocusRequester = remember { FocusRequester() }
CardGrid(
api = api,
pager = pager,
itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) },
longClicker = {},
letterPosition = { 0 },
requestFocus = true,
gridFocusRequester = gridFocusRequester,
navigationManager = navigationManager,
modifier = modifier,
initialPosition = 0,
positionCallback = { _, _ -> },
)
}

View file

@ -7,52 +7,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class EpisodeViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
val item = MutableLiveData<BaseItemDto?>(null)
fun init(
itemId: UUID,
potential: BaseItemDto?,
) {
if (item.value == null && potential?.id == itemId) {
item.value = potential
return
}
if (item.value?.id == itemId) {
return
}
viewModelScope.launch {
try {
item.value = api.userLibraryApi.getItem(itemId).content
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
}
}
}
}
api: ApiClient,
) : ItemViewModel<Video>(api)
@Composable
fun EpisodeDetails(

View file

@ -0,0 +1,45 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.convertModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.util.UUID
abstract class ItemViewModel<T : DolphinModel>(
val api: ApiClient,
) : ViewModel() {
val item = MutableLiveData<BaseItemDto?>(null)
val model = MutableLiveData<T?>(null)
open fun init(
itemId: UUID,
potential: BaseItemDto?,
): Job? {
if (item.value == null && potential?.id == itemId) {
item.value = potential
return null
}
if (item.value?.id == itemId) {
return null
}
return viewModelScope.launch {
try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api)
item.value = fetchedItem
model.value = modelInstance as T
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
}
}
}
}

View file

@ -21,6 +21,14 @@ fun MediaItemContent(
BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier)
BaseItemKind.MOVIE -> TODO()
BaseItemKind.VIDEO -> TODO()
BaseItemKind.COLLECTION_FOLDER -> {
CollectionFolderDetails(
preferences,
navigationManager,
destination,
modifier,
)
}
else -> {
Text("Unsupported item type: ${destination.type}")
}

View file

@ -32,11 +32,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import javax.inject.Inject
@ -98,7 +100,23 @@ class MainViewModel
// TODO
HomeSection.LIBRARY_TILES_SMALL -> null
HomeSection.RESUME -> null
HomeSection.RESUME -> {
val request =
GetResumeItemsRequest(
userId = user.id,
// TODO, more params?
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { convertModel(it, api) }
HomeRow(
section = section,
items = items,
)
}
HomeSection.ACTIVE_RECORDINGS -> null
HomeSection.NEXT_UP -> {
val request =

View file

@ -53,7 +53,6 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@ -71,7 +70,7 @@ class NavDrawerViewModel
api.userViewsApi
.getUserViews()
.content.items
Timber.v("userViews: ${userViews.map { it.type }}")
// Timber.v("userViews: $userViews")
libraries.value = userViews.map { Library.fromDto(it, api) }
}
}

View file

@ -2,7 +2,9 @@ package com.github.damontecres.dolphin.ui.playback
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
fun isDirectionalDpad(event: KeyEvent): Boolean =
event.key == Key.DirectionUp ||
@ -42,3 +44,8 @@ fun isForwardButton(event: KeyEvent): Boolean =
event.key == Key.MediaNext ||
event.key == Key.MediaFastForward ||
event.key == Key.MediaSkipForward
/**
* Whether the [KeyEvent] is a key up event pressing media play or media play/pause
*/
fun isPlayKeyUp(key: KeyEvent) = key.type == KeyEventType.KeyUp && (key.key == Key.MediaPlay || key.key == Key.MediaPlayPause)