diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 0857fa33935..a786e1bbf0e 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -70,6 +70,7 @@ 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 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { ); } - DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); + final String qualityLabel = + 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(); diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt new file mode 100644 index 00000000000..8ef710b31a9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadStatusRepository.kt @@ -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> = 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 { + 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 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.toDownloadEntries(): List { + 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? + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt new file mode 100644 index 00000000000..4481dd9dd3a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/ui/DownloadStatusUi.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 279f5150a84..1ac996361bb 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -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 @@ -143,6 +153,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 @@ -269,6 +280,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 ////////////////////////////////////////////////////////////////////////// */ @@ -2270,6 +2370,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" diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt new file mode 100644 index 00000000000..3516c964828 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDownloadStatusViewModel.kt @@ -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 = _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 = 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 diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 2eeb14b1b41..7068304a703 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -134,7 +134,6 @@ private View getCustomView(final int position, if (stream instanceof VideoStream) { final VideoStream videoStream = ((VideoStream) stream); - qualityString = videoStream.getResolution(); if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { if (videoStream.isVideoOnly()) { @@ -149,24 +148,13 @@ private View getCustomView(final int position, 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) { diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt b/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt new file mode 100644 index 00000000000..908c64cae2c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StreamLabelUtils.kt @@ -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() } + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 04930b002de..726a5510243 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -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; diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java index 29f3c62968d..94bf508a69a 100644 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -4,6 +4,10 @@ public class FinishedMission extends Mission { + public int serviceId = -1; + public long streamUid = -1; + public String qualityLabel = null; + public FinishedMission() { } @@ -13,6 +17,9 @@ public FinishedMission(@NonNull DownloadMission mission) { timestamp = mission.timestamp; kind = mission.kind; storage = mission.storage; + serviceId = mission.serviceId; + streamUid = mission.streamUid; + qualityLabel = mission.qualityLabel; } } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java index 704385212ab..c535d687de2 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -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 void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 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 @@ private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { 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 @@ private FinishedMission getMissionFromCursor(Cursor cursor) { 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 void deleteMission(Mission mission) { 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 void updateMission(Mission mission) { 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"); diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index d02f77bc1ab..54858193722 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -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.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; @@ -333,6 +335,16 @@ private DownloadMission getPendingMission(StoredFileHelper storage) { 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 @@ -342,6 +354,50 @@ private DownloadMission getPendingMission(StoredFileHelper storage) { * @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)) { @@ -427,6 +483,79 @@ void setFinished(DownloadMission mission) { } } + 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 snapshots = getDownloadStatuses(serviceId, url, revalidateFile); + if (snapshots.isEmpty()) { + return new DownloadStatusSnapshot(MissionState.None, null, null, false); + } + return snapshots.get(0); + } + + List getDownloadStatuses(int serviceId, String url, boolean revalidateFile) { + List 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 * diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 45211211f40..3826455f34d 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -38,6 +38,8 @@ 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 void updateForegroundState(boolean state) { public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, String source, String psName, String[] psArgs, long nearLength, - ArrayList recoveryInfo) { + ArrayList 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 static void startMission(Context context, String[] urls, StoredFileHelper .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 @@ private void startMission(Intent intent) { 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 @@ private void startMission(Intent intent) { 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 void enableNotifications(boolean enable) { mDownloadNotificationEnable = enable; } + public DownloadManager.DownloadStatusSnapshot getDownloadStatus(int serviceId, String source, + boolean revalidateFile) { + return mManager.getDownloadStatus(serviceId, source, revalidateFile); + } + + public List 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); + } + } } diff --git a/app/src/main/res/layout/fragment_video_detail.xml b/app/src/main/res/layout/fragment_video_detail.xml index 1a4711581e2..24e167a5e18 100644 --- a/app/src/main/res/layout/fragment_video_detail.xml +++ b/app/src/main/res/layout/fragment_video_detail.xml @@ -273,8 +273,9 @@ - + + @@ -562,6 +574,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" @@ -570,6 +583,7 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c439f19e272..05f2abacf9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,18 @@ Share Download Download stream file + Downloaded • %1$s + Downloaded + Downloaded %1$s + Downloading %1$s + Pending %1$s + Audio + Video + Captions + Media + Downloading… + Previously downloaded – file missing + Show in folder Search Search %1$s Search %1$s (%2$s)