-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Track downloaded streams and surface status on video detail #12639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: refactor
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
||
| 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 | ||
| } | ||
| } | ||
| } | ||
| 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)) } | ||
| ) | ||
|
||
| 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)) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.