From 65ce28cbd797f4ff0899965c47f749a4c71d5af6 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Fri, 3 Oct 2025 20:31:28 -0500 Subject: [PATCH] Address PR Feedback --- .../newpipe/download/DownloadDialog.java | 24 +-- .../download/DownloadStatusRepository.kt | 180 +++++++++++++----- .../newpipe/download/ui/DownloadStatusUi.kt | 169 +++++++++++----- .../fragments/detail/VideoDetailFragment.kt | 56 +++--- .../detail/VideoDownloadStatusViewModel.kt | 95 +++++---- .../newpipe/util/StreamItemAdapter.java | 24 +-- .../schabi/newpipe/util/StreamLabelUtils.kt | 30 +++ .../giga/service/DownloadManager.java | 78 ++++++-- .../giga/service/DownloadManagerService.java | 12 +- app/src/main/res/values/strings.xml | 15 +- 10 files changed, 448 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 98cfbb25c..a786e1bbf 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -70,6 +70,7 @@ import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; +import org.schabi.newpipe.util.StreamLabelUtils; import org.schabi.newpipe.util.ThemeHelper; import java.io.File; @@ -1132,7 +1133,8 @@ public class DownloadDialog extends DialogFragment ); } - final String qualityLabel = buildQualityLabel(selectedStream); + final String qualityLabel = + StreamLabelUtils.getQualityLabel(requireContext(), selectedStream); DownloadManagerService.startMission( context, @@ -1155,24 +1157,4 @@ public class DownloadDialog extends DialogFragment dismiss(); } - - @Nullable - private String buildQualityLabel(@NonNull final Stream stream) { - if (stream instanceof VideoStream) { - return ((VideoStream) stream).getResolution(); - } else if (stream instanceof AudioStream) { - final int bitrate = ((AudioStream) stream).getAverageBitrate(); - return bitrate > 0 ? bitrate + "kbps" : null; - } else if (stream instanceof SubtitlesStream) { - final SubtitlesStream subtitlesStream = (SubtitlesStream) stream; - final String language = subtitlesStream.getDisplayLanguageName(); - if (subtitlesStream.isAutoGenerated()) { - return language + " (" + getString(R.string.caption_auto_generated) + ")"; - } - return language; - } - - final MediaFormat format = stream.getFormat(); - return format != null ? format.getSuffix() : null; - } } diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt index 4002ef42c..8ef710b31 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt @@ -23,26 +23,43 @@ import us.shandian.giga.service.MissionState import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -sealed interface DownloadStatus { - data object None : DownloadStatus - data class InProgress(val running: Boolean) : DownloadStatus - data class Completed(val info: CompletedDownload) : DownloadStatus +enum class DownloadStage { + Pending, + Running, + Finished } -data class CompletedDownload( +data class DownloadHandle( + val serviceId: Int, + val url: String, + val streamUid: Long, + val storageUri: Uri?, + val timestamp: Long, + val kind: Char +) + +data class DownloadEntry( + val handle: DownloadHandle, val displayName: String?, val qualityLabel: String?, val mimeType: String?, val fileUri: Uri?, val parentUri: Uri?, - val fileAvailable: Boolean + val fileAvailable: Boolean, + val stage: DownloadStage ) object DownloadStatusRepository { - fun observe(context: Context, serviceId: Int, url: String): Flow = callbackFlow { + /** + * Keeps a one-off binding to [DownloadManagerService] alive for as long as the caller stays + * subscribed. We prime the channel with the latest persisted snapshot and then forward every + * mission event emitted by the service-bound handler. Once the consumer cancels the flow we + * make sure to unregister the handler and unbind the service to avoid leaking the connection. + */ + fun observe(context: Context, serviceId: Int, url: String): Flow> = callbackFlow { if (serviceId < 0 || url.isBlank()) { - trySend(DownloadStatus.None) + trySend(emptyList()) close() return@callbackFlow } @@ -50,6 +67,9 @@ object DownloadStatusRepository { val appContext = context.applicationContext val intent = Intent(appContext, DownloadManagerService::class.java) appContext.startService(intent) + // The download manager service only notifies listeners while a client is bound, so the flow + // keeps a foreground-style binding alive for its entire lifetime. Holding on to + // applicationContext avoids leaking short-lived UI contexts. var binder: DownloadManagerBinder? = null var registeredCallback: Handler.Callback? = null @@ -57,19 +77,23 @@ object DownloadStatusRepository { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val downloadBinder = service as? DownloadManagerBinder if (downloadBinder == null) { - trySend(DownloadStatus.None) + trySend(emptyList()) appContext.unbindService(this) close() return } binder = downloadBinder - trySend(downloadBinder.getDownloadStatus(serviceId, url, false).toDownloadStatus()) + // First delivery: snapshot persisted on disk so the UI paints immediately even + // before the service emits new events. + trySend(downloadBinder.getDownloadStatuses(serviceId, url, true).toDownloadEntries()) val callback = Handler.Callback { message: Message -> val mission = message.obj if (mission.matches(serviceId, url)) { - val snapshot = downloadBinder.getDownloadStatus(serviceId, url, false) - trySend(snapshot.toDownloadStatus()) + // Each mission event carries opaque state, so we fetch a fresh snapshot to + // guarantee consistent entries while the download progresses or finishes. + val snapshots = downloadBinder.getDownloadStatuses(serviceId, url, false) + trySend(snapshots.toDownloadEntries()) } false } @@ -80,48 +104,57 @@ object DownloadStatusRepository { override fun onServiceDisconnected(name: ComponentName?) { registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) } binder = null - trySend(DownloadStatus.None) + trySend(emptyList()) } } val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) if (!bound) { - trySend(DownloadStatus.None) + trySend(emptyList()) close() return@callbackFlow } awaitClose { + // When the collector disappears we remove listeners and unbind immediately to avoid + // holding the service forever; the service will rebind on the next subscription. registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) } runCatching { appContext.unbindService(connection) } } } - suspend fun refresh(context: Context, serviceId: Int, url: String): DownloadStatus { - if (serviceId < 0 || url.isBlank()) return DownloadStatus.None + suspend fun refresh(context: Context, serviceId: Int, url: String): List { + if (serviceId < 0 || url.isBlank()) return emptyList() return withBinder(context) { binder -> - binder.getDownloadStatus(serviceId, url, true).toDownloadStatus() + binder.getDownloadStatuses(serviceId, url, true).toDownloadEntries() } } - suspend fun deleteFile(context: Context, serviceId: Int, url: String): Boolean { - if (serviceId < 0 || url.isBlank()) return false + suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean { + if (handle.serviceId < 0 || handle.url.isBlank()) return false return withBinder(context) { binder -> - binder.deleteFinishedMission(serviceId, url, true) + binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, true) } } - suspend fun removeLink(context: Context, serviceId: Int, url: String): Boolean { - if (serviceId < 0 || url.isBlank()) return false + suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean { + if (handle.serviceId < 0 || handle.url.isBlank()) return false return withBinder(context) { binder -> - binder.deleteFinishedMission(serviceId, url, false) + binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, false) } } + /** + * Helper that briefly binds to [DownloadManagerService], executes [block] against its binder + * and tears everything down in one place. All callers should use this to prevent scattering + * ad-hoc bind/unbind logic across the codebase. + */ private suspend fun withBinder(context: Context, block: (DownloadManagerBinder) -> T): T { val appContext = context.applicationContext val intent = Intent(appContext, DownloadManagerService::class.java) appContext.startService(intent) + // The direct call path still needs the service running long enough to complete the + // binder transaction, so we explicitly start it before establishing the short-lived bind. return suspendCancellableCoroutine { continuation -> val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { @@ -176,32 +209,83 @@ object DownloadStatusRepository { @VisibleForTesting @MainThread - internal fun DownloadManager.DownloadStatusSnapshot?.toDownloadStatus(): DownloadStatus { - if (this == null || state == MissionState.None) { - return DownloadStatus.None - } - return when (state) { - MissionState.Pending, MissionState.PendingRunning -> - DownloadStatus.InProgress(state == MissionState.PendingRunning) - MissionState.Finished -> { - val mission = finishedMission - if (mission == null) { - DownloadStatus.None - } else { - val storage = mission.storage - val hasStorage = storage != null && !storage.isInvalid() - val info = CompletedDownload( - displayName = storage?.getName(), - qualityLabel = mission.qualityLabel, - mimeType = if (hasStorage) storage!!.getType() else null, - fileUri = if (hasStorage) storage!!.getUri() else null, - parentUri = if (hasStorage) storage!!.getParentUri() else null, - fileAvailable = fileExists && hasStorage - ) - DownloadStatus.Completed(info) - } + internal fun List.toDownloadEntries(): List { + return buildList { + for (snapshot in this@toDownloadEntries) { + snapshot.toDownloadEntry()?.let { add(it) } } - else -> DownloadStatus.None } } + + @VisibleForTesting + @MainThread + internal fun DownloadManager.DownloadStatusSnapshot.toDownloadEntry(): DownloadEntry? { + val stage = when (state) { + MissionState.Pending -> DownloadStage.Pending + MissionState.PendingRunning -> DownloadStage.Running + MissionState.Finished -> DownloadStage.Finished + else -> return null + } + + val (metadata, storage) = when (stage) { + DownloadStage.Finished -> finishedMission?.let { + MissionMetadata( + serviceId = it.serviceId, + url = it.source, + streamUid = it.streamUid, + timestamp = it.timestamp, + kind = it.kind, + qualityLabel = it.qualityLabel + ) to it.storage + } + else -> pendingMission?.let { + MissionMetadata( + serviceId = it.serviceId, + url = it.source, + streamUid = it.streamUid, + timestamp = it.timestamp, + kind = it.kind, + qualityLabel = it.qualityLabel + ) to it.storage + } + } ?: return null + + val hasStorage = storage != null && !storage.isInvalid() + val fileUri = storage?.getUri() + val parentUri = storage?.getParentUri() + + val handle = DownloadHandle( + serviceId = metadata.serviceId, + url = metadata.url ?: "", + streamUid = metadata.streamUid, + storageUri = fileUri, + timestamp = metadata.timestamp, + kind = metadata.kind + ) + + val fileAvailable = when (stage) { + DownloadStage.Finished -> hasStorage && fileExists + DownloadStage.Pending, DownloadStage.Running -> false + } + + return DownloadEntry( + handle = handle, + displayName = storage?.getName(), + qualityLabel = metadata.qualityLabel, + mimeType = if (hasStorage) storage.getType() else null, + fileUri = fileUri, + parentUri = parentUri, + fileAvailable = fileAvailable, + stage = stage + ) + } + + private data class MissionMetadata( + val serviceId: Int, + val url: String?, + val streamUid: Long, + val timestamp: Long, + val kind: Char, + val qualityLabel: String? + ) } diff --git a/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt index ef36b7d09..4481dd9dd 100644 --- a/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt +++ b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt @@ -1,11 +1,14 @@ package org.schabi.newpipe.download.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -14,66 +17,134 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.schabi.newpipe.R -import org.schabi.newpipe.download.CompletedDownload -import org.schabi.newpipe.fragments.detail.DownloadChipState +import org.schabi.newpipe.download.DownloadEntry +import org.schabi.newpipe.download.DownloadStage import org.schabi.newpipe.fragments.detail.DownloadUiState +import org.schabi.newpipe.fragments.detail.isPending +import org.schabi.newpipe.fragments.detail.isRunning +import us.shandian.giga.util.Utility +import us.shandian.giga.util.Utility.FileType -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DownloadStatusHost( state: DownloadUiState, - onChipClick: () -> Unit, + onChipClick: (DownloadEntry) -> Unit, onDismissSheet: () -> Unit, - onOpenFile: (CompletedDownload) -> Unit, - onDeleteFile: (CompletedDownload) -> Unit, - onRemoveLink: (CompletedDownload) -> Unit, - onShowInFolder: (CompletedDownload) -> Unit + onOpenFile: (DownloadEntry) -> Unit, + onDeleteFile: (DownloadEntry) -> Unit, + onRemoveLink: (DownloadEntry) -> Unit, + onShowInFolder: (DownloadEntry) -> Unit ) { - val chipState = state.chipState + val selected = state.selected val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (state.isSheetVisible && chipState is DownloadChipState.Downloaded) { + if (state.isSheetVisible && selected != null) { ModalBottomSheet( onDismissRequest = onDismissSheet, sheetState = sheetState ) { DownloadSheetContent( - info = chipState.info, - onOpenFile = { onOpenFile(chipState.info) }, - onDeleteFile = { onDeleteFile(chipState.info) }, - onRemoveLink = { onRemoveLink(chipState.info) }, - onShowInFolder = { onShowInFolder(chipState.info) } + entry = selected, + onOpenFile = { onOpenFile(selected) }, + onDeleteFile = { onDeleteFile(selected) }, + onRemoveLink = { onRemoveLink(selected) }, + onShowInFolder = { onShowInFolder(selected) } ) } } - when (chipState) { - DownloadChipState.Hidden -> Unit - DownloadChipState.Downloading -> AssistChip( - onClick = onChipClick, - label = { Text(text = stringResource(id = R.string.download_status_downloading)) } - ) - is DownloadChipState.Downloaded -> { - val label = chipState.info.qualityLabel - val text = if (!label.isNullOrBlank()) { - stringResource(R.string.download_status_downloaded, label) - } else { - stringResource(R.string.download_status_downloaded_simple) - } - AssistChip( - onClick = onChipClick, - label = { Text(text = text) } - ) + if (state.entries.isEmpty()) { + return + } + + FlowRow(modifier = Modifier.padding(horizontal = 12.dp)) { + state.entries.forEach { entry -> + DownloadChip(entry = entry, onClick = { onChipClick(entry) }) } } } +@Composable +private fun DownloadChip(entry: DownloadEntry, onClick: () -> Unit) { + val context = LocalContext.current + val type = Utility.getFileType(entry.handle.kind, entry.displayName ?: "") + val backgroundColor = Utility.getBackgroundForFileType(context, type) + val stripeColor = Utility.getForegroundForFileType(context, type) + + val typeLabelRes = when (type) { + FileType.MUSIC -> R.string.download_type_audio + FileType.VIDEO -> R.string.download_type_video + FileType.SUBTITLE -> R.string.download_type_captions + FileType.UNKNOWN -> R.string.download_type_media + } + val typeLabel = stringResource(typeLabelRes) + + val stageText = when (entry.stage) { + DownloadStage.Finished -> stringResource(R.string.download_status_downloaded_type, typeLabel) + DownloadStage.Running -> stringResource(R.string.download_status_downloading_type, typeLabel) + DownloadStage.Pending -> stringResource(R.string.download_status_pending_type, typeLabel) + } + + val chipText = entry.qualityLabel?.takeIf { it.isNotBlank() }?.let { "$stageText • $it" } ?: stageText + + val chipShape = MaterialTheme.shapes.small + + val baseModifier = Modifier + .padding(end = 8.dp, bottom = 8.dp) + .clip(chipShape) + .drawWithContent { + if (entry.stage == DownloadStage.Finished) { + drawRect(Color(backgroundColor)) + drawContent() + } else if (entry.isPending) { + drawRect(Color(backgroundColor)) + val stripePaint = Color(stripeColor).copy(alpha = 0.35f) + val stripeWidth = 12.dp.toPx() + var offset = -size.height + val diagonal = size.height + while (offset < size.width + size.height) { + drawLine( + color = stripePaint, + start = Offset(offset, 0f), + end = Offset(offset + diagonal, diagonal), + strokeWidth = stripeWidth + ) + offset += stripeWidth * 2f + } + drawContent() + } else { + drawContent() + } + } + + val labelColor = MaterialTheme.colorScheme.onSurface + + val chipColors = AssistChipDefaults.assistChipColors( + containerColor = Color.Transparent, + labelColor = labelColor + ) + + AssistChip( + onClick = onClick, + label = { Text(text = chipText) }, + colors = chipColors, + modifier = baseModifier, + border = null + ) +} + @Composable private fun DownloadSheetContent( - info: CompletedDownload, + entry: DownloadEntry, onOpenFile: () -> Unit, onDeleteFile: () -> Unit, onRemoveLink: () -> Unit, @@ -84,13 +155,22 @@ private fun DownloadSheetContent( .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp) ) { - val title = info.displayName ?: stringResource(id = R.string.download) + val title = entry.displayName ?: stringResource(id = R.string.download) Text(text = title, style = MaterialTheme.typography.titleLarge) val subtitleParts = buildList { - info.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) } - if (!info.fileAvailable) { - add(stringResource(id = R.string.download_status_missing)) + entry.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) } + when (entry.stage) { + DownloadStage.Finished -> if (!entry.fileAvailable) { + add(stringResource(id = R.string.download_status_missing)) + } + DownloadStage.Pending, DownloadStage.Running -> { + if (entry.isRunning) { + add(stringResource(R.string.download_status_downloading)) + } else { + add(stringResource(R.string.missions_header_pending)) + } + } } } if (subtitleParts.isNotEmpty()) { @@ -104,21 +184,24 @@ private fun DownloadSheetContent( Spacer(modifier = Modifier.height(12.dp)) - val showFileActions = info.fileAvailable && info.fileUri != null + val showFileActions = entry.fileAvailable && entry.fileUri != null if (showFileActions) { TextButton(onClick = onOpenFile) { - Text(text = stringResource(id = R.string.download_action_open)) + Text(text = stringResource(id = R.string.open_with)) } - TextButton(onClick = onShowInFolder, enabled = info.parentUri != null) { + TextButton(onClick = onShowInFolder, enabled = entry.parentUri != null) { Text(text = stringResource(id = R.string.download_action_show_in_folder)) } TextButton(onClick = onDeleteFile) { - Text(text = stringResource(id = R.string.download_action_delete)) + Text(text = stringResource(id = R.string.delete_file)) } } - TextButton(onClick = onRemoveLink) { - Text(text = stringResource(id = R.string.download_action_remove_link), color = MaterialTheme.colorScheme.error) + TextButton(onClick = onRemoveLink, enabled = entry.stage == DownloadStage.Finished) { + Text( + text = stringResource(id = R.string.delete_entry), + color = MaterialTheme.colorScheme.error + ) } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 88382bc62..1ac996361 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -67,8 +67,8 @@ import org.schabi.newpipe.App import org.schabi.newpipe.R import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.databinding.FragmentVideoDetailBinding -import org.schabi.newpipe.download.CompletedDownload import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.download.DownloadEntry import org.schabi.newpipe.download.ui.DownloadStatusHost import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar @@ -288,14 +288,12 @@ class VideoDetailFragment : val composeContext = LocalContext.current DownloadStatusHost( state = uiState, - onChipClick = { - downloadStatusViewModel.onChipClicked(composeContext.applicationContext) - }, + onChipClick = { entry -> downloadStatusViewModel.onChipSelected(entry) }, onDismissSheet = { downloadStatusViewModel.dismissSheet() }, - onOpenFile = { info -> openDownloaded(info) }, - onDeleteFile = { info -> deleteDownloadedFile(info) }, - onRemoveLink = { info -> removeDownloadLink(info) }, - onShowInFolder = { info -> showDownloadedInFolder(info) } + onOpenFile = { entry -> openDownloaded(entry) }, + onDeleteFile = { entry -> deleteDownloadedFile(entry) }, + onRemoveLink = { entry -> removeDownloadLink(entry) }, + onShowInFolder = { entry -> showDownloadedInFolder(entry) } ) } } @@ -1578,29 +1576,29 @@ class VideoDetailFragment : } } - private fun openDownloaded(info: CompletedDownload) { - val uri = info.fileUri + private fun openDownloaded(entry: DownloadEntry) { + val uri = entry.fileUri if (uri == null) { - Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show() return } val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, info.mimeType ?: "*/*") + setDataAndType(uri, entry.mimeType ?: "*/*") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } runCatching { startActivity(intent) } .onFailure { if (DEBUG) Log.e(TAG, "Failed to open downloaded file", it) - Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show() } } - private fun showDownloadedInFolder(info: CompletedDownload) { - val parent = info.parentUri + private fun showDownloadedInFolder(entry: DownloadEntry) { + val parent = entry.parentUri if (parent == null) { - Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.invalid_directory, Toast.LENGTH_SHORT).show() return } @@ -1619,37 +1617,29 @@ class VideoDetailFragment : runCatching { startActivity(treeIntent) } .onFailure { throwable -> if (DEBUG) Log.e(TAG, "Failed to open folder", throwable) - Toast.makeText(context, R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.invalid_directory, Toast.LENGTH_SHORT).show() } } } - private fun deleteDownloadedFile(info: CompletedDownload) { - if (!info.fileAvailable) { - Toast.makeText(requireContext(), R.string.download_delete_failed, Toast.LENGTH_SHORT).show() + private fun deleteDownloadedFile(entry: DownloadEntry) { + if (!entry.fileAvailable) { + Toast.makeText(requireContext(), R.string.general_error, Toast.LENGTH_SHORT).show() return } val appContext = requireContext().applicationContext viewLifecycleOwner.lifecycleScope.launch { - val success = downloadStatusViewModel.deleteFile(appContext) - val message = if (success) { - R.string.download_deleted - } else { - R.string.download_delete_failed - } + val success = downloadStatusViewModel.deleteFile(appContext, entry.handle) + val message = if (success) R.string.file_deleted else R.string.general_error Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } } - private fun removeDownloadLink(@Suppress("UNUSED_PARAMETER") info: CompletedDownload) { + private fun removeDownloadLink(entry: DownloadEntry) { val appContext = requireContext().applicationContext viewLifecycleOwner.lifecycleScope.launch { - val success = downloadStatusViewModel.removeLink(appContext) - val message = if (success) { - R.string.download_link_removed - } else { - R.string.download_delete_failed - } + val success = downloadStatusViewModel.removeLink(appContext, entry.handle) + val message = if (success) R.string.entry_deleted else R.string.general_error Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt index 940cd774f..3516c9648 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt @@ -9,8 +9,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.schabi.newpipe.download.CompletedDownload -import org.schabi.newpipe.download.DownloadStatus +import org.schabi.newpipe.download.DownloadEntry +import org.schabi.newpipe.download.DownloadHandle +import org.schabi.newpipe.download.DownloadStage import org.schabi.newpipe.download.DownloadStatusRepository class VideoDownloadStatusViewModel : ViewModel() { @@ -44,76 +45,70 @@ class VideoDownloadStatusViewModel : ViewModel() { observeJob?.cancel() observeJob = viewModelScope.launch { - DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl).collectLatest { status -> - _uiState.update { it.copy(chipState = status.toChipState()) } - } + DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl) + .collectLatest { entries -> + _uiState.update { current -> + val selectedHandle = current.selected?.handle + val newSelected = selectedHandle?.let { handle -> + entries.firstOrNull { it.handle == handle } + } + current.copy(entries = entries, selected = newSelected) + } + } } } - fun onChipClicked(context: Context) { - val url = currentUrl ?: return - val serviceId = currentServiceId - viewModelScope.launch { - val result = runCatching { - DownloadStatusRepository.refresh(context.applicationContext, serviceId, url) - } - result.getOrNull()?.let { status -> - _uiState.update { - val chipState = status.toChipState() - it.copy( - chipState = chipState, - isSheetVisible = chipState is DownloadChipState.Downloaded - ) - } - } - if (result.isFailure) { - _uiState.update { it.copy(isSheetVisible = false) } - } - } + fun onChipSelected(entry: DownloadEntry) { + _uiState.update { it.copy(selected = entry) } } fun dismissSheet() { - _uiState.update { it.copy(isSheetVisible = false) } + _uiState.update { it.copy(selected = null) } } - suspend fun deleteFile(context: Context): Boolean { - val url = currentUrl ?: return false - val serviceId = currentServiceId + suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean { val success = runCatching { - DownloadStatusRepository.deleteFile(context.applicationContext, serviceId, url) + DownloadStatusRepository.deleteFile(context.applicationContext, handle) }.getOrDefault(false) if (success) { - _uiState.update { it.copy(isSheetVisible = false) } + _uiState.update { state -> + state.copy( + entries = state.entries.filterNot { it.handle == handle }, + selected = null + ) + } } return success } - suspend fun removeLink(context: Context): Boolean { - val url = currentUrl ?: return false - val serviceId = currentServiceId + suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean { val success = runCatching { - DownloadStatusRepository.removeLink(context.applicationContext, serviceId, url) + DownloadStatusRepository.removeLink(context.applicationContext, handle) }.getOrDefault(false) if (success) { - _uiState.update { it.copy(isSheetVisible = false) } + _uiState.update { state -> + state.copy( + entries = state.entries.filterNot { it.handle == handle }, + selected = null + ) + } } return success } - - private fun DownloadStatus.toChipState(): DownloadChipState = when (this) { - DownloadStatus.None -> DownloadChipState.Hidden - is DownloadStatus.InProgress -> DownloadChipState.Downloading - is DownloadStatus.Completed -> DownloadChipState.Downloaded(info) - } } data class DownloadUiState( - val chipState: DownloadChipState = DownloadChipState.Hidden, - val isSheetVisible: Boolean = false -) - -sealed interface DownloadChipState { - data object Hidden : DownloadChipState - data object Downloading : DownloadChipState - data class Downloaded(val info: CompletedDownload) : DownloadChipState + val entries: List = emptyList(), + val selected: DownloadEntry? = null +) { + val isSheetVisible: Boolean get() = selected != null } + +val DownloadEntry.isPending: Boolean + get() = when (stage) { + DownloadStage.Pending, DownloadStage.Running -> true + DownloadStage.Finished -> false + } + +val DownloadEntry.isRunning: Boolean + get() = stage == DownloadStage.Running diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 2eeb14b1b..7068304a7 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -134,7 +134,6 @@ public class StreamItemAdapter extends BaseA if (stream instanceof VideoStream) { final VideoStream videoStream = ((VideoStream) stream); - qualityString = videoStream.getResolution(); if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { if (videoStream.isVideoOnly()) { @@ -149,24 +148,13 @@ public class StreamItemAdapter extends BaseA woSoundIconVisibility = View.INVISIBLE; } } - } else if (stream instanceof AudioStream) { - final AudioStream audioStream = ((AudioStream) stream); - if (audioStream.getAverageBitrate() > 0) { - qualityString = audioStream.getAverageBitrate() + "kbps"; - } else { - qualityString = context.getString(R.string.unknown_quality); - } - } else if (stream instanceof SubtitlesStream) { - qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); - if (((SubtitlesStream) stream).isAutoGenerated()) { - qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; - } } else { - if (mediaFormat == null) { - qualityString = context.getString(R.string.unknown_quality); - } else { - qualityString = mediaFormat.getSuffix(); - } + woSoundIconVisibility = View.GONE; + } + + qualityString = StreamLabelUtils.getQualityLabel(context, stream); + if (qualityString == null) { + qualityString = context.getString(R.string.unknown_quality); } if (streamsWrapper.getSizeInBytes(position) > 0) { diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt b/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt new file mode 100644 index 000000000..908c64cae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.util + +import android.content.Context +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.Stream +import org.schabi.newpipe.extractor.stream.SubtitlesStream +import org.schabi.newpipe.extractor.stream.VideoStream + +object StreamLabelUtils { + @JvmStatic + fun getQualityLabel(context: Context, stream: Stream): String? = when (stream) { + is VideoStream -> stream.resolution?.takeIf { it.isNotBlank() } + is AudioStream -> { + val bitrate = stream.averageBitrate + if (bitrate > 0) "$bitrate kbps" else null + } + is SubtitlesStream -> { + val language = stream.displayLanguageName + if (language.isNullOrBlank()) { + null + } else if (stream.isAutoGenerated) { + "$language (${context.getString(R.string.caption_auto_generated)})" + } else { + language + } + } + else -> stream.format?.suffix?.takeIf { it.isNotBlank() } + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 9ba021c12..548581937 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -1,6 +1,7 @@ package us.shandian.giga.service; import android.content.Context; +import android.net.Uri; import android.os.Handler; import android.util.Log; @@ -363,6 +364,30 @@ public class DownloadManager { return null; } + @Nullable + private FinishedMission getFinishedMission(Uri storageUri) { + String uriString = storageUri.toString(); + for (FinishedMission mission : mMissionsFinished) { + if (mission.storage != null && !mission.storage.isInvalid()) { + Uri missionUri = mission.storage.getUri(); + if (missionUri != null && uriString.equals(missionUri.toString())) { + return mission; + } + } + } + return null; + } + + @Nullable + private FinishedMission getFinishedMission(long timestamp) { + for (FinishedMission mission : mMissionsFinished) { + if (mission.timestamp == timestamp) { + return mission; + } + } + return null; + } + private boolean isFileAvailable(@NonNull FinishedMission mission) { if (mission.storage == null || mission.storage.isInvalid()) { return false; @@ -474,27 +499,56 @@ public class DownloadManager { } DownloadStatusSnapshot getDownloadStatus(int serviceId, String url, boolean revalidateFile) { + List snapshots = getDownloadStatuses(serviceId, url, revalidateFile); + if (snapshots.isEmpty()) { + return new DownloadStatusSnapshot(MissionState.None, null, null, false); + } + return snapshots.get(0); + } + + List getDownloadStatuses(int serviceId, String url, boolean revalidateFile) { + List result = new ArrayList<>(); synchronized (this) { - DownloadMission pending = getPendingMission(serviceId, url); - if (pending != null) { - MissionState state = pending.running - ? MissionState.PendingRunning - : MissionState.Pending; - return new DownloadStatusSnapshot(state, pending, null, true); + for (DownloadMission mission : mMissionsPending) { + if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) { + MissionState state = mission.running + ? MissionState.PendingRunning + : MissionState.Pending; + result.add(new DownloadStatusSnapshot(state, mission, null, true)); + } } - FinishedMission finished = getFinishedMission(serviceId, url); - if (finished != null) { - boolean available = !revalidateFile || isFileAvailable(finished); - return new DownloadStatusSnapshot(MissionState.Finished, null, finished, available); + for (FinishedMission mission : mMissionsFinished) { + if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) { + boolean available = !revalidateFile || isFileAvailable(mission); + result.add(new DownloadStatusSnapshot(MissionState.Finished, null, mission, available)); + } } } - return new DownloadStatusSnapshot(MissionState.None, null, null, false); + if (result.isEmpty()) { + result.add(new DownloadStatusSnapshot(MissionState.None, null, null, false)); + } + return result; } + @Deprecated boolean deleteFinishedMission(int serviceId, String url, boolean deleteFile) { - FinishedMission mission = getFinishedMission(serviceId, url); + return deleteFinishedMission(serviceId, url, null, -1L, deleteFile); + } + + boolean deleteFinishedMission(int serviceId, String url, @Nullable Uri storageUri, + long timestamp, boolean deleteFile) { + FinishedMission mission = null; + if (storageUri != null) { + mission = getFinishedMission(storageUri); + } + if (mission == null && timestamp > 0) { + mission = getFinishedMission(timestamp); + } + if (mission == null) { + mission = getFinishedMission(serviceId, url); + } if (mission == null) { return false; } diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 53726ab54..3826455f3 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -38,6 +38,8 @@ import androidx.core.content.ContextCompat; import androidx.core.content.IntentCompat; import androidx.preference.PreferenceManager; +import java.util.List; + import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; @@ -601,8 +603,14 @@ public class DownloadManagerService extends Service { return mManager.getDownloadStatus(serviceId, source, revalidateFile); } - public boolean deleteFinishedMission(int serviceId, String source, boolean deleteFile) { - return mManager.deleteFinishedMission(serviceId, source, deleteFile); + public List getDownloadStatuses(int serviceId, + String source, boolean revalidateFile) { + return mManager.getDownloadStatuses(serviceId, source, revalidateFile); + } + + public boolean deleteFinishedMission(int serviceId, String source, @Nullable Uri storageUri, + long timestamp, boolean deleteFile) { + return mManager.deleteFinishedMission(serviceId, source, storageUri, timestamp, deleteFile); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8f1440a6..05f2abacf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,17 +18,16 @@ Download stream file Downloaded • %1$s Downloaded + Downloaded %1$s + Downloading %1$s + Pending %1$s + Audio + Video + Captions + Media Downloading… Previously downloaded – file missing - Open file Show in folder - Delete file - Remove link - Download link removed - Unable to open downloaded file - Unable to open folder - Unable to delete downloaded file - Deleted downloaded file Search Search %1$s Search %1$s (%2$s)