mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80bb15f3fb | |||
| 27a86a67f0 | |||
| 284b2a24f8 | |||
| 854d1506a6 | |||
| 811fd4e73e | |||
| 335988aa67 | |||
| 29a54fbed4 | |||
| 3a11d0d9d1 | |||
| 894e400819 |
+1
-1
@@ -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";
|
||||
|
||||
+9
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Submodule app/src/stable/assets/sources/bilibili updated: 17d7aef314...b153339c93
Submodule app/src/stable/assets/sources/crunchyroll updated: 534bded369...a1714790c5
Submodule app/src/stable/assets/sources/curiositystream updated: f6eb2463f5...1ebf5da236
Submodule app/src/stable/assets/sources/odysee updated: 98a8df5a60...1c7a8a4974
Submodule app/src/stable/assets/sources/rumble updated: d24fc4cf8e...3b51471010
Submodule app/src/stable/assets/sources/tedtalks updated: b9528e44c5...292e459eef
Submodule app/src/stable/assets/sources/twitch updated: e4cdb5a32e...cebdad37a3
Submodule app/src/stable/assets/sources/youtube updated: ec5359ae16...5e903fa569
Submodule app/src/unstable/assets/sources/bilibili updated: 17d7aef314...b153339c93
Submodule app/src/unstable/assets/sources/crunchyroll updated: 534bded369...a1714790c5
Submodule app/src/unstable/assets/sources/curiositystream updated: f6eb2463f5...1ebf5da236
Submodule app/src/unstable/assets/sources/odysee updated: 98a8df5a60...1c7a8a4974
Submodule app/src/unstable/assets/sources/rumble updated: d24fc4cf8e...3b51471010
Submodule app/src/unstable/assets/sources/tedtalks updated: b9528e44c5...292e459eef
Submodule app/src/unstable/assets/sources/twitch updated: e4cdb5a32e...cebdad37a3
Submodule app/src/unstable/assets/sources/youtube updated: ec5359ae16...5e903fa569
Reference in New Issue
Block a user