Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
Original file line number Diff line number Diff line change
Expand Up @@ -1132,12 +1132,47 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) {
);
}

DownloadManagerService.startMission(context, urls, storage, kind, threads,
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
final String qualityLabel = buildQualityLabel(selectedStream);

DownloadManagerService.startMission(
context,
urls,
storage,
kind,
threads,
currentInfo.getUrl(),
psName,
psArgs,
nearLength,
new ArrayList<>(recoveryInfo),
-1L,
currentInfo.getServiceId(),
qualityLabel
);

Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show();

dismiss();
}

@Nullable
private String buildQualityLabel(@NonNull final Stream stream) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of duplicating code, could you create a common function and also use it in https://github.com/TeamNewPipe/NewPipe/blob/dev/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java#L113 ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created StreamLabelUtils.getQualityLabel(...) and replaced both the dialog helper and the adapter text formatting with calls to that function.

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package org.schabi.newpipe.download

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Handler
import android.os.IBinder
import android.os.Message
import androidx.annotation.MainThread
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import us.shandian.giga.get.DownloadMission
import us.shandian.giga.get.FinishedMission
import us.shandian.giga.service.DownloadManager
import us.shandian.giga.service.DownloadManagerService
import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't None and InProgress also have CompletedDownload info? Isn't that info also available there?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introduced a unified DownloadEntry data class that carries the same metadata for pending, running, and finished missions, so each chip can surface identical details regardless of state.

data class Completed(val info: CompletedDownload) : DownloadStatus
}

data class CompletedDownload(
val displayName: String?,
val qualityLabel: String?,
val mimeType: String?,
val fileUri: Uri?,
val parentUri: Uri?,
val fileAvailable: Boolean
)

object DownloadStatusRepository {

fun observe(context: Context, serviceId: Int, url: String): Flow<DownloadStatus> = callbackFlow {
if (serviceId < 0 || url.isBlank()) {
trySend(DownloadStatus.None)
close()
return@callbackFlow
}

val appContext = context.applicationContext
val intent = Intent(appContext, DownloadManagerService::class.java)
appContext.startService(intent)
var binder: DownloadManagerBinder? = null
var registeredCallback: Handler.Callback? = null

val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val downloadBinder = service as? DownloadManagerBinder
if (downloadBinder == null) {
trySend(DownloadStatus.None)
appContext.unbindService(this)
close()
return
}
binder = downloadBinder
trySend(downloadBinder.getDownloadStatus(serviceId, url, false).toDownloadStatus())

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())
}
false
}
registeredCallback = callback
downloadBinder.addMissionEventListener(callback)
}

override fun onServiceDisconnected(name: ComponentName?) {
registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) }
binder = null
trySend(DownloadStatus.None)
}
}

val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
if (!bound) {
trySend(DownloadStatus.None)
close()
return@callbackFlow
}

awaitClose {
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
return withBinder(context) { binder ->
binder.getDownloadStatus(serviceId, url, true).toDownloadStatus()
}
}

suspend fun deleteFile(context: Context, serviceId: Int, url: String): Boolean {
if (serviceId < 0 || url.isBlank()) return false
return withBinder(context) { binder ->
binder.deleteFinishedMission(serviceId, url, true)
}
}

suspend fun removeLink(context: Context, serviceId: Int, url: String): Boolean {
if (serviceId < 0 || url.isBlank()) return false
return withBinder(context) { binder ->
binder.deleteFinishedMission(serviceId, url, false)
}
}

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)
return suspendCancellableCoroutine { continuation ->
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as? DownloadManagerBinder
if (binder == null) {
if (continuation.isActive) {
continuation.resumeWithException(IllegalStateException("Download service binder is null"))
}
appContext.unbindService(this)
return
}
try {
val result = block(binder)
if (continuation.isActive) {
continuation.resume(result)
}
} catch (throwable: Throwable) {
if (continuation.isActive) {
continuation.resumeWithException(throwable)
}
} finally {
appContext.unbindService(this)
}
}

override fun onServiceDisconnected(name: ComponentName?) {
if (continuation.isActive) {
continuation.resumeWithException(IllegalStateException("Download service disconnected"))
}
}
}

val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
if (!bound) {
continuation.resumeWithException(IllegalStateException("Unable to bind download service"))
return@suspendCancellableCoroutine
}

continuation.invokeOnCancellation {
runCatching { appContext.unbindService(connection) }
}
}
}

private fun Any?.matches(serviceId: Int, url: String): Boolean {
return when (this) {
is DownloadMission -> this.serviceId == serviceId && url == this.source
is FinishedMission -> this.serviceId == serviceId && url == this.source
else -> false
}
}

@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)
}
}
else -> DownloadStatus.None
}
}
}
126 changes: 126 additions & 0 deletions app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package org.schabi.newpipe.download.ui

import androidx.compose.foundation.layout.Column
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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.fragments.detail.DownloadUiState

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadStatusHost(
state: DownloadUiState,
onChipClick: () -> Unit,
onDismissSheet: () -> Unit,
onOpenFile: (CompletedDownload) -> Unit,
onDeleteFile: (CompletedDownload) -> Unit,
onRemoveLink: (CompletedDownload) -> Unit,
onShowInFolder: (CompletedDownload) -> Unit
) {
val chipState = state.chipState
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)

if (state.isSheetVisible && chipState is DownloadChipState.Downloaded) {
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) }
)
}
}

when (chipState) {
DownloadChipState.Hidden -> Unit
DownloadChipState.Downloading -> AssistChip(
onClick = onChipClick,
label = { Text(text = stringResource(id = R.string.download_status_downloading)) }
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would show the same CompletedDownload information for pending downloads too. Just maybe show a different background, like it's currently done in DownloadActivity (with the bar-stripes background), so users can distinguish pending downloads from finished downloads.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending chips now show the same name/quality details and apply the marquee stripe styling sourced from Utility’s color logic, matching the visual language used in DownloadActivity.

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

@Composable
private fun DownloadSheetContent(
info: CompletedDownload,
onOpenFile: () -> Unit,
onDeleteFile: () -> Unit,
onRemoveLink: () -> Unit,
onShowInFolder: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
val title = info.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))
}
}
if (subtitleParts.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = subtitleParts.joinToString(""),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}

Spacer(modifier = Modifier.height(12.dp))

val showFileActions = info.fileAvailable && info.fileUri != null
if (showFileActions) {
TextButton(onClick = onOpenFile) {
Text(text = stringResource(id = R.string.download_action_open))
}
TextButton(onClick = onShowInFolder, enabled = info.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))
}
}

TextButton(onClick = onRemoveLink) {
Text(text = stringResource(id = R.string.download_action_remove_link), color = MaterialTheme.colorScheme.error)
}

Spacer(modifier = Modifier.height(8.dp))
}
}
Loading