Address PR Feedback

This commit is contained in:
Josh Mandel 2025-10-03 20:31:28 -05:00
parent 13b10b6e52
commit 65ce28cbd7
10 changed files with 448 additions and 235 deletions

View File

@ -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;
}
}

View File

@ -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<DownloadStatus> = 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<List<DownloadEntry>> = 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<DownloadEntry> {
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 <T> 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<DownloadManager.DownloadStatusSnapshot>.toDownloadEntries(): List<DownloadEntry> {
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?
)
}

View File

@ -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))

View File

@ -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()
}
}

View File

@ -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<DownloadEntry> = 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

View File

@ -134,7 +134,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> 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<T extends Stream, U extends Stream> 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) {

View File

@ -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() }
}
}

View File

@ -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<DownloadStatusSnapshot> snapshots = getDownloadStatuses(serviceId, url, revalidateFile);
if (snapshots.isEmpty()) {
return new DownloadStatusSnapshot(MissionState.None, null, null, false);
}
return snapshots.get(0);
}
List<DownloadStatusSnapshot> getDownloadStatuses(int serviceId, String url, boolean revalidateFile) {
List<DownloadStatusSnapshot> 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;
}

View File

@ -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<DownloadManager.DownloadStatusSnapshot> 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);
}
}

View File

@ -18,17 +18,16 @@
<string name="controls_download_desc">Download stream file</string>
<string name="download_status_downloaded">Downloaded • %1$s</string>
<string name="download_status_downloaded_simple">Downloaded</string>
<string name="download_status_downloaded_type">Downloaded %1$s</string>
<string name="download_status_downloading_type">Downloading %1$s</string>
<string name="download_status_pending_type">Pending %1$s</string>
<string name="download_type_audio">Audio</string>
<string name="download_type_video">Video</string>
<string name="download_type_captions">Captions</string>
<string name="download_type_media">Media</string>
<string name="download_status_downloading">Downloading…</string>
<string name="download_status_missing">Previously downloaded file missing</string>
<string name="download_action_open">Open file</string>
<string name="download_action_show_in_folder">Show in folder</string>
<string name="download_action_delete">Delete file</string>
<string name="download_action_remove_link">Remove link</string>
<string name="download_link_removed">Download link removed</string>
<string name="download_open_failed">Unable to open downloaded file</string>
<string name="download_folder_open_failed">Unable to open folder</string>
<string name="download_delete_failed">Unable to delete downloaded file</string>
<string name="download_deleted">Deleted downloaded file</string>
<string name="search">Search</string>
<string name="search_with_service_name">Search %1$s</string>
<string name="search_with_service_name_and_filter">Search %1$s (%2$s)</string>