mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add grid
This commit is contained in:
parent
611da0d60e
commit
6e185b25b7
15 changed files with 955 additions and 44 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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...",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
@ -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 = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/src/main/res/values/fa_strings.xml
Normal file
29
app/src/main/res/values/fa_strings.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="fa_tag" translatable="false"></string>
|
||||||
|
<string name="fa_user" translatable="false"></string>
|
||||||
|
<string name="fa_location_dot" translatable="false"></string>
|
||||||
|
<string name="fa_film" translatable="false"></string>
|
||||||
|
<string name="fa_circle_play" translatable="false"></string>
|
||||||
|
<string name="fa_image" translatable="false"></string>
|
||||||
|
<string name="fa_images" translatable="false"></string>
|
||||||
|
<string name="fa_rotate_right" translatable="false"></string>
|
||||||
|
<string name="fa_rotate_left" translatable="false"></string>
|
||||||
|
<string name="fa_arrow_right_arrow_left" translatable="false"></string>
|
||||||
|
<string name="fa_magnifying_glass_plus" translatable="false"></string>
|
||||||
|
<string name="fa_magnifying_glass_minus" translatable="false"></string>
|
||||||
|
<string name="fa_caret_up" translatable="false"></string>
|
||||||
|
<string name="fa_caret_down" translatable="false"></string>
|
||||||
|
<string name="fa_heart" translatable="false"></string>
|
||||||
|
<string name="fa_video" translatable="false"></string>
|
||||||
|
<string name="fa_play" translatable="false"></string>
|
||||||
|
<string name="fa_pause" translatable="false"></string>
|
||||||
|
<string name="fa_arrow_down_long" translatable="false"></string>
|
||||||
|
<string name="fa_arrow_up_long" translatable="false"></string>
|
||||||
|
<string name="fa_angle_up" translatable="false"></string>
|
||||||
|
<string name="fa_angles_up" translatable="false"></string>
|
||||||
|
<string name="fa_angle_down" translatable="false"></string>
|
||||||
|
<string name="fa_angles_down" translatable="false"></string>
|
||||||
|
<string name="fa_house" translatable="false"></string>
|
||||||
|
<string name="fa_closed_captioning" translatable="false"></string>
|
||||||
|
</resources>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue