Merge 65ce28cbd797f4ff0899965c47f749a4c71d5af6 into 2e8e203276800f5f20e5d129d2db530770ad334a

This commit is contained in:
Josh Mandel 2026-01-03 09:44:53 +01:00 committed by GitHub
commit 2c060262da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1011 additions and 32 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,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();

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@ -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" />

View File

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