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

@ -1,9 +1,16 @@
package com.github.damontecres.dolphin package com.github.damontecres.dolphin
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber import timber.log.Timber
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract import kotlin.contracts.contract
@ -85,3 +92,19 @@ fun Modifier.handleDPadKeyEvents(
false false
} }
/**
* Run a [LaunchedEffect] exactly once even with multiple recompositions.
*
* If the composition is removed from the navigation back stack and "re-added", this will run again
*/
@Composable
fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
var hasRun by rememberSaveable { mutableStateOf(false) }
if (!hasRun) {
LaunchedEffect(Unit) {
hasRun = true
runOnceBlock.invoke(this)
}
}
}

View file

@ -2,13 +2,14 @@ package com.github.damontecres.dolphin.ui.cards
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.Library
import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.data.model.Video
import org.jellyfin.sdk.model.api.BaseItemDto
@Composable @Composable
fun DolphinCard( fun DolphinCard(
item: DolphinModel?, item: Any?,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -21,6 +22,10 @@ fun DolphinCard(
) )
is Library -> TODO() 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 package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.runtime.Composable
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Card
@ -24,14 +28,20 @@ fun VideoCard(
onClick = onClick, onClick = onClick,
) { ) {
Column( Column(
modifier = Modifier.size(cardWidth, cardHeight), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
) { ) {
Text(
text = item.name ?: "",
)
AsyncImage( AsyncImage(
model = item.imageUrl, model = item.imageUrl,
contentDescription = item.name, 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.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EpisodeViewModel class EpisodeViewModel
@Inject @Inject
constructor( constructor(
val api: ApiClient, api: ApiClient,
) : ViewModel() { ) : ItemViewModel<Video>(api)
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
}
}
}
}
@Composable @Composable
fun EpisodeDetails( 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.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier)
BaseItemKind.MOVIE -> TODO() BaseItemKind.MOVIE -> TODO()
BaseItemKind.VIDEO -> TODO() BaseItemKind.VIDEO -> TODO()
BaseItemKind.COLLECTION_FOLDER -> {
CollectionFolderDetails(
preferences,
navigationManager,
destination,
modifier,
)
}
else -> { else -> {
Text("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}")
} }

View file

@ -32,11 +32,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi 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.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -98,7 +100,23 @@ class MainViewModel
// TODO // TODO
HomeSection.LIBRARY_TILES_SMALL -> null 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.ACTIVE_RECORDINGS -> null
HomeSection.NEXT_UP -> { HomeSection.NEXT_UP -> {
val request = 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -71,7 +70,7 @@ class NavDrawerViewModel
api.userViewsApi api.userViewsApi
.getUserViews() .getUserViews()
.content.items .content.items
Timber.v("userViews: ${userViews.map { it.type }}") // Timber.v("userViews: $userViews")
libraries.value = userViews.map { Library.fromDto(it, api) } 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.Key
import androidx.compose.ui.input.key.KeyEvent 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.key
import androidx.compose.ui.input.key.type
fun isDirectionalDpad(event: KeyEvent): Boolean = fun isDirectionalDpad(event: KeyEvent): Boolean =
event.key == Key.DirectionUp || event.key == Key.DirectionUp ||
@ -42,3 +44,8 @@ fun isForwardButton(event: KeyEvent): Boolean =
event.key == Key.MediaNext || event.key == Key.MediaNext ||
event.key == Key.MediaFastForward || event.key == Key.MediaFastForward ||
event.key == Key.MediaSkipForward 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)

View file

@ -0,0 +1,128 @@
package com.github.damontecres.dolphin.util
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
class DolphinPager(
val api: ApiClient,
val request: GetItemsRequest,
private val scope: CoroutineScope,
private val pageSize: Int = 25,
cacheSize: Long = 8,
) : AbstractList<BaseItemDto?>() {
private var items by mutableStateOf(ItemList<BaseItemDto>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, List<BaseItemDto>>()
suspend fun init() {
val result =
api.itemsApi
.getItems(
request.copy(
startIndex = 0,
limit = 1,
enableTotalRecordCount = true,
),
).content
totalCount = result.totalRecordCount
}
override operator fun get(index: Int): BaseItemDto? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
suspend fun getBlocking(position: Int): BaseItemDto? {
if (position in 0..<totalCount) {
val item = items[position]
if (item == null) {
fetchPage(position).join()
return items[position]
}
return item
} else {
throw IndexOutOfBoundsException("$position of $totalCount")
}
}
override val size: Int
get() = totalCount
private fun fetchPage(position: Int): Job =
scope.launch(Dispatchers.IO) {
mutex.withLock {
val pageNumber = position / pageSize
if (cachedPages.getIfPresent(pageNumber) == null) {
if (DEBUG) Timber.v("fetchPage: $pageNumber")
val result =
api.itemsApi
.getItems(
request.copy(
startIndex = pageNumber * pageSize,
limit = pageSize,
enableTotalRecordCount = false,
),
).content
val data = result.items
cachedPages.put(pageNumber, data)
items = ItemList(totalCount, pageSize, cachedPages.asMap())
}
}
}
companion object {
private const val TAG = "DolphinPager"
private const val DEBUG = true
}
class ItemList<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
operator fun get(position: Int): T? {
val page = position / pageSize
val data = pages[page]
if (data != null) {
val index = position % pageSize
if (index in data.indices) {
return data[index]
} else {
// This can happen when items are removed while scrolling
Timber.w(
"Index $index not in data: position=$position, data.size=${data.size}",
)
return null
}
} else {
return null
}
}
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="fa_tag" translatable="false">&#xf02b;</string>
<string name="fa_user" translatable="false">&#xf007;</string>
<string name="fa_location_dot" translatable="false">&#xf3c5;</string>
<string name="fa_film" translatable="false">&#xf008;</string>
<string name="fa_circle_play" translatable="false">&#xf144;</string>
<string name="fa_image" translatable="false">&#xf03e;</string>
<string name="fa_images" translatable="false">&#xf302;</string>
<string name="fa_rotate_right" translatable="false">&#xf2f9;</string>
<string name="fa_rotate_left" translatable="false">&#xf2ea;</string>
<string name="fa_arrow_right_arrow_left" translatable="false">&#xf0ec;</string>
<string name="fa_magnifying_glass_plus" translatable="false">&#xf00e;</string>
<string name="fa_magnifying_glass_minus" translatable="false">&#xf010;</string>
<string name="fa_caret_up" translatable="false">&#xf0d8;</string>
<string name="fa_caret_down" translatable="false">&#xf0d7;</string>
<string name="fa_heart" translatable="false">&#xf004;</string>
<string name="fa_video" translatable="false">&#xf03d;</string>
<string name="fa_play" translatable="false">&#xf04b;</string>
<string name="fa_pause" translatable="false">&#xf04c;</string>
<string name="fa_arrow_down_long" translatable="false">&#xf175;</string>
<string name="fa_arrow_up_long" translatable="false">&#xf176;</string>
<string name="fa_angle_up" translatable="false">&#xf106;</string>
<string name="fa_angles_up" translatable="false">&#xf102;</string>
<string name="fa_angle_down" translatable="false">&#xf107;</string>
<string name="fa_angles_down" translatable="false">&#xf103;</string>
<string name="fa_house" translatable="false">&#xf015;</string>
<string name="fa_closed_captioning" translatable="false">&#xf20a;</string>
</resources>