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
|
||||
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import timber.log.Timber
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
|
@ -85,3 +92,19 @@ fun Modifier.handleDPadKeyEvents(
|
|||
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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 ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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(
|
||||
|
|
|
|||
|
|
@ -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.MOVIE -> TODO()
|
||||
BaseItemKind.VIDEO -> TODO()
|
||||
BaseItemKind.COLLECTION_FOLDER -> {
|
||||
CollectionFolderDetails(
|
||||
preferences,
|
||||
navigationManager,
|
||||
destination,
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text("Unsupported item type: ${destination.type}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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