Compare commits

...

9 Commits

Author SHA1 Message Date
Koen J 80bb15f3fb Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-15 10:03:34 +01:00
Koen J 27a86a67f0 Updated submodules and fixed casting for combined request executor. 2025-12-15 10:03:18 +01:00
Koen 284b2a24f8 Merge branch 'marcus/casting-sdk-updates' into 'master'
casting: subscribe to and handle MediaItemEnd events

See merge request videostreaming/grayjay!158
2025-12-15 09:01:31 +00:00
Kelvin K 854d1506a6 Compile fix 2025-12-11 17:17:42 -06:00
Kelvin K 811fd4e73e Improved dl 2025-12-11 17:16:31 -06:00
Kelvin K 335988aa67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-11 14:16:07 -06:00
Kelvin K 29a54fbed4 Download support combined 2025-12-11 14:15:55 -06:00
Koen J 3a11d0d9d1 Fixed HLS downloading for Twitch, DialyMotion, Nebula. 2025-12-05 15:31:31 +01:00
Marcus Hanestad 894e400819 casting: subscribe to and handle MediaItemEnd events 2025-11-27 16:56:43 +01:00
27 changed files with 288 additions and 77 deletions
+1 -1
View File
@@ -232,7 +232,7 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK //Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') { implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
// Polycentricandroid includes this // Polycentricandroid includes this
exclude group: 'net.java.dev.jna' exclude group: 'net.java.dev.jna'
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
@@ -16,6 +17,7 @@ abstract class CastingDevice {
abstract val onDurationChanged: Event1<Double> abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double> abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double> abstract val onSpeedChanged: Event1<Double>
abstract val onMediaItemEnd: Event0
abstract var connectionState: CastConnectionState abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean abstract var isPlaying: Boolean
@@ -2,12 +2,14 @@ package com.futo.platformplayer.casting
import android.os.Build import android.os.Build
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.polycentric.core.Event
import org.fcast.sender_sdk.ApplicationInfo import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.GenericMediaEvent import org.fcast.sender_sdk.MediaEvent
import org.fcast.sender_sdk.PlaybackState import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source import org.fcast.sender_sdk.Source
import java.net.InetAddress import java.net.InetAddress
@@ -15,8 +17,10 @@ import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler; import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr import org.fcast.sender_sdk.urlFormatIpAddr
@@ -63,6 +67,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
var onDurationChanged = Event1<Double>() var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>() var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>() var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) { override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state) onConnectionStateChanged.emit(state)
@@ -92,12 +97,14 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
// TODO // TODO
} }
override fun keyEvent(event: GenericKeyEvent) { override fun keyEvent(event: KeyEvent) {
// Unreachable // Unreachable
} }
override fun mediaEvent(event: GenericMediaEvent) { override fun mediaEvent(event: MediaEvent) {
// Unreachable if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
} }
override fun playbackError(message: String) { override fun playbackError(message: String) {
@@ -127,6 +134,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
get() = eventHandler.onVolumeChanged get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double> override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged get() = eventHandler.onSpeedChanged
override val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
override fun resumePlayback() = device.resumePlayback() override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback() override fun pausePlayback() = device.pausePlayback()
@@ -181,7 +190,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
resumePosition = resumePosition, resumePosition = resumePosition,
speed = speed, speed = speed,
volume = volume, volume = volume,
metadata = metadata metadata = metadata,
requestHeaders = null,
) )
) )
@@ -200,6 +210,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
speed = speed, speed = speed,
volume = volume, volume = volume,
metadata = metadata, metadata = metadata,
requestHeaders = null,
) )
) )
@@ -227,6 +238,13 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
eventHandler.onConnectionStateChanged.subscribe { newState -> eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) { when (newState) {
is DeviceConnectionState.Connected -> { is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr) usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr) localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED connectionState = CastConnectionState.CONNECTED
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
@@ -181,6 +182,7 @@ class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override val onMediaItemEnd: Event0 = Event0()
override var connectionState: CastConnectionState override var connectionState: CastConnectionState
get() = inner.connectionState get() = inner.connectionState
set(_) = Unit set(_) = Unit
@@ -40,6 +40,7 @@ import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAud
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.awaitCancelConverted import com.futo.platformplayer.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
@@ -82,6 +83,7 @@ abstract class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>(); val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>(); val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>(); val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null; var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
@@ -145,6 +147,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect() ad.disconnect()
} }
@@ -159,6 +162,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null; activeDevice = null;
} }
@@ -222,6 +226,9 @@ abstract class StateCasting {
device.onTimeChanged.subscribe { device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try { try {
device.connect(); device.connect();
@@ -232,6 +239,7 @@ abstract class StateCasting {
device.onTimeChanged.clear(); device.onTimeChanged.clear();
device.onVolumeChanged.clear(); device.onVolumeChanged.clear();
device.onDurationChanged.clear(); device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return; return;
} }
@@ -1323,8 +1331,14 @@ abstract class StateCasting {
return emptyList() return emptyList()
} }
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) { for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) { dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) { if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value return@replace it.value
@@ -1348,16 +1362,20 @@ abstract class StateCasting {
throw Exception("Audio source without request executor not supported") throw Exception("Audio source without request executor not supported")
} }
if (audioSource != null && audioSource.hasRequestExecutor) { if (videoSource != null && videoSource.hasRequestExecutor) {
val oldExecutor = _audioExecutor; val oldVideoExecutor = _videoExecutor
oldExecutor?.closeAsync(); oldVideoExecutor?.closeAsync()
_audioExecutor = audioSource.getRequestExecutor() _videoExecutor = videoSource.getRequestExecutor()
} }
if (videoSource != null && videoSource.hasRequestExecutor) { if (audioSource != null) {
val oldExecutor = _videoExecutor; val oldExecutor = _audioExecutor
oldExecutor?.closeAsync(); oldExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor() _audioExecutor = audioSource.getRequestExecutor()
} else if (hasAudioInDash && videoSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = _videoExecutor
} }
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also //TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
@@ -1388,7 +1406,7 @@ abstract class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true }.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw"); ).withTag("castDashRaw");
} }
if (audioSource != null) { if (audioSource != null || (audioSource == null && hasAudioInDash)) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext -> HttpFunctionHandler("GET", audioPath) { httpContext ->
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
@@ -9,7 +9,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
@@ -40,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
@@ -86,6 +91,9 @@ import kotlin.time.times
class VideoDownload { class VideoDownload {
var state: State = State.QUEUED; var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null; var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null; var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -101,6 +109,7 @@ class VideoDownload {
var videoSource: VideoUrlSource?; var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?; var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual @Contextual
@Transient @Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?; val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@@ -270,7 +279,7 @@ class VideoDownload {
//Fetch full video object and determine source //Fetch full video object and determine source
if(video != null && videoDetails == null) { if(video != null && videoDetails == null) {
val original = StatePlatform.instance.getContentDetails(video!!.url).await(); val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
if(original !is IPlatformVideoDetails) if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?"); throw IllegalStateException("Original content is not media?");
@@ -437,6 +446,11 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName(); videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container); videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath; videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
} }
if(actualAudioSource != null) { if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName(); audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
@@ -490,7 +504,11 @@ class VideoDownload {
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
} }
else if(actualVideoSource is JSDashManifestRawSource) { else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback); if(actualAudioSource == null)
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
File(downloadDir, audioFileName!!));
else
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
} }
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name); else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
}); });
@@ -530,7 +548,7 @@ class VideoDownload {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
} }
else if(actualAudioSource is JSDashManifestRawAudioSource) { else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback); audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
} }
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name); else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
}); });
@@ -589,38 +607,54 @@ class VideoDownload {
} }
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) suspendCancellableCoroutine { continuation ->
val concatInput = buildString {
append("concat:")
append(
segmentFiles.joinToString("|") { file ->
file.absolutePath
}
)
}
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //No callback
} }
val executorService = Executors.newSingleThreadExecutor() val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session -> val session = FFmpegKit.executeAsync(
if (ReturnCode.isSuccess(session.returnCode)) { cmd,
fileList.delete() { completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled" "Command cancelled"
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" "Command failed with state '${completedSession.state}' " +
"and return code ${completedSession.returnCode}, " +
"stack trace ${completedSession.failStackTrace}"
} }
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
{ Logger.v(TAG, it.message) }, { log ->
Logger.v(TAG, log.message)
},
statisticsCallback, statisticsCallback,
executorService executorService
) )
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
session.cancel() session.cancel()
executorService.shutdownNow()
} }
} }
} }
@@ -856,14 +890,19 @@ class VideoDownload {
return downloadedTotalLength return downloadedTotalLength
} }
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile(); targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?; val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile); val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null; var executor: JSRequestExecutor? = null;
try{ try{
@@ -874,14 +913,27 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation"); throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash //TODO: Temporary naive assume single-sourced dash
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest); val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3) val foundTemplate = when(downloadType) {
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
}
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)"); throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
val foundTemplateUrl = foundTemplate.groupValues[1]; val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]); val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
if(foundCues.count() <= 0) if(foundCues.count() <= 0)
throw IllegalStateException("No Cues found in manifest (unsupported dash?)"); throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
val foundCues2Downloaded = hashSetOf<MatchResult>();
if(foundTemplate2 != null)
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
executor = if(source is JSSource && source.hasRequestExecutor) executor = if(source is JSSource && source.hasRequestExecutor)
source.getRequestExecutor(); source.getRequestExecutor();
else else
@@ -896,13 +948,17 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString()); Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0; var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0; var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0); onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) { for(cue in foundCues) {
val t = cue.groupValues[1]; val t = cue.groupValues[1];
val d = cue.groupValues[2]; val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString()); val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf()); val modified = modifier?.modifyRequest(url, mapOf());
@@ -918,17 +974,60 @@ class VideoDownload {
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written += data.size; written += data.size;
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed); onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
indexCounter++; indexCounter++;
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
val toDownload = if(lastCue != null && cue == lastCue)
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
for(cue2 in toDownload) {
val index2 = foundCues2.indexOf(cue2);
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
indexCounter2++;
foundCues2Downloaded.add(cue2);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
}
}
} }
sourceLength = written; sourceLength = written;
sourceLengthAudio = written2;
Logger.i(TAG, "$name downloadSource Finished"); Logger.i(TAG, "$name downloadSource Finished");
} }
catch(scriptEx: ScriptReloadRequiredException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
createNewPluginClient();
throw scriptEx;
}
catch(ioex: IOException) { catch(ioex: IOException) {
if(targetFile.exists() ?: false) if(targetFile.exists() ?: false)
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false) if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex); throw Exception("Not enough space on device", ioex);
else else
@@ -937,14 +1036,37 @@ class VideoDownload {
catch(ex: Throwable) { catch(ex: Throwable) {
if(targetFile.exists() ?: false) if(targetFile.exists() ?: false)
targetFile.delete(); targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex; throw ex;
} }
finally { finally {
fileStream.close(); fileStream.close();
fileStream2?.close();
executor?.closeAsync() executor?.closeAsync()
} }
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
return sourceLength!!; return sourceLength!!;
} }
fun createNewPluginClient() {
UIDialogs.appToast("Download creating new client at request of plugin");
cleanupPluginClient();
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
plugin?.initialize();
}
fun cleanupPluginClient() {
val oldPlugin = plugin;
plugin = null;
try {
oldPlugin?.disable();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@@ -1304,7 +1426,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}"); throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
} }
} }
if(audioSourceToUse != null) { if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioFilePath == null) if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download"); throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!); val expectedFile = File(audioFilePath!!);
@@ -1327,7 +1449,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]"); Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id); val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) }; val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) }; val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) }; val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource) if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1369,6 +1491,10 @@ class VideoDownload {
} }
} }
fun cleanup(){
cleanupPluginClient()
}
enum class State { enum class State {
QUEUED, QUEUED,
PREPARING, PREPARING,
@@ -1392,6 +1518,8 @@ class VideoDownload {
const val GROUP_WATCHLATER= "WatchLater"; const val GROUP_WATCHLATER= "WatchLater";
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL); val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL); val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? { fun videoContainerToExtension(container: String): String? {
@@ -1411,6 +1539,16 @@ class VideoDownload {
return "video";//throw IllegalStateException("Unknown container: " + container) return "video";//throw IllegalStateException("Unknown container: " + container)
} }
//TODO: Change usages of this to an accurate container instead of infering it.
fun videoAudioContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4a";
else if (container.contains("video/webm"))
return "webm";
else
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "mp4a";
@@ -723,15 +723,17 @@ class VideoDetailView : ConstraintLayout {
val activeDevice = StateCasting.instance.activeDevice; val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) { if (activeDevice != null) {
handlePlayChanged(it); handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
} }
}; };
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) { StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
if (_isCasting) { if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true); setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@@ -1273,6 +1275,7 @@ class VideoDetailView : ConstraintLayout {
StateCasting.instance.onActiveDevicePlayChanged.remove(this); StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
StateApp.instance.preventPictureInPicture.remove(this); StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this); StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this); StatePlayer.instance.onVideoChanging.remove(this);
@@ -15,6 +15,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.getNowDiffMinutes import com.futo.platformplayer.getNowDiffMinutes
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -169,6 +170,7 @@ class DownloadService : Service() {
Thread.sleep(500); Thread.sleep(500);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException)
Logger.e(TAG, "Download failed", ex); Logger.e(TAG, "Download failed", ex);
if(currentVideo.video == null && currentVideo.videoDetails == null) { if(currentVideo.video == null && currentVideo.videoDetails == null) {
//Corrupt? //Corrupt?
@@ -179,6 +179,7 @@ class StateDownloads {
fun removeDownload(download: VideoDownload) { fun removeDownload(download: VideoDownload) {
download.isCancelled = true; download.isCancelled = true;
download.cleanup();
_downloading.delete(download); _downloading.delete(download);
onDownloadsChanged.emit(); onDownloadsChanged.emit();
} }
@@ -11,6 +11,7 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -32,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
private val _videoState: TextView; private val _videoState: TextView;
private val _videoCancel: TextView; private val _videoCancel: TextView;
private val _videoRetry: TextView;
private val _scope: CoroutineScope; private val _scope: CoroutineScope;
@@ -51,13 +53,14 @@ class ActiveDownloadItem: LinearLayout {
_videoSpeed = findViewById(R.id.download_video_speed); _videoSpeed = findViewById(R.id.download_video_speed);
_videoCancel = findViewById(R.id.download_cancel); _videoCancel = findViewById(R.id.download_cancel);
_videoRetry = findViewById(R.id.download_retry);
_videoName.text = download.name; _videoName.text = download.name;
_videoDuration.text = download.videoEither.duration.toHumanTime(false); _videoDuration.text = download.videoEither.duration.toHumanTime(false);
_videoAuthor.text = download.videoEither.author.name; _videoAuthor.text = download.videoEither.author.name;
_videoState.setOnClickListener { _videoState.setOnClickListener {
UIDialogs.toast(context, _videoState.text.toString(), false); UIDialogs.appToast(_videoState.text.toString(), false);
} }
Glide.with(_videoImage) Glide.with(_videoImage)
@@ -72,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
StateDownloads.instance.removeDownload(_download); StateDownloads.instance.removeDownload(_download);
StateDownloads.instance.preventPlaylistDownload(_download); StateDownloads.instance.preventPlaylistDownload(_download);
}; };
_videoRetry.setOnClickListener {
download.changeState(VideoDownload.State.QUEUED);
DownloadService.getOrCreateService(context) {
}
}
_download.onProgressChanged.subscribe(this) { _download.onProgressChanged.subscribe(this) {
_scope.launch(Dispatchers.Main) { _scope.launch(Dispatchers.Main) {
@@ -122,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
VideoDownload.State.DOWNLOADING -> { VideoDownload.State.DOWNLOADING -> {
_videoBar.visibility = VISIBLE; _videoBar.visibility = VISIBLE;
_videoSpeed.visibility = VISIBLE; _videoSpeed.visibility = VISIBLE;
_videoRetry.visibility = GONE;
}; };
VideoDownload.State.ERROR -> { VideoDownload.State.ERROR -> {
_videoState.setTextColor(Color.RED); _videoState.setTextColor(Color.RED);
_videoState.text = _download.error ?: context.getString(R.string.error); _videoState.text = _download.error ?: context.getString(R.string.error);
_videoBar.visibility = GONE; _videoBar.visibility = GONE;
_videoSpeed.visibility = GONE; _videoSpeed.visibility = GONE;
_videoRetry.visibility = VISIBLE;
} }
else -> { else -> {
_videoBar.visibility = GONE; _videoBar.visibility = GONE;
_videoSpeed.visibility = GONE; _videoSpeed.visibility = GONE;
_videoRetry.visibility = GONE;
} }
} }
} }
+28 -13
View File
@@ -118,6 +118,21 @@
android:ellipsize="end" android:ellipsize="end"
android:layout_marginEnd="10dp" /> android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/downloaded_author"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:gravity="center_vertical"
android:textSize="9dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
app:layout_constraintTop_toBottomOf="@id/downloaded_video_name"
app:layout_constraintLeft_toLeftOf="parent"
tools:text="ShortCircuit"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginStart="10dp" />
<TextView <TextView
android:id="@+id/download_cancel" android:id="@+id/download_cancel"
android:layout_width="60dp" android:layout_width="60dp"
@@ -130,20 +145,20 @@
android:background="@drawable/background_small_button" android:background="@drawable/background_small_button"
android:textAlignment="center" android:textAlignment="center"
android:text="@string/cancel" /> android:text="@string/cancel" />
<TextView
android:id="@+id/download_retry"
android:layout_width="60dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:padding="2dp"
android:visibility="gone"
app:layout_constraintRight_toRightOf="@id/download_cancel"
app:layout_constraintTop_toBottomOf="@id/download_cancel"
android:textSize="10dp"
android:background="@drawable/background_small_button"
android:textAlignment="center"
android:text="@string/retry" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/downloaded_author"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:gravity="center_vertical"
android:textSize="9dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="ShortCircuit"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginStart="10dp" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_height="wrap_content" android:layout_height="wrap_content"