Browse Source

Compose TV: implement the browse screen using the mobile viewmodels

merge-requests/2332/head
Nicolas Pomepuy 9 months ago
parent
commit
96530e4b19
  1. 6
      application/television/src/main/java/org/videolan/television/ui/compose/composable/components/ContentLine.kt
  2. 97
      application/television/src/main/java/org/videolan/television/ui/compose/composable/items/AudioItem.kt
  3. 55
      application/television/src/main/java/org/videolan/television/ui/compose/composable/lists/BrowseList.kt
  4. 75
      application/television/src/main/java/org/videolan/television/ui/compose/utils/ComposeUtils.kt
  5. 15
      application/vlc-android/src/org/videolan/vlc/viewmodels/browser/BrowserFavoritesModel.kt
  6. 38
      application/vlc-android/src/org/videolan/vlc/viewmodels/browser/BrowserModel.kt
  7. 24
      application/vlc-android/src/org/videolan/vlc/viewmodels/browser/NetworkModel.kt

6
application/television/src/main/java/org/videolan/television/ui/compose/composable/components/ContentLine.kt

@ -59,7 +59,7 @@ import org.videolan.television.ui.compose.theme.WhiteTransparent10
import org.videolan.vlc.R
@Composable
fun ContentLine(items: List<MediaLibraryItem>?, historyLoading: Boolean?, text: Int, titleFocusable: Boolean = true) {
fun ContentLine(items: List<MediaLibraryItem>?, historyLoading: Boolean?, text: Int, titleFocusable: Boolean = true, spannableDescription: Boolean = false) {
var focused by remember { mutableStateOf(false) }
Row(
horizontalArrangement = Arrangement.Center,
@ -80,7 +80,7 @@ fun ContentLine(items: List<MediaLibraryItem>?, historyLoading: Boolean?, text:
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.weight(1F)
.padding(horizontal = 16.dp)
.padding(horizontal = if (titleFocusable) 16.dp else 0.dp)
)
if (titleFocusable)
Icon(
@ -101,7 +101,7 @@ fun ContentLine(items: List<MediaLibraryItem>?, historyLoading: Boolean?, text:
) {
items(items?.size ?: 0) { index ->
Box(modifier = Modifier.width(150.dp)) {
AudioItem(items!!, index)
AudioItem(items!!, index, spannableDescription = spannableDescription)
}
}
}

97
application/television/src/main/java/org/videolan/television/ui/compose/composable/items/AudioItem.kt

@ -76,18 +76,21 @@ import org.videolan.television.ui.compose.composable.lists.vlcBorder
import org.videolan.television.ui.compose.theme.Transparent
import org.videolan.television.ui.compose.theme.WhiteTransparent05
import org.videolan.television.ui.compose.theme.WhiteTransparent10
import org.videolan.television.ui.compose.utils.getDescriptionAnnotated
import org.videolan.television.ui.compose.utils.inlineContentMap
import org.videolan.vlc.gui.helpers.UiTools
import org.videolan.vlc.gui.helpers.getTvIconRes
import org.videolan.vlc.gui.helpers.hf.StoragePermissionsDelegate.Companion.askStoragePermission
import org.videolan.vlc.util.ThumbnailsProvider
import org.videolan.vlc.util.getDescriptionSpan
@Composable
fun AudioItem(audios: List<MediaLibraryItem>, index: Int, inCard: Boolean = true) {
if (inCard) AudioItemCard(audios[index]) else AudioItemList(audios[index])
fun AudioItem(audios: List<MediaLibraryItem>, index: Int, inCard: Boolean = true, spannableDescription: Boolean = false) {
if (inCard) AudioItemCard(audios[index], spannableDescription = spannableDescription) else AudioItemList(audios[index], spannableDescription = spannableDescription)
}
@Composable
fun AudioItemCard(item: MediaLibraryItem, modifier: Modifier = Modifier) {
fun AudioItemCard(item: MediaLibraryItem, modifier: Modifier = Modifier, spannableDescription: Boolean = false) {
val mapBitmap: MutableState<Bitmap?> = remember { mutableStateOf(null) }
val coroutineScope = rememberCoroutineScope()
val activity = LocalActivity.current
@ -176,21 +179,35 @@ fun AudioItemCard(item: MediaLibraryItem, modifier: Modifier = Modifier) {
.padding(start = 4.dp, end = 4.dp, top = 4.dp)
.fillMaxWidth()
)
Text(
item.description ?: "",
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = 4.dp, end = 4.dp)
.fillMaxWidth()
)
if (spannableDescription)
Text(
text = item.description.getDescriptionAnnotated(),
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = 4.dp, end = 4.dp)
.fillMaxWidth(),
inlineContent = inlineContentMap
)
else
Text(
text = item.description ?: "",
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = 4.dp, end = 4.dp)
.fillMaxWidth(),
)
}
if (item.isFavorite || (item as? MediaWrapper)?.hasFlag(FAVORITE_FLAG) == true) {
Icon(
painterResource(R.drawable.ic_favorite),
contentDescription = stringResource(R.string.favorite),
modifier = Modifier.padding(8.dp).size(16.dp)
modifier = Modifier
.padding(8.dp)
.size(16.dp)
)
}
}
@ -198,7 +215,7 @@ fun AudioItemCard(item: MediaLibraryItem, modifier: Modifier = Modifier) {
}
@Composable
fun AudioItemList(item: MediaLibraryItem, modifier: Modifier = Modifier) {
fun AudioItemList(item: MediaLibraryItem, modifier: Modifier = Modifier, spannableDescription: Boolean = false) {
val mapBitmap: MutableState<Bitmap?> = remember { mutableStateOf(null) }
val coroutineScope = rememberCoroutineScope()
val activity = LocalActivity.current
@ -207,33 +224,33 @@ fun AudioItemList(item: MediaLibraryItem, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.onFocusChanged {
focused.value = it.isFocused
}
.fillMaxWidth()
.height(52.dp)
.onFocusChanged {
focused.value = it.isFocused
}
// .border(vlcBorder(focused.value))
// .padding(vertical = if (focused.value) 8.dp else 0.dp)
.background(color = if (focused.value) WhiteTransparent10 else WhiteTransparent05, shape = MaterialTheme.shapes.medium)
.combinedClickable(
onClick = {
when (item) {
is Artist -> TvUtil.openAudioCategory(activity!!, item)
is Album -> TvUtil.openAudioCategory(activity!!, item)
is Genre -> TvUtil.openAudioCategory(activity!!, item)
is Playlist -> TvUtil.openAudioCategory(activity!!, item)
else -> TvUtil.openMedia(activity as FragmentActivity, item)
}
},
onLongClick = {
if (item is MediaWrapper)
TvUtil.showMediaDetail(activity!!, item, false)
else
(activity as? FragmentActivity)?.askStoragePermission(false, null)
},
indication = null,
interactionSource = null
)
.combinedClickable(
onClick = {
when (item) {
is Artist -> TvUtil.openAudioCategory(activity!!, item)
is Album -> TvUtil.openAudioCategory(activity!!, item)
is Genre -> TvUtil.openAudioCategory(activity!!, item)
is Playlist -> TvUtil.openAudioCategory(activity!!, item)
else -> TvUtil.openMedia(activity as FragmentActivity, item)
}
},
onLongClick = {
if (item is MediaWrapper)
TvUtil.showMediaDetail(activity!!, item, false)
else
(activity as? FragmentActivity)?.askStoragePermission(false, null)
},
indication = null,
interactionSource = null
)
) {
Card(
modifier = Modifier
@ -304,7 +321,9 @@ fun AudioItemList(item: MediaLibraryItem, modifier: Modifier = Modifier) {
Icon(
painterResource(R.drawable.ic_favorite),
contentDescription = stringResource(R.string.favorite),
modifier = Modifier.padding(8.dp).size(16.dp)
modifier = Modifier
.padding(8.dp)
.size(16.dp)
)
}
}

55
application/television/src/main/java/org/videolan/television/ui/compose/composable/lists/BrowseList.kt

@ -24,6 +24,7 @@
package org.videolan.television.ui.compose.composable.lists
import android.app.Application
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
@ -33,17 +34,54 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.MutableCreationExtras
import androidx.lifecycle.viewmodel.compose.viewModel
import org.videolan.television.R
import org.videolan.television.ui.compose.composable.components.ContentLine
import org.videolan.television.viewmodel.BrowserViewModel
import org.videolan.vlc.viewmodels.browser.BrowserFavoritesModel
import org.videolan.vlc.viewmodels.browser.BrowserModel
import org.videolan.vlc.viewmodels.browser.NetworkModel
import org.videolan.vlc.viewmodels.browser.TYPE_STORAGE
import org.videolan.vlc.viewmodels.mobile.VideosViewModel
@Composable
fun BrowseList(onFocusExit: () -> Unit, onFocusEnter: () -> Unit, viewModel: BrowserViewModel = viewModel()) {
val favorites by viewModel.favoritesList.observeAsState()
val browsers by viewModel.browsers.observeAsState()
val favoritesLoading by viewModel.favoritesLoading.observeAsState()
val browsersLoading by viewModel.browsersLoading.observeAsState()
fun BrowseList(onFocusExit: () -> Unit, onFocusEnter: () -> Unit) {
val context = LocalContext.current
val extras = MutableCreationExtras().apply {
set(APPLICATION_KEY, context.applicationContext as Application)
}
val favoritesModel: BrowserFavoritesModel = viewModel(
factory = BrowserFavoritesModel.Factory,
extras = extras
)
val browserExtras = MutableCreationExtras().apply {
set(APPLICATION_KEY, context.applicationContext as Application)
set(BrowserModel.TYPE_KEY, TYPE_STORAGE)
set(BrowserModel.URL_KEY, null)
set(BrowserModel.SHOW_DUMMY_KEY, false)
}
val browserModel: BrowserModel = viewModel(
factory = BrowserModel.Factory,
extras = browserExtras
)
val networkExtras = MutableCreationExtras().apply {
set(APPLICATION_KEY, context.applicationContext as Application)
set(NetworkModel.URL_KEY, null)
set(NetworkModel.MOCKED_KEY, null)
}
val networkModel: NetworkModel = viewModel(
factory = NetworkModel.Factory,
extras = networkExtras
)
val favorites by favoritesModel.favorites.observeAsState()
val storages by browserModel.dataset.observeAsState()
val networks by networkModel.dataset.observeAsState()
Column(
modifier = Modifier
.focusProperties {
@ -58,7 +96,10 @@ fun BrowseList(onFocusExit: () -> Unit, onFocusEnter: () -> Unit, viewModel: Bro
.focusGroup()
) {
if (!favorites.isNullOrEmpty())
ContentLine(favorites, favoritesLoading, R.string.favorites, titleFocusable = false)
ContentLine(browsers, browsersLoading, R.string.browsing, titleFocusable = false)
ContentLine(favorites, false, R.string.favorites, titleFocusable = false, spannableDescription = true)
if (!storages.isNullOrEmpty())
ContentLine(storages, false, R.string.browser_storages, titleFocusable = false, spannableDescription = true)
if (!networks.isNullOrEmpty())
ContentLine(networks, false, R.string.network_browsing, titleFocusable = false, spannableDescription = true)
}
}

75
application/television/src/main/java/org/videolan/television/ui/compose/utils/ComposeUtils.kt

@ -0,0 +1,75 @@
/*
* ************************************************************************
* ComposeUtils.kt
* *************************************************************************
* Copyright © 2025 VLC authors and VideoLAN
* Author: Nicolas POMEPUY
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
* **************************************************************************
*
*
*/
package org.videolan.television.ui.compose.utils
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
import org.videolan.vlc.R
import org.videolan.vlc.util.fileReplacementMarker
import org.videolan.vlc.util.folderReplacementMarker
fun CharSequence?.getDescriptionAnnotated(): AnnotatedString {
return buildAnnotatedString {
this@getDescriptionAnnotated?.split(folderReplacementMarker)?.forEachIndexed { index, splitByFolder ->
if (index > 0) appendInlineContent(id = folderReplacementMarker)
splitByFolder.split(fileReplacementMarker).forEachIndexed { index, it ->
if (index > 0) appendInlineContent(id = fileReplacementMarker)
append(it)
}
}
}
}
val inlineContentMap = mapOf(
folderReplacementMarker to InlineTextContent(
Placeholder(12.sp, 12.sp, PlaceholderVerticalAlign.TextTop)
) {
Image(
painter = painterResource(id = R.drawable.ic_emoji_folder),
modifier = Modifier.fillMaxSize(),
contentDescription = ""
)
},
fileReplacementMarker to InlineTextContent(
Placeholder(12.sp, 12.sp, PlaceholderVerticalAlign.TextTop)
) {
Image(
painter = painterResource(id = R.drawable.ic_emoji_file),
modifier = Modifier.fillMaxSize(),
contentDescription = ""
)
}
)

15
application/vlc-android/src/org/videolan/vlc/viewmodels/browser/BrowserFavoritesModel.kt

@ -27,7 +27,11 @@ package org.videolan.vlc.viewmodels.browser
import android.content.Context
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
@ -46,6 +50,17 @@ import org.videolan.vlc.util.convertFavorites
class BrowserFavoritesModel(context: Context) : ViewModel() {
val favorites = LiveDataset<MediaLibraryItem>()
val provider = FavoritesProvider(context.applicationContext, favorites, viewModelScope)
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
// Get the dependency in your factory
val application = checkNotNull(this[APPLICATION_KEY])
BrowserFavoritesModel(application)
}
}
}
}
class FavoritesProvider(

38
application/vlc-android/src/org/videolan/vlc/viewmodels/browser/BrowserModel.kt

@ -26,15 +26,25 @@ import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.videolan.medialibrary.Tools
import org.videolan.medialibrary.interfaces.Medialibrary
import org.videolan.medialibrary.interfaces.media.Folder
import org.videolan.medialibrary.interfaces.media.MediaWrapper
import org.videolan.medialibrary.interfaces.media.VideoGroup
import org.videolan.medialibrary.media.MediaLibraryItem
import org.videolan.resources.GROUP_VIDEOS_FOLDER
import org.videolan.resources.GROUP_VIDEOS_NAME
import org.videolan.resources.GROUP_VIDEOS_NONE
import org.videolan.tools.CoroutineContextProvider
import org.videolan.tools.KEY_GROUP_VIDEOS
import org.videolan.tools.Settings
import org.videolan.tools.putSingle
import org.videolan.vlc.gui.helpers.MedialibraryUtils
@ -46,6 +56,10 @@ import org.videolan.vlc.providers.PickerType
import org.videolan.vlc.providers.StorageProvider
import org.videolan.vlc.repository.DirectoryRepository
import org.videolan.vlc.viewmodels.BaseModel
import org.videolan.vlc.viewmodels.mobile.VideoGroupingType
import org.videolan.vlc.viewmodels.mobile.VideosViewModel
import org.videolan.vlc.viewmodels.mobile.VideosViewModel.Companion.PARENT_FOLDER_KEY
import org.videolan.vlc.viewmodels.mobile.VideosViewModel.Companion.PARENT_GROUP_KEY
import org.videolan.vlc.viewmodels.tv.TvBrowserModel
const val TYPE_FILE = 0L
@ -177,6 +191,30 @@ open class BrowserModel(
putBoolean("${sortKey}_desc", desc)
}
}
companion object {
// Define a custom key for your dependency
val URL_KEY = object : CreationExtras.Key<String?> {}
val TYPE_KEY = object : CreationExtras.Key<Long> {}
val SHOW_DUMMY_KEY = object : CreationExtras.Key<Boolean> {}
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
// Get the dependency in your factory
val application = checkNotNull(this[APPLICATION_KEY])
val url = this[URL_KEY]
val type = checkNotNull(this[TYPE_KEY])
val showDummy = checkNotNull(this[SHOW_DUMMY_KEY])
BrowserModel(
application,
url,
type,
showDummy
)
}
}
}
}
fun Fragment.getBrowserModel(category: Long, url: String?, showDummyCategory: Boolean = false, mocked: ArrayList<MediaLibraryItem>? = null) = if (category == TYPE_NETWORK)

24
application/vlc-android/src/org/videolan/vlc/viewmodels/browser/NetworkModel.kt

@ -23,7 +23,11 @@ package org.videolan.vlc.viewmodels.browser
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.videolan.medialibrary.media.MediaLibraryItem
@ -45,4 +49,24 @@ class NetworkModel(context: Context, url: String? = null, mocked: ArrayList<Medi
return NetworkModel(context.applicationContext, url, mocked) as T
}
}
companion object {
// Define a custom key for your dependency
val URL_KEY = object : CreationExtras.Key<String?> {}
val MOCKED_KEY = object : CreationExtras.Key<ArrayList<MediaLibraryItem>?> {}
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
// Get the dependency in your factory
val application = checkNotNull(this[APPLICATION_KEY])
val url = this[URL_KEY]
val mocked = this[MOCKED_KEY]
NetworkModel(
application,
url,
mocked,
)
}
}
}
}
Loading…
Cancel
Save