Migrate downloads UI to Compose (#1948)

This commit is contained in:
Max Rumpf authored and GitHub committed 2026-04-20 22:16:19 +02:00
1 parent 9fa2a96d31
commit 146db28af5
13 files changed
+244 -204

No files matched your search

@@ -1,14 +0,0 @@
package org.jellyfin.mobile.downloads
import androidx.recyclerview.widget.DiffUtil
import org.jellyfin.mobile.data.entity.DownloadEntity
class DownloadDiffCallback : DiffUtil.ItemCallback<DownloadEntity>() {
override fun areItemsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
return oldItem.itemId == newItem.itemId
}
override fun areContentsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
return oldItem == newItem
}
}
@@ -48,16 +48,14 @@ object DownloadServiceUtil : KoinComponent {
@Synchronized
private fun ensureDownloadManagerInitialized(context: Context) {
if (downloadManager == null) {
downloadManager =
DownloadManager(
context,
databaseProvider,
downloadCache,
dataSourceFactory,
Executors.newFixedThreadPool(DOWNLOAD_THREADS),
)
downloadTracker =
DownloadTracker(downloadManager!!)
downloadManager = DownloadManager(
context,
databaseProvider,
downloadCache,
dataSourceFactory,
Executors.newFixedThreadPool(DOWNLOAD_THREADS),
)
downloadTracker = DownloadTracker(downloadManager!!)
}
}
}
@@ -16,10 +16,9 @@ class DownloadTracker(downloadManager: DownloadManager) {
private val listeners: CopyOnWriteArraySet<Listener> = CopyOnWriteArraySet()
private val downloads: HashMap<Uri, Download> = HashMap()
private val downloadIndex: DownloadIndex
private val downloadIndex: DownloadIndex = downloadManager.downloadIndex
init {
downloadIndex = downloadManager.downloadIndex
downloadManager.addListener(DownloadManagerListener())
loadDownloads()
}
@@ -151,9 +151,10 @@ class DownloadUtils(
BaseItemKind.EPISODE,
BaseItemKind.MOVIE,
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
)
private suspend fun downloadFiles(jellyfinMediaSource: JellyfinMediaSource) {
private fun downloadFiles(jellyfinMediaSource: JellyfinMediaSource) {
val jellyfinDownloadTracker = JellyfinDownloadTracker(jellyfinMediaSource)
downloadTracker.addListener(jellyfinDownloadTracker)
downloadMediaFile(jellyfinMediaSource)
@@ -1,81 +0,0 @@
package org.jellyfin.mobile.downloads
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil3.load
import coil3.request.error
import coil3.request.fallback
import org.jellyfin.mobile.R
import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.databinding.DownloadItemBinding
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 java.util.UUID
class DownloadsAdapter(
private val apiClient: ApiClient,
private val onItemClick: (DownloadEntity) -> Unit,
private val onItemHold: (DownloadEntity) -> Unit,
) : ListAdapter<DownloadEntity, DownloadsAdapter.DownloadViewHolder>(
DownloadDiffCallback(),
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
val binding = DownloadItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return DownloadViewHolder(binding)
}
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class DownloadViewHolder(private val binding: DownloadItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(downloadEntity: DownloadEntity) {
val context = itemView.context
val mediaItem: BaseItemDto? = downloadEntity.mediaSource.item
binding.textViewName.text = downloadEntity.mediaSource.getName(context)
binding.textViewDescription.text = when {
mediaItem?.seriesName != null -> context.getString(
R.string.tv_show_desc,
mediaItem.seriesName,
mediaItem.parentIndexNumber,
mediaItem.indexNumber,
)
mediaItem?.productionYear != null -> mediaItem.productionYear.toString()
else -> downloadEntity.mediaSource.id
}
binding.textViewFileSize.text = downloadEntity.fileSize
itemView.setOnClickListener {
onItemClick(downloadEntity)
}
itemView.setOnLongClickListener {
onItemHold(downloadEntity)
true
}
val thumbnailUrl = getThumbnailUrl(context, downloadEntity.mediaSource.itemId)
binding.imageViewThumbnail.load(thumbnailUrl) {
fallback(R.drawable.ic_local_movies_white_64)
error(R.drawable.ic_local_movies_white_64)
}
}
}
fun getThumbnailUrl(context: Context, mediaSourceItemId: UUID): String? {
val size = context.resources.getDimensionPixelSize(R.dimen.movie_thumbnail_list_size)
return apiClient.imageApi.getItemImageUrl(
itemId = mediaSourceItemId,
imageType = ImageType.PRIMARY,
maxWidth = size,
maxHeight = size,
)
}
}
@@ -1,76 +1,16 @@
package org.jellyfin.mobile.downloads
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.databinding.FragmentDownloadsBinding
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
import org.jellyfin.mobile.utils.extensions.requireMainActivity
import org.jellyfin.mobile.utils.withThemedContext
import org.jellyfin.sdk.api.client.ApiClient
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import androidx.compose.runtime.Composable
import org.jellyfin.mobile.ui.ComposeFragment
import org.jellyfin.mobile.ui.screens.downloads.DownloadsScreen
class DownloadsFragment : Fragment(), KoinComponent {
private val viewModel: DownloadsViewModel by inject()
private val activityEventHandler: ActivityEventHandler by inject()
private val apiClient: ApiClient by inject()
private lateinit var adapter: DownloadsAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val localInflater = inflater.withThemedContext(requireContext(), R.style.AppTheme_Settings)
val binding = FragmentDownloadsBinding.inflate(localInflater, container, false)
binding.root.applyWindowInsetsAsMargins()
binding.toolbar.setTitle(R.string.downloads)
requireMainActivity().apply {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
adapter = DownloadsAdapter(
apiClient,
onItemClick = { download -> onDownloadItemClick(download) },
onItemHold = { download -> onDownloadItemHold(download) },
class DownloadsFragment : ComposeFragment() {
@Composable
override fun Content() {
DownloadsScreen(
onBackPressed = {
isAdded && !parentFragmentManager.isStateSaved && parentFragmentManager.popBackStackImmediate()
},
)
binding.recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.downloads.collect { downloads ->
adapter.submitList(downloads)
}
}
}
return binding.root
}
private fun onDownloadItemClick(download: DownloadEntity) {
val playOptions = PlayOptions(
ids = listOf(download.mediaSource.itemId),
mediaSourceId = download.mediaSource.id,
startIndex = 0,
startPosition = null,
audioStreamIndex = 1,
subtitleStreamIndex = -1,
playFromDownloads = true,
)
activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions))
}
private fun onDownloadItemHold(download: DownloadEntity) {
activityEventHandler.emit(ActivityEvent.RemoveDownload(download.mediaSource))
}
}
@@ -3,35 +3,42 @@ package org.jellyfin.mobile.downloads
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.stateIn
import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class DownloadsViewModel : ViewModel(), KoinComponent {
private val downloadDao: DownloadDao by inject()
private val activityEventHandler: ActivityEventHandler by inject()
// This is a mutable state flow that will be used internally in the viewmodel, empty list is given as initial value.
private val _downloads = MutableStateFlow(emptyList<DownloadEntity>())
val downloads: StateFlow<List<DownloadEntity>> = downloadDao
.getAllDownloads()
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
// Immutable state flow that you expose to your UI
val downloads = _downloads.asStateFlow()
init {
getAllDownloads()
fun playDownload(download: DownloadEntity) {
val playOptions = PlayOptions(
ids = listOf(download.mediaSource.itemId),
mediaSourceId = download.mediaSource.id,
startIndex = 0,
startPosition = null,
audioStreamIndex = 1,
subtitleStreamIndex = -1,
playFromDownloads = true,
)
activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions))
}
private fun getAllDownloads() {
viewModelScope.launch { // this: CoroutineScope
downloadDao.getAllDownloads().flowOn(Dispatchers.IO).collect { downloads: List<DownloadEntity> ->
_downloads.update { downloads }
}
}
fun removeDownload(download: DownloadEntity, force: Boolean = false) {
activityEventHandler.emit(ActivityEvent.RemoveDownload(download.mediaSource, force))
}
}
@@ -35,7 +35,7 @@ class JellyfinDownloadService : DownloadService(
return downloadManager
}
override fun getScheduler(): Scheduler? {
override fun getScheduler(): Scheduler {
return PlatformScheduler(this, jobId)
}
@@ -164,14 +164,16 @@ sealed class JellyfinMediaSource(
val name = if (
it.type in arrayOf(BaseItemKind.PROGRAM, BaseItemKind.RECORDING) &&
(it.isSeries == true || !it.episodeTitle.isNullOrEmpty())
) { it.episodeTitle } else { it.name }
) {
it.episodeTitle
} else {
it.name
}
val specialEpisode = context.getString(R.string.special_episode)
val extraInfo = when {
it.type == BaseItemKind.TV_CHANNEL && !it.channelNumber.isNullOrEmpty() -> it.channelNumber
it.type == BaseItemKind.EPISODE && it.parentIndexNumber == 0 -> specialEpisode
it.type in arrayOf(BaseItemKind.EPISODE, BaseItemKind.RECORDING) &&
it.indexNumber != null && it.parentIndexNumber != null ->
val extraInfo = when (it.type) {
BaseItemKind.TV_CHANNEL if !it.channelNumber.isNullOrEmpty() -> it.channelNumber
BaseItemKind.EPISODE if it.parentIndexNumber == 0 -> context.getString(R.string.special_episode)
in arrayOf(BaseItemKind.EPISODE, BaseItemKind.RECORDING) if it.indexNumber != null && it.parentIndexNumber != null ->
"S${it.parentIndexNumber}:E${it.indexNumber}${it.indexNumberEnd?.let { n -> "-$n" } ?: ""}"
else -> ""
}
@@ -0,0 +1,32 @@
package org.jellyfin.mobile.ui
import android.os.Bundle
import android.view.View
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import org.jellyfin.mobile.R
import org.jellyfin.mobile.ui.utils.AppTheme
abstract class ComposeFragment : Fragment(R.layout.fragment_compose) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val composeView: ComposeView = view.findViewById(R.id.compose_view)
composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
composeView.setContent {
AppTheme {
Surface(color = MaterialTheme.colors.background) {
Content()
}
}
}
}
@Composable
abstract fun Content()
}
@@ -0,0 +1,101 @@
package org.jellyfin.mobile.ui.screens.downloads
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import org.jellyfin.mobile.R
import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.downloads.DownloadsViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject
@Composable
fun DownloadsList(
viewModel: DownloadsViewModel = viewModel(),
contentPadding: PaddingValues = PaddingValues.Zero,
) {
val downloads by viewModel.downloads.collectAsState()
LazyColumn(
contentPadding = contentPadding,
) {
items(
downloads,
key = DownloadEntity::itemId,
) { download ->
DownloadItem(
download,
modifier = Modifier.combinedClickable(
onClick = { viewModel.playDownload(download) },
onLongClick = { viewModel.removeDownload(download) },
),
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun DownloadItem(
download: DownloadEntity,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val apiClient: ApiClient = koinInject()
ListItem(
modifier = modifier,
text = {
val name = remember(download.mediaSource.itemId) {
download.mediaSource.getName(context)
}
Text(
text = name,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
icon = {
val maxSize = LocalResources.current.getDimensionPixelSize(R.dimen.movie_thumbnail_list_size)
val url = remember(download.mediaSource.itemId) {
apiClient.imageApi.getItemImageUrl(
itemId = download.mediaSource.itemId,
imageType = ImageType.PRIMARY,
maxWidth = maxSize,
maxHeight = maxSize,
)
}
AsyncImage(
model = url,
placeholder = painterResource(R.drawable.ic_local_movies_white_64),
fallback = painterResource(R.drawable.ic_local_movies_white_64),
contentDescription = null,
)
},
secondaryText = {
Text(
text = download.fileSize,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
singleLineSecondaryText = true,
)
}
@@ -0,0 +1,54 @@
package org.jellyfin.mobile.ui.screens.downloads
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import org.jellyfin.mobile.R
import org.jellyfin.mobile.downloads.DownloadsViewModel
@Composable
fun DownloadsScreen(
viewModel: DownloadsViewModel = viewModel(),
onBackPressed: () -> Unit = {},
) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
topBar = {
TopAppBar(
title = {
Text(text = stringResource(R.string.downloads))
},
navigationIcon = {
IconButton(
onClick = onBackPressed,
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = null,
)
}
},
backgroundColor = Color.Transparent,
)
},
content = { innerPadding ->
DownloadsList(
viewModel = viewModel,
contentPadding = innerPadding,
)
},
)
}
+2 -1
View File
@@ -114,6 +114,7 @@ compose-material-icons-extended = { group = "androidx.compose.material", name =
jellyfin-sdk = { group = "org.jellyfin.sdk", name = "jellyfin-core", version.ref = "jellyfin-sdk" }
coil = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" }
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
# Media
androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" }
@@ -154,7 +155,7 @@ detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-form
[bundles]
coroutines = ["coroutines-core", "coroutines-android"]
koin = ["koin", "koin-compose"]
coil = ["coil", "coil-network"]
coil = ["coil", "coil-network", "coil-compose"]
androidx-lifecycle = [
"androidx-lifecycle-viewmodel",
"androidx-lifecycle-livedata",