Merge 65ce28cbd797f4ff0899965c47f749a4c71d5af6 into 2e8e203276800f5f20e5d129d2db530770ad334a
This commit is contained in:
commit
2c060262da
@ -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,8 +1133,24 @@ public class DownloadDialog extends DialogFragment
|
||||
);
|
||||
}
|
||||
|
||||
DownloadManagerService.startMission(context, urls, storage, kind, threads,
|
||||
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
|
||||
final String qualityLabel =
|
||||
StreamLabelUtils.getQualityLabel(requireContext(), 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();
|
||||
|
||||
@ -0,0 +1,291 @@
|
||||
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
|
||||
|
||||
enum class DownloadStage {
|
||||
Pending,
|
||||
Running,
|
||||
Finished
|
||||
}
|
||||
|
||||
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 stage: DownloadStage
|
||||
)
|
||||
|
||||
object DownloadStatusRepository {
|
||||
|
||||
/**
|
||||
* 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(emptyList())
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val downloadBinder = service as? DownloadManagerBinder
|
||||
if (downloadBinder == null) {
|
||||
trySend(emptyList())
|
||||
appContext.unbindService(this)
|
||||
close()
|
||||
return
|
||||
}
|
||||
binder = downloadBinder
|
||||
// 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)) {
|
||||
// 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
|
||||
}
|
||||
registeredCallback = callback
|
||||
downloadBinder.addMissionEventListener(callback)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) }
|
||||
binder = null
|
||||
trySend(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
if (!bound) {
|
||||
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): List<DownloadEntry> {
|
||||
if (serviceId < 0 || url.isBlank()) return emptyList()
|
||||
return withBinder(context) { binder ->
|
||||
binder.getDownloadStatuses(serviceId, url, true).toDownloadEntries()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean {
|
||||
if (handle.serviceId < 0 || handle.url.isBlank()) return false
|
||||
return withBinder(context) { binder ->
|
||||
binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean {
|
||||
if (handle.serviceId < 0 || handle.url.isBlank()) return false
|
||||
return withBinder(context) { binder ->
|
||||
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?) {
|
||||
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 List<DownloadManager.DownloadStatusSnapshot>.toDownloadEntries(): List<DownloadEntry> {
|
||||
return buildList {
|
||||
for (snapshot in this@toDownloadEntries) {
|
||||
snapshot.toDownloadEntry()?.let { add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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?
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
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
|
||||
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.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.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, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DownloadStatusHost(
|
||||
state: DownloadUiState,
|
||||
onChipClick: (DownloadEntry) -> Unit,
|
||||
onDismissSheet: () -> Unit,
|
||||
onOpenFile: (DownloadEntry) -> Unit,
|
||||
onDeleteFile: (DownloadEntry) -> Unit,
|
||||
onRemoveLink: (DownloadEntry) -> Unit,
|
||||
onShowInFolder: (DownloadEntry) -> Unit
|
||||
) {
|
||||
val selected = state.selected
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
if (state.isSheetVisible && selected != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissSheet,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
DownloadSheetContent(
|
||||
entry = selected,
|
||||
onOpenFile = { onOpenFile(selected) },
|
||||
onDeleteFile = { onDeleteFile(selected) },
|
||||
onRemoveLink = { onRemoveLink(selected) },
|
||||
onShowInFolder = { onShowInFolder(selected) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
entry: DownloadEntry,
|
||||
onOpenFile: () -> Unit,
|
||||
onDeleteFile: () -> Unit,
|
||||
onRemoveLink: () -> Unit,
|
||||
onShowInFolder: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
val title = entry.displayName ?: stringResource(id = R.string.download)
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
val subtitleParts = buildList {
|
||||
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()) {
|
||||
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 = entry.fileAvailable && entry.fileUri != null
|
||||
if (showFileActions) {
|
||||
TextButton(onClick = onOpenFile) {
|
||||
Text(text = stringResource(id = R.string.open_with))
|
||||
}
|
||||
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.delete_file))
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
@ -37,6 +38,8 @@ import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
@ -44,6 +47,9 @@ import androidx.core.net.toUri
|
||||
import androidx.core.os.postDelayed
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import coil3.util.CoilUtils
|
||||
import com.evernote.android.state.State
|
||||
@ -56,11 +62,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
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.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
|
||||
import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar
|
||||
@ -99,6 +108,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
|
||||
import org.schabi.newpipe.player.ui.MainPlayerUi
|
||||
import org.schabi.newpipe.player.ui.VideoPlayerUi
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
@ -144,6 +154,7 @@ class VideoDetailFragment :
|
||||
// can't make this lateinit because it needs to be set to null when the view is destroyed
|
||||
private var nullableBinding: FragmentVideoDetailBinding? = null
|
||||
private val binding: FragmentVideoDetailBinding get() = nullableBinding!!
|
||||
private val downloadStatusViewModel: VideoDownloadStatusViewModel by viewModels()
|
||||
private lateinit var pageAdapter: TabAdapter
|
||||
private var settingsContentObserver: ContentObserver? = null
|
||||
|
||||
@ -270,6 +281,24 @@ class VideoDetailFragment :
|
||||
): View {
|
||||
val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false)
|
||||
nullableBinding = newBinding
|
||||
newBinding.detailDownloadStatusCompose?.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
AppTheme {
|
||||
val uiState = downloadStatusViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
val composeContext = LocalContext.current
|
||||
DownloadStatusHost(
|
||||
state = uiState,
|
||||
onChipClick = { entry -> downloadStatusViewModel.onChipSelected(entry) },
|
||||
onDismissSheet = { downloadStatusViewModel.dismissSheet() },
|
||||
onOpenFile = { entry -> openDownloaded(entry) },
|
||||
onDeleteFile = { entry -> deleteDownloadedFile(entry) },
|
||||
onRemoveLink = { entry -> removeDownloadLink(entry) },
|
||||
onShowInFolder = { entry -> showDownloadedInFolder(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBinding.getRoot()
|
||||
}
|
||||
|
||||
@ -1366,6 +1395,9 @@ class VideoDetailFragment :
|
||||
currentInfo = info
|
||||
setInitialData(info.serviceId, info.originalUrl, info.name, playQueue)
|
||||
|
||||
downloadStatusViewModel.dismissSheet()
|
||||
downloadStatusViewModel.setStream(requireContext().applicationContext, info.serviceId, info.url)
|
||||
|
||||
updateTabs(info)
|
||||
|
||||
binding.detailThumbnailPlayButton.animate(true, 200)
|
||||
@ -1544,6 +1576,74 @@ class VideoDetailFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDownloaded(entry: DownloadEntry) {
|
||||
val uri = entry.fileUri
|
||||
if (uri == null) {
|
||||
Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
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.missing_file, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDownloadedInFolder(entry: DownloadEntry) {
|
||||
val parent = entry.parentUri
|
||||
if (parent == null) {
|
||||
Toast.makeText(requireContext(), R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val context = requireContext()
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(parent, DocumentsContract.Document.MIME_TYPE_DIR)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
runCatching { startActivity(viewIntent) }
|
||||
.onFailure {
|
||||
val treeIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(DocumentsContract.EXTRA_INITIAL_URI, parent)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
runCatching { startActivity(treeIntent) }
|
||||
.onFailure { throwable ->
|
||||
if (DEBUG) Log.e(TAG, "Failed to open folder", throwable)
|
||||
Toast.makeText(context, R.string.invalid_directory, 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, 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(entry: DownloadEntry) {
|
||||
val appContext = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Stream Results
|
||||
////////////////////////////////////////////////////////////////////////// */
|
||||
@ -2271,6 +2371,7 @@ class VideoDetailFragment :
|
||||
|
||||
private const val MAX_OVERLAY_ALPHA = 0.9f
|
||||
private const val MAX_PLAYER_HEIGHT = 0.7f
|
||||
private val AVAILABILITY_CHECK_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5)
|
||||
|
||||
const val ACTION_SHOW_MAIN_PLAYER: String =
|
||||
App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
package org.schabi.newpipe.fragments.detail
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DownloadUiState())
|
||||
val uiState: StateFlow<DownloadUiState> = _uiState
|
||||
|
||||
private var observeJob: Job? = null
|
||||
private var currentServiceId: Int = -1
|
||||
private var currentUrl: String? = null
|
||||
|
||||
fun setStream(context: Context, serviceId: Int, url: String?) {
|
||||
val normalizedUrl = url ?: ""
|
||||
if (serviceId < 0 || normalizedUrl.isBlank()) {
|
||||
observeJob?.cancel()
|
||||
observeJob = null
|
||||
currentServiceId = -1
|
||||
currentUrl = null
|
||||
_uiState.value = DownloadUiState()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentServiceId == serviceId && currentUrl == normalizedUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
currentServiceId = serviceId
|
||||
currentUrl = normalizedUrl
|
||||
|
||||
val appContext = context.applicationContext
|
||||
|
||||
observeJob?.cancel()
|
||||
observeJob = viewModelScope.launch {
|
||||
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 onChipSelected(entry: DownloadEntry) {
|
||||
_uiState.update { it.copy(selected = entry) }
|
||||
}
|
||||
|
||||
fun dismissSheet() {
|
||||
_uiState.update { it.copy(selected = null) }
|
||||
}
|
||||
|
||||
suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean {
|
||||
val success = runCatching {
|
||||
DownloadStatusRepository.deleteFile(context.applicationContext, handle)
|
||||
}.getOrDefault(false)
|
||||
if (success) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
entries = state.entries.filterNot { it.handle == handle },
|
||||
selected = null
|
||||
)
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean {
|
||||
val success = runCatching {
|
||||
DownloadStatusRepository.removeLink(context.applicationContext, handle)
|
||||
}.getOrDefault(false)
|
||||
if (success) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
entries = state.entries.filterNot { it.handle == handle },
|
||||
selected = null
|
||||
)
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadUiState(
|
||||
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
|
||||
@ -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) {
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
@ -134,6 +134,10 @@ public class DownloadMission extends Mission {
|
||||
*/
|
||||
public MissionRecoveryInfo[] recoveryInfo;
|
||||
|
||||
public long streamUid = -1;
|
||||
public int serviceId = -1;
|
||||
public String qualityLabel = null;
|
||||
|
||||
private transient int finishCount;
|
||||
public transient volatile boolean running;
|
||||
public boolean enqueued;
|
||||
|
||||
@ -4,6 +4,10 @@ import androidx.annotation.NonNull;
|
||||
|
||||
public class FinishedMission extends Mission {
|
||||
|
||||
public int serviceId = -1;
|
||||
public long streamUid = -1;
|
||||
public String qualityLabel = null;
|
||||
|
||||
public FinishedMission() {
|
||||
}
|
||||
|
||||
@ -13,6 +17,9 @@ public class FinishedMission extends Mission {
|
||||
timestamp = mission.timestamp;
|
||||
kind = mission.kind;
|
||||
storage = mission.storage;
|
||||
serviceId = mission.serviceId;
|
||||
streamUid = mission.streamUid;
|
||||
qualityLabel = mission.qualityLabel;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
// TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?)
|
||||
private static final String DATABASE_NAME = "downloads.db";
|
||||
|
||||
private static final int DATABASE_VERSION = 4;
|
||||
private static final int DATABASE_VERSION = 5;
|
||||
|
||||
/**
|
||||
* The table name of download missions (old)
|
||||
@ -56,6 +56,12 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
|
||||
private static final String KEY_PATH = "path";
|
||||
|
||||
private static final String KEY_SERVICE_ID = "service_id";
|
||||
|
||||
private static final String KEY_STREAM_UID = "stream_uid";
|
||||
|
||||
private static final String KEY_QUALITY_LABEL = "quality_label";
|
||||
|
||||
/**
|
||||
* The statement to create the table
|
||||
*/
|
||||
@ -66,6 +72,9 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
KEY_DONE + " INTEGER NOT NULL, " +
|
||||
KEY_TIMESTAMP + " INTEGER NOT NULL, " +
|
||||
KEY_KIND + " TEXT NOT NULL, " +
|
||||
KEY_SERVICE_ID + " INTEGER NOT NULL DEFAULT -1, " +
|
||||
KEY_STREAM_UID + " INTEGER NOT NULL DEFAULT -1, " +
|
||||
KEY_QUALITY_LABEL + " TEXT, " +
|
||||
" UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));";
|
||||
|
||||
|
||||
@ -121,6 +130,17 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
|
||||
cursor.close();
|
||||
db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2);
|
||||
oldVersion++;
|
||||
}
|
||||
|
||||
if (oldVersion == 4) {
|
||||
db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN "
|
||||
+ KEY_SERVICE_ID + " INTEGER NOT NULL DEFAULT -1");
|
||||
db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN "
|
||||
+ KEY_STREAM_UID + " INTEGER NOT NULL DEFAULT -1");
|
||||
db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN "
|
||||
+ KEY_QUALITY_LABEL + " TEXT");
|
||||
oldVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,6 +157,17 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
values.put(KEY_DONE, downloadMission.length);
|
||||
values.put(KEY_TIMESTAMP, downloadMission.timestamp);
|
||||
values.put(KEY_KIND, String.valueOf(downloadMission.kind));
|
||||
if (downloadMission instanceof DownloadMission) {
|
||||
DownloadMission dm = (DownloadMission) downloadMission;
|
||||
values.put(KEY_SERVICE_ID, dm.serviceId);
|
||||
values.put(KEY_STREAM_UID, dm.streamUid);
|
||||
values.put(KEY_QUALITY_LABEL, dm.qualityLabel);
|
||||
} else if (downloadMission instanceof FinishedMission) {
|
||||
FinishedMission fm = (FinishedMission) downloadMission;
|
||||
values.put(KEY_SERVICE_ID, fm.serviceId);
|
||||
values.put(KEY_STREAM_UID, fm.streamUid);
|
||||
values.put(KEY_QUALITY_LABEL, fm.qualityLabel);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@ -152,6 +183,9 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE));
|
||||
mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP));
|
||||
mission.kind = kind.charAt(0);
|
||||
mission.serviceId = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_SERVICE_ID));
|
||||
mission.streamUid = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_STREAM_UID));
|
||||
mission.qualityLabel = cursor.getString(cursor.getColumnIndexOrThrow(KEY_QUALITY_LABEL));
|
||||
|
||||
try {
|
||||
mission.storage = new StoredFileHelper(context,null, Uri.parse(path), "");
|
||||
@ -200,11 +234,10 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts});
|
||||
} else {
|
||||
database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{
|
||||
ts, mission.storage.getUri().toString()
|
||||
});
|
||||
ts, mission.storage.getUri().toString()});
|
||||
}
|
||||
} else {
|
||||
throw new UnsupportedOperationException("DownloadMission");
|
||||
database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts});
|
||||
}
|
||||
}
|
||||
|
||||
@ -217,11 +250,11 @@ public class FinishedMissionStore extends SQLiteOpenHelper {
|
||||
|
||||
if (mission instanceof FinishedMission) {
|
||||
if (mission.storage.isInvalid()) {
|
||||
rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", new String[]{ts});
|
||||
rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?",
|
||||
new String[]{ts});
|
||||
} else {
|
||||
rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{
|
||||
mission.storage.getUri().toString()
|
||||
});
|
||||
rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?",
|
||||
new String[]{mission.storage.getUri().toString()});
|
||||
}
|
||||
} else {
|
||||
throw new UnsupportedOperationException("DownloadMission");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -14,6 +15,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import us.shandian.giga.get.DownloadMission;
|
||||
import us.shandian.giga.get.FinishedMission;
|
||||
@ -352,6 +354,16 @@ public class DownloadManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private DownloadMission getPendingMission(int serviceId, String url) {
|
||||
for (DownloadMission mission : mMissionsPending) {
|
||||
if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) {
|
||||
return mission;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index into {@link #mMissionsFinished} of a finished mission by its path, return
|
||||
* {@code -1} if there is no such mission. This function also checks if the matched mission's
|
||||
@ -361,6 +373,50 @@ public class DownloadManager {
|
||||
* @param storage where the file would be stored
|
||||
* @return the mission index or -1 if no such mission exists
|
||||
*/
|
||||
@Nullable
|
||||
private FinishedMission getFinishedMission(int serviceId, String url) {
|
||||
for (FinishedMission mission : mMissionsFinished) {
|
||||
if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) {
|
||||
return mission;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!mission.storage.existsAsFile()) {
|
||||
return false;
|
||||
}
|
||||
return mission.storage.length() > 0;
|
||||
}
|
||||
|
||||
private int getFinishedMissionIndex(StoredFileHelper storage) {
|
||||
for (int i = 0; i < mMissionsFinished.size(); i++) {
|
||||
if (mMissionsFinished.get(i).storage.equals(storage)) {
|
||||
@ -446,6 +502,79 @@ public class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DownloadStatusSnapshot {
|
||||
public final MissionState state;
|
||||
public final DownloadMission pendingMission;
|
||||
public final FinishedMission finishedMission;
|
||||
public final boolean fileExists;
|
||||
|
||||
DownloadStatusSnapshot(MissionState state, DownloadMission pendingMission,
|
||||
FinishedMission finishedMission, boolean fileExists) {
|
||||
this.state = state;
|
||||
this.pendingMission = pendingMission;
|
||||
this.finishedMission = finishedMission;
|
||||
this.fileExists = fileExists;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
result.add(new DownloadStatusSnapshot(MissionState.None, null, null, false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
boolean deleteFinishedMission(int serviceId, String url, boolean deleteFile) {
|
||||
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;
|
||||
}
|
||||
deleteMission(mission, deleteFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* runs one or multiple missions in from queue if possible
|
||||
*
|
||||
|
||||
@ -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;
|
||||
@ -80,6 +82,9 @@ public class DownloadManagerService extends Service {
|
||||
private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath";
|
||||
private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag";
|
||||
private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo";
|
||||
private static final String EXTRA_STREAM_UID = "DownloadManagerService.extra.streamUid";
|
||||
private static final String EXTRA_SERVICE_ID = "DownloadManagerService.extra.serviceId";
|
||||
private static final String EXTRA_QUALITY_LABEL = "DownloadManagerService.extra.qualityLabel";
|
||||
|
||||
private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished";
|
||||
private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished";
|
||||
@ -361,7 +366,8 @@ public class DownloadManagerService extends Service {
|
||||
public static void startMission(Context context, String[] urls, StoredFileHelper storage,
|
||||
char kind, int threads, String source, String psName,
|
||||
String[] psArgs, long nearLength,
|
||||
ArrayList<MissionRecoveryInfo> recoveryInfo) {
|
||||
ArrayList<MissionRecoveryInfo> recoveryInfo,
|
||||
long streamUid, int serviceId, String qualityLabel) {
|
||||
final Intent intent = new Intent(context, DownloadManagerService.class)
|
||||
.setAction(Intent.ACTION_RUN)
|
||||
.putExtra(EXTRA_URLS, urls)
|
||||
@ -374,7 +380,10 @@ public class DownloadManagerService extends Service {
|
||||
.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo)
|
||||
.putExtra(EXTRA_PARENT_PATH, storage.getParentUri())
|
||||
.putExtra(EXTRA_PATH, storage.getUri())
|
||||
.putExtra(EXTRA_STORAGE_TAG, storage.getTag());
|
||||
.putExtra(EXTRA_STORAGE_TAG, storage.getTag())
|
||||
.putExtra(EXTRA_STREAM_UID, streamUid)
|
||||
.putExtra(EXTRA_SERVICE_ID, serviceId)
|
||||
.putExtra(EXTRA_QUALITY_LABEL, qualityLabel);
|
||||
|
||||
context.startService(intent);
|
||||
}
|
||||
@ -390,6 +399,9 @@ public class DownloadManagerService extends Service {
|
||||
String source = intent.getStringExtra(EXTRA_SOURCE);
|
||||
long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0);
|
||||
String tag = intent.getStringExtra(EXTRA_STORAGE_TAG);
|
||||
long streamUid = intent.getLongExtra(EXTRA_STREAM_UID, -1L);
|
||||
int serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, -1);
|
||||
String qualityLabel = intent.getStringExtra(EXTRA_QUALITY_LABEL);
|
||||
final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO,
|
||||
MissionRecoveryInfo.class);
|
||||
Objects.requireNonNull(recovery);
|
||||
@ -412,6 +424,9 @@ public class DownloadManagerService extends Service {
|
||||
mission.source = source;
|
||||
mission.nearLength = nearLength;
|
||||
mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]);
|
||||
mission.streamUid = streamUid;
|
||||
mission.serviceId = serviceId;
|
||||
mission.qualityLabel = qualityLabel;
|
||||
|
||||
if (ps != null)
|
||||
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));
|
||||
@ -583,6 +598,21 @@ public class DownloadManagerService extends Service {
|
||||
mDownloadNotificationEnable = enable;
|
||||
}
|
||||
|
||||
public DownloadManager.DownloadStatusSnapshot getDownloadStatus(int serviceId, String source,
|
||||
boolean revalidateFile) {
|
||||
return mManager.getDownloadStatus(serviceId, source, revalidateFile);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -272,8 +272,9 @@
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
<LinearLayout
|
||||
android:id="@+id/detail_primary_control_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:gravity="center_vertical"
|
||||
@ -549,10 +550,21 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/detail_download_status_compose"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/detail_control_panel"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:visibility="visible" />
|
||||
|
||||
<View
|
||||
android:id="@+id/detail_meta_info_separator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:layout_below="@id/detail_download_status_compose"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="?attr/separator_color" />
|
||||
@ -561,6 +573,7 @@
|
||||
android:id="@+id/detail_meta_info_text_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/detail_meta_info_separator"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"
|
||||
android:textSize="@dimen/video_item_detail_description_text_size"
|
||||
@ -569,6 +582,7 @@
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:layout_below="@id/detail_meta_info_text_view"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="?attr/separator_color" />
|
||||
|
||||
@ -16,6 +16,18 @@
|
||||
<string name="share">Share</string>
|
||||
<string name="download">Download</string>
|
||||
<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_show_in_folder">Show in folder</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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user