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'
//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
exclude group: 'net.java.dev.jna'
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata
@@ -16,6 +17,7 @@ abstract class CastingDevice {
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract val onMediaItemEnd: Event0
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
@@ -2,12 +2,14 @@ package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.polycentric.core.Event
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
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.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
@@ -63,6 +67,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
@@ -92,12 +97,14 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
// TODO
}
override fun keyEvent(event: GenericKeyEvent) {
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: GenericMediaEvent) {
// Unreachable
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
@@ -127,6 +134,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
@@ -181,7 +190,8 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
metadata = metadata,
requestHeaders = null,
)
)
@@ -200,6 +210,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
@@ -227,6 +238,13 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
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)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
@@ -1,5 +1,6 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
@@ -181,6 +182,7 @@ class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override val onMediaItemEnd: Event0 = Event0()
override var connectionState: CastConnectionState
get() = inner.connectionState
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.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -82,6 +83,7 @@ abstract class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null
@@ -145,6 +147,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect()
}
@@ -159,6 +162,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null;
}
@@ -222,6 +226,9 @@ abstract class StateCasting {
device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
};
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try {
device.connect();
@@ -232,6 +239,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return;
}
@@ -1323,8 +1331,14 @@ abstract class StateCasting {
return emptyList()
}
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value
@@ -1348,16 +1362,20 @@ abstract class StateCasting {
throw Exception("Audio source without request executor not supported")
}
if (audioSource != null && audioSource.hasRequestExecutor) {
val oldExecutor = _audioExecutor;
oldExecutor?.closeAsync();
_audioExecutor = audioSource.getRequestExecutor()
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldVideoExecutor = _videoExecutor
oldVideoExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor()
}
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldExecutor = _videoExecutor;
oldExecutor?.closeAsync();
_videoExecutor = videoSource.getRequestExecutor()
if (audioSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_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
@@ -1388,7 +1406,7 @@ abstract class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw");
}
if (audioSource != null) {
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext ->
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.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
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.models.modifier.IRequestModifier
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.JSSource
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.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads
@@ -86,6 +91,9 @@ import kotlin.time.times
class VideoDownload {
var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -101,6 +109,7 @@ class VideoDownload {
var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual
@Transient
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
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)
throw IllegalStateException("Original content is not media?");
@@ -437,6 +446,11 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
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) {
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 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);
});
@@ -530,7 +548,7 @@ class VideoDownload {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
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);
});
@@ -589,38 +607,54 @@ class VideoDownload {
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
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 { _ ->
//TODO: Show progress?
//No callback
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
val session = FFmpegKit.executeAsync(
cmd,
{ completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled"
} 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))
}
},
{ Logger.v(TAG, it.message) },
{ log ->
Logger.v(TAG, log.message)
},
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdownNow()
}
}
}
@@ -856,14 +890,19 @@ class VideoDownload {
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())
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null;
try{
@@ -874,14 +913,27 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
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?)");
val foundTemplateUrl = foundTemplate.groupValues[1];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
if(foundCues.count() <= 0)
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)
source.getRequestExecutor();
else
@@ -896,13 +948,17 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf());
@@ -918,17 +974,60 @@ class VideoDownload {
speedTracker.addWork(data.size.toLong());
written += data.size;
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
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;
sourceLengthAudio = written2;
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) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
@@ -937,14 +1036,37 @@ class VideoDownload {
catch(ex: Throwable) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex;
}
finally {
fileStream.close();
fileStream2?.close();
executor?.closeAsync()
}
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
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 {
if(targetFile.exists())
targetFile.delete();
@@ -1304,7 +1426,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSourceToUse != null) {
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!);
@@ -1327,7 +1449,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id);
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) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1369,6 +1491,10 @@ class VideoDownload {
}
}
fun cleanup(){
cleanupPluginClient()
}
enum class State {
QUEUED,
PREPARING,
@@ -1392,6 +1518,8 @@ class VideoDownload {
const val GROUP_WATCHLATER= "WatchLater";
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);
fun videoContainerToExtension(container: String): String? {
@@ -1411,6 +1539,16 @@ class VideoDownload {
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 {
if (container.contains("audio/mp4"))
return "mp4a";
@@ -723,15 +723,17 @@ class VideoDetailView : ConstraintLayout {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
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) {
if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@@ -1273,6 +1275,7 @@ class VideoDetailView : ConstraintLayout {
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.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.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.getNowDiffMinutes
import com.futo.platformplayer.logging.Logger
@@ -169,6 +170,7 @@ class DownloadService : Service() {
Thread.sleep(500);
}
catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException)
Logger.e(TAG, "Download failed", ex);
if(currentVideo.video == null && currentVideo.videoDetails == null) {
//Corrupt?
@@ -179,6 +179,7 @@ class StateDownloads {
fun removeDownload(download: VideoDownload) {
download.isCancelled = true;
download.cleanup();
_downloading.delete(download);
onDownloadsChanged.emit();
}
@@ -11,6 +11,7 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.CoroutineScope
@@ -32,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
private val _videoState: TextView;
private val _videoCancel: TextView;
private val _videoRetry: TextView;
private val _scope: CoroutineScope;
@@ -51,13 +53,14 @@ class ActiveDownloadItem: LinearLayout {
_videoSpeed = findViewById(R.id.download_video_speed);
_videoCancel = findViewById(R.id.download_cancel);
_videoRetry = findViewById(R.id.download_retry);
_videoName.text = download.name;
_videoDuration.text = download.videoEither.duration.toHumanTime(false);
_videoAuthor.text = download.videoEither.author.name;
_videoState.setOnClickListener {
UIDialogs.toast(context, _videoState.text.toString(), false);
UIDialogs.appToast(_videoState.text.toString(), false);
}
Glide.with(_videoImage)
@@ -72,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
StateDownloads.instance.removeDownload(_download);
StateDownloads.instance.preventPlaylistDownload(_download);
};
_videoRetry.setOnClickListener {
download.changeState(VideoDownload.State.QUEUED);
DownloadService.getOrCreateService(context) {
}
}
_download.onProgressChanged.subscribe(this) {
_scope.launch(Dispatchers.Main) {
@@ -122,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
VideoDownload.State.DOWNLOADING -> {
_videoBar.visibility = VISIBLE;
_videoSpeed.visibility = VISIBLE;
_videoRetry.visibility = GONE;
};
VideoDownload.State.ERROR -> {
_videoState.setTextColor(Color.RED);
_videoState.text = _download.error ?: context.getString(R.string.error);
_videoBar.visibility = GONE;
_videoSpeed.visibility = GONE;
_videoRetry.visibility = VISIBLE;
}
else -> {
_videoBar.visibility = GONE;
_videoSpeed.visibility = GONE;
_videoRetry.visibility = GONE;
}
}
}
+28 -13
View File
@@ -118,6 +118,21 @@
android:ellipsize="end"
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
android:id="@+id/download_cancel"
android:layout_width="60dp"
@@ -130,20 +145,20 @@
android:background="@drawable/background_small_button"
android:textAlignment="center"
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>
<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
android:layout_height="wrap_content"