diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadDiffCallback.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadDiffCallback.kt deleted file mode 100644 index d9c0d0eb..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadDiffCallback.kt +++ /dev/null @@ -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() { - override fun areItemsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean { - return oldItem.itemId == newItem.itemId - } - - override fun areContentsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean { - return oldItem == newItem - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt index e65430ac..5552bc48 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt @@ -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!!) } } } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt index decbb09f..423961d4 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt @@ -16,10 +16,9 @@ class DownloadTracker(downloadManager: DownloadManager) { private val listeners: CopyOnWriteArraySet = CopyOnWriteArraySet() private val downloads: HashMap = HashMap() - private val downloadIndex: DownloadIndex + private val downloadIndex: DownloadIndex = downloadManager.downloadIndex init { - downloadIndex = downloadManager.downloadIndex downloadManager.addListener(DownloadManagerListener()) loadDownloads() } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt index 5445640b..30c153be 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt @@ -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) diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsAdapter.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsAdapter.kt deleted file mode 100644 index 31a2461b..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsAdapter.kt +++ /dev/null @@ -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( - 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, - ) - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt index 0ca20859..513929eb 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt @@ -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)) } } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt index ee76aed8..28729379 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt @@ -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()) + val downloads: StateFlow> = 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 -> - _downloads.update { downloads } - } - } + fun removeDownload(download: DownloadEntity, force: Boolean = false) { + activityEventHandler.emit(ActivityEvent.RemoveDownload(download.mediaSource, force)) } } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt index b1a04906..f0811334 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt @@ -35,7 +35,7 @@ class JellyfinDownloadService : DownloadService( return downloadManager } - override fun getScheduler(): Scheduler? { + override fun getScheduler(): Scheduler { return PlatformScheduler(this, jobId) } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt index d34641ee..8ffe242d 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt @@ -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 -> "" } diff --git a/app/src/main/java/org/jellyfin/mobile/ui/ComposeFragment.kt b/app/src/main/java/org/jellyfin/mobile/ui/ComposeFragment.kt new file mode 100644 index 00000000..55cfdd17 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/ComposeFragment.kt @@ -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() +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt new file mode 100644 index 00000000..d20a9802 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt @@ -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, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt new file mode 100644 index 00000000..9954f8d9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt @@ -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, + ) + }, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b32970ce..c80f2464 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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",