mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-05-30 13:30:22 +02:00
Merge branch 'hls-download' into 'master'
HLS download implementation See merge request videostreaming/grayjay!6
This commit is contained in:
commit
3d2840fe15
@ -12,18 +12,25 @@ import android.widget.TextView
|
|||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
|
import com.futo.platformplayer.casting.StateCasting
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
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.models.Playlist
|
import com.futo.platformplayer.models.Playlist
|
||||||
import com.futo.platformplayer.models.Subscription
|
import com.futo.platformplayer.models.Subscription
|
||||||
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.states.*
|
import com.futo.platformplayer.states.*
|
||||||
import com.futo.platformplayer.views.Loader
|
import com.futo.platformplayer.views.Loader
|
||||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||||
@ -33,10 +40,12 @@ import com.futo.platformplayer.views.pills.RoundButton
|
|||||||
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
import com.futo.platformplayer.views.pills.RoundButtonGroup
|
||||||
import com.futo.platformplayer.views.overlays.slideup.*
|
import com.futo.platformplayer.views.overlays.slideup.*
|
||||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||||
|
import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.internal.notifyAll
|
||||||
import java.lang.IllegalStateException
|
import java.lang.IllegalStateException
|
||||||
|
|
||||||
class UISlideOverlays {
|
class UISlideOverlays {
|
||||||
@ -127,6 +136,101 @@ class UISlideOverlays {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||||
|
val items = arrayListOf<View>(Loader(container.context))
|
||||||
|
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||||
|
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
|
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
|
||||||
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
|
?: throw Exception("Master playlist content is empty")
|
||||||
|
|
||||||
|
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||||
|
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||||
|
//TODO: Implement subtitles
|
||||||
|
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
|
||||||
|
|
||||||
|
var selectedVideoVariant: HLSVariantVideoUrlSource? = null
|
||||||
|
var selectedAudioVariant: HLSVariantAudioUrlSource? = null
|
||||||
|
//TODO: Implement subtitles
|
||||||
|
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
|
||||||
|
|
||||||
|
val masterPlaylist: HLS.MasterPlaylist
|
||||||
|
try {
|
||||||
|
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||||
|
|
||||||
|
masterPlaylist.getAudioSources().forEach { it ->
|
||||||
|
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||||
|
selectedAudioVariant = it
|
||||||
|
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||||
|
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||||
|
}, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||||
|
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||||
|
selectedSubtitleVariant = it
|
||||||
|
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||||
|
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||||
|
}, false))
|
||||||
|
}*/
|
||||||
|
|
||||||
|
masterPlaylist.getVideoSources().forEach {
|
||||||
|
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||||
|
selectedVideoVariant = it
|
||||||
|
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||||
|
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||||
|
}, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
val newItems = arrayListOf<View>()
|
||||||
|
if (videoButtons.isNotEmpty()) {
|
||||||
|
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoButtons, videoButtons))
|
||||||
|
}
|
||||||
|
if (audioButtons.isNotEmpty()) {
|
||||||
|
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioButtons, audioButtons))
|
||||||
|
}
|
||||||
|
//TODO: Implement subtitles
|
||||||
|
/*if (subtitleButtons.isNotEmpty()) {
|
||||||
|
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleButtons, subtitleButtons))
|
||||||
|
}*/
|
||||||
|
|
||||||
|
slideUpMenuOverlay.onOK.subscribe {
|
||||||
|
//TODO: Fix SubtitleRawSource issue
|
||||||
|
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
|
||||||
|
slideUpMenuOverlay.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
slideUpMenuOverlay.setItems(newItems)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (source is IHLSManifestSource) {
|
||||||
|
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
|
||||||
|
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||||
|
slideUpMenuOverlay.hide()
|
||||||
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
|
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
|
||||||
|
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||||
|
slideUpMenuOverlay.hide()
|
||||||
|
} else {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slideUpMenuOverlay.apply { show() }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
|
||||||
val items = arrayListOf<View>();
|
val items = arrayListOf<View>();
|
||||||
var menu: SlideUpMenuOverlay? = null;
|
var menu: SlideUpMenuOverlay? = null;
|
||||||
@ -166,30 +270,49 @@ class UISlideOverlays {
|
|||||||
videoSources
|
videoSources
|
||||||
.filter { it.isDownloadable() }
|
.filter { it.isDownloadable() }
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
if (it is IVideoUrlSource) {
|
||||||
selectedVideo = it as IVideoUrlSource;
|
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
|
||||||
menu?.selectOption(videoSources, it);
|
selectedVideo = it
|
||||||
if(selectedAudio != null || !requiresAudio)
|
menu?.selectOption(videoSources, it);
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
if(selectedAudio != null || !requiresAudio)
|
||||||
}, false)
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
|
}, false)
|
||||||
|
} else if (it is IHLSManifestSource) {
|
||||||
|
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
|
||||||
|
showHlsPicker(video, it, it.url, container)
|
||||||
|
}, false)
|
||||||
|
} else {
|
||||||
|
throw Exception("Unhandled source type")
|
||||||
|
}
|
||||||
}).flatten().toList()
|
}).flatten().toList()
|
||||||
));
|
));
|
||||||
|
|
||||||
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
|
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0) {
|
||||||
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
|
//TODO: Add HLS support here
|
||||||
|
selectedVideo = VideoHelper.selectBestVideoSource(
|
||||||
|
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
|
||||||
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
Settings.instance.downloads.getDefaultVideoQualityPixels(),
|
||||||
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
|
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
|
||||||
|
) as IVideoUrlSource;
|
||||||
|
}
|
||||||
|
|
||||||
audioSources?.let { audioSources ->
|
audioSources?.let { audioSources ->
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
|
||||||
.filter { VideoHelper.isDownloadable(it) }
|
.filter { VideoHelper.isDownloadable(it) }
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
if (it is IAudioUrlSource) {
|
||||||
selectedAudio = it as IAudioUrlSource;
|
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||||
menu?.selectOption(audioSources, it);
|
selectedAudio = it
|
||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.selectOption(audioSources, it);
|
||||||
}, false);
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
|
}, false);
|
||||||
|
} else if (it is IHLSManifestAudioSource) {
|
||||||
|
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
|
||||||
|
showHlsPicker(video, it, it.url, container)
|
||||||
|
}, false)
|
||||||
|
} else {
|
||||||
|
throw Exception("Unhandled source type")
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
val asources = audioSources;
|
val asources = audioSources;
|
||||||
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
|
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
|
||||||
@ -198,15 +321,15 @@ class UISlideOverlays {
|
|||||||
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
|
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
|
||||||
menu?.selectOption(asources, preferredAudioSource);
|
menu?.selectOption(asources, preferredAudioSource);
|
||||||
|
|
||||||
|
//TODO: Add HLS support here
|
||||||
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
|
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
|
||||||
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
|
||||||
Settings.instance.playback.getPrimaryLanguage(container.context),
|
Settings.instance.playback.getPrimaryLanguage(container.context),
|
||||||
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
|
||||||
}
|
}
|
||||||
|
|
||||||
//ContentResolver is required for subtitles..
|
//ContentResolver is required for subtitles..
|
||||||
if(contentResolver != null) {
|
if(contentResolver != null && subtitleSources.isNotEmpty()) {
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
|
||||||
.map {
|
.map {
|
||||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||||
|
@ -0,0 +1,51 @@
|
|||||||
|
package com.futo.platformplayer.api.media.models.streams.sources
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||||
|
|
||||||
|
class HLSVariantVideoUrlSource(
|
||||||
|
override val name: String,
|
||||||
|
override val width: Int,
|
||||||
|
override val height: Int,
|
||||||
|
override val container: String,
|
||||||
|
override val codec: String,
|
||||||
|
override val bitrate: Int?,
|
||||||
|
override val duration: Long,
|
||||||
|
override val priority: Boolean,
|
||||||
|
val url: String
|
||||||
|
) : IVideoUrlSource {
|
||||||
|
override fun getVideoUrl(): String {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HLSVariantAudioUrlSource(
|
||||||
|
override val name: String,
|
||||||
|
override val bitrate: Int,
|
||||||
|
override val container: String,
|
||||||
|
override val codec: String,
|
||||||
|
override val language: String,
|
||||||
|
override val duration: Long?,
|
||||||
|
override val priority: Boolean,
|
||||||
|
val url: String
|
||||||
|
) : IAudioUrlSource {
|
||||||
|
override fun getAudioUrl(): String {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HLSVariantSubtitleUrlSource(
|
||||||
|
override val name: String,
|
||||||
|
override val url: String,
|
||||||
|
override val format: String,
|
||||||
|
) : ISubtitleSource {
|
||||||
|
override val hasFetch: Boolean = false
|
||||||
|
|
||||||
|
override fun getSubtitles(): String? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSubtitlesURI(): Uri? {
|
||||||
|
return Uri.parse(url)
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,17 @@
|
|||||||
package com.futo.platformplayer.downloads
|
package com.futo.platformplayer.downloads
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
|
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateDownloads
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
|
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@ -18,22 +24,28 @@ import com.futo.platformplayer.hasAnySource
|
|||||||
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.isDownloadable
|
import com.futo.platformplayer.isDownloadable
|
||||||
|
import com.futo.platformplayer.parsers.HLS
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
|
||||||
import com.futo.platformplayer.toHumanBitrate
|
import com.futo.platformplayer.toHumanBitrate
|
||||||
import com.futo.platformplayer.toHumanBytesSpeed
|
import com.futo.platformplayer.toHumanBytesSpeed
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ForkJoinPool
|
import java.util.concurrent.ForkJoinPool
|
||||||
import java.util.concurrent.ForkJoinTask
|
import java.util.concurrent.ForkJoinTask
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
class VideoDownload {
|
class VideoDownload {
|
||||||
@ -137,7 +149,7 @@ class VideoDownload {
|
|||||||
return items.joinToString(" • ");
|
return items.joinToString(" • ");
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun prepare() {
|
suspend fun prepare(client: ManagedHttpClient) {
|
||||||
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
||||||
if(video == null && videoDetails == null)
|
if(video == null && videoDetails == null)
|
||||||
throw IllegalStateException("Missing information for download to complete");
|
throw IllegalStateException("Missing information for download to complete");
|
||||||
@ -157,24 +169,65 @@ class VideoDownload {
|
|||||||
|
|
||||||
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
|
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
|
||||||
if(videoSource == null && targetPixelCount != null) {
|
if(videoSource == null && targetPixelCount != null) {
|
||||||
val vsource = VideoHelper.selectBestVideoSource(videoDetails!!.video, targetPixelCount!!.toInt(), arrayOf())
|
val videoSources = arrayListOf<IVideoSource>()
|
||||||
|
for (source in original.video.videoSources) {
|
||||||
|
if (source is IHLSManifestSource) {
|
||||||
|
try {
|
||||||
|
val playlistResponse = client.get(source.url)
|
||||||
|
if (playlistResponse.isOk) {
|
||||||
|
val playlistContent = playlistResponse.body?.string()
|
||||||
|
if (playlistContent != null) {
|
||||||
|
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.i(TAG, "Failed to get HLS video sources", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoSources.add(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
|
||||||
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
// ?: throw IllegalStateException("Could not find a valid video source for video");
|
||||||
if(vsource != null) {
|
if(vsource != null) {
|
||||||
if (vsource is IVideoUrlSource)
|
if (vsource is IVideoUrlSource)
|
||||||
videoSource = VideoUrlSource.fromUrlSource(vsource);
|
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||||
else
|
else
|
||||||
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
throw DownloadException("Video source is not supported for downloading (yet)", false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(audioSource == null && targetBitrate != null) {
|
if(audioSource == null && targetBitrate != null) {
|
||||||
val asource = VideoHelper.selectBestAudioSource(videoDetails!!.video, arrayOf(), null, targetPixelCount)
|
val audioSources = arrayListOf<IAudioSource>()
|
||||||
|
val video = original.video
|
||||||
|
if (video is VideoUnMuxedSourceDescriptor) {
|
||||||
|
for (source in video.audioSources) {
|
||||||
|
if (source is IHLSManifestSource) {
|
||||||
|
try {
|
||||||
|
val playlistResponse = client.get(source.url)
|
||||||
|
if (playlistResponse.isOk) {
|
||||||
|
val playlistContent = playlistResponse.body?.string()
|
||||||
|
if (playlistContent != null) {
|
||||||
|
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.i(TAG, "Failed to get HLS audio sources", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audioSources.add(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
|
||||||
?: if(videoSource != null ) null
|
?: if(videoSource != null ) null
|
||||||
else throw DownloadException("Could not find a valid video or audio source for download")
|
else throw DownloadException("Could not find a valid video or audio source for download")
|
||||||
if(asource == null)
|
if(asource == null)
|
||||||
audioSource = null;
|
audioSource = null;
|
||||||
else if(asource is IAudioUrlSource)
|
else if(asource is IAudioUrlSource)
|
||||||
audioSource = AudioUrlSource.fromUrlSource(asource);
|
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||||
else
|
else
|
||||||
throw DownloadException("Audio source is not supported for downloading (yet)", false);
|
throw DownloadException("Audio source is not supported for downloading (yet)", false);
|
||||||
}
|
}
|
||||||
@ -183,7 +236,8 @@ class VideoDownload {
|
|||||||
throw DownloadException("No valid sources found for video/audio");
|
throw DownloadException("No valid sources found for video/audio");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suspend fun download(client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
|
|
||||||
|
suspend fun download(context: Context, client: ManagedHttpClient, onProgress: ((Double) -> Unit)? = null) = coroutineScope {
|
||||||
Logger.i(TAG, "VideoDownload Download [${name}]");
|
Logger.i(TAG, "VideoDownload Download [${name}]");
|
||||||
if(videoDetails == null || (videoSource == null && audioSource == null))
|
if(videoDetails == null || (videoSource == null && audioSource == null))
|
||||||
throw IllegalStateException("Missing information for download to complete");
|
throw IllegalStateException("Missing information for download to complete");
|
||||||
@ -199,7 +253,7 @@ class VideoDownload {
|
|||||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSource != null) {
|
||||||
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
|
audioFileName = "${videoDetails!!.id.value!!} [${audioSource!!.language}-${audioSource!!.bitrate}].${audioContainerToExtension(audioSource!!.container)}".sanitizeFileName();
|
||||||
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||||
}
|
}
|
||||||
if(subtitleSource != null) {
|
if(subtitleSource != null) {
|
||||||
@ -217,7 +271,8 @@ class VideoDownload {
|
|||||||
if(videoSource != null) {
|
if(videoSource != null) {
|
||||||
sourcesToDownload.add(async {
|
sourcesToDownload.add(async {
|
||||||
Logger.i(TAG, "Started downloading video");
|
Logger.i(TAG, "Started downloading video");
|
||||||
videoFileSize = downloadSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!)) { length, totalRead, speed ->
|
|
||||||
|
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||||
synchronized(progressLock) {
|
synchronized(progressLock) {
|
||||||
lastVideoLength = length;
|
lastVideoLength = length;
|
||||||
lastVideoRead = totalRead;
|
lastVideoRead = totalRead;
|
||||||
@ -235,12 +290,18 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoFileSize = when (videoSource!!.container) {
|
||||||
|
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||||
|
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSource != null) {
|
||||||
sourcesToDownload.add(async {
|
sourcesToDownload.add(async {
|
||||||
Logger.i(TAG, "Started downloading audio");
|
Logger.i(TAG, "Started downloading audio");
|
||||||
audioFileSize = downloadSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!)) { length, totalRead, speed ->
|
|
||||||
|
val progressCallback = { length: Long, totalRead: Long, speed: Long ->
|
||||||
synchronized(progressLock) {
|
synchronized(progressLock) {
|
||||||
lastAudioLength = length;
|
lastAudioLength = length;
|
||||||
lastAudioRead = totalRead;
|
lastAudioRead = totalRead;
|
||||||
@ -258,6 +319,11 @@ class VideoDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioFileSize = when (audioSource!!.container) {
|
||||||
|
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||||
|
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (subtitleSource != null) {
|
if (subtitleSource != null) {
|
||||||
@ -279,7 +345,105 @@ class VideoDownload {
|
|||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun downloadSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
|
||||||
|
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
|
if(targetFile.exists())
|
||||||
|
targetFile.delete();
|
||||||
|
|
||||||
|
var downloadedTotalLength = 0L
|
||||||
|
|
||||||
|
val segmentFiles = arrayListOf<File>()
|
||||||
|
try {
|
||||||
|
val response = client.get(hlsUrl)
|
||||||
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
|
val vpContent = response.body?.string()
|
||||||
|
?: throw Exception("Variant playlist content is empty")
|
||||||
|
|
||||||
|
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||||
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
|
if (segment !is HLS.MediaSegment) {
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||||
|
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||||
|
segmentFiles.add(segmentFile)
|
||||||
|
|
||||||
|
val segmentLength = downloadSource_Sequential(client, segmentFile.outputStream(), segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||||
|
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||||
|
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||||
|
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedTotalLength += segmentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Combining segments into $targetFile");
|
||||||
|
combineSegments(context, segmentFiles, targetFile)
|
||||||
|
|
||||||
|
Logger.i(TAG, "${name} downloadSource Finished");
|
||||||
|
}
|
||||||
|
catch(ioex: IOException) {
|
||||||
|
if(targetFile.exists() ?: false)
|
||||||
|
targetFile.delete();
|
||||||
|
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||||
|
throw Exception("Not enough space on device", ioex);
|
||||||
|
else
|
||||||
|
throw ioex;
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
if(targetFile.exists() ?: false)
|
||||||
|
targetFile.delete();
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
for (segmentFile in segmentFiles) {
|
||||||
|
segmentFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downloadedTotalLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}'" })
|
||||||
|
|
||||||
|
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||||
|
|
||||||
|
val statisticsCallback = StatisticsCallback { statistics ->
|
||||||
|
//TODO: Show progress?
|
||||||
|
}
|
||||||
|
|
||||||
|
val executorService = Executors.newSingleThreadExecutor()
|
||||||
|
val session = FFmpegKit.executeAsync(cmd,
|
||||||
|
{ session ->
|
||||||
|
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
fileList.delete()
|
||||||
|
continuation.resumeWith(Result.success(Unit))
|
||||||
|
} else {
|
||||||
|
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||||
|
"Command cancelled"
|
||||||
|
} else {
|
||||||
|
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||||
|
}
|
||||||
|
fileList.delete()
|
||||||
|
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ Logger.v(TAG, it.message) },
|
||||||
|
statisticsCallback,
|
||||||
|
executorService
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
session.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||||
if(targetFile.exists())
|
if(targetFile.exists())
|
||||||
targetFile.delete();
|
targetFile.delete();
|
||||||
|
|
||||||
@ -472,8 +636,10 @@ class VideoDownload {
|
|||||||
val expectedFile = File(videoFilePath!!);
|
val expectedFile = File(videoFilePath!!);
|
||||||
if(!expectedFile.exists())
|
if(!expectedFile.exists())
|
||||||
throw IllegalStateException("Video file missing after download");
|
throw IllegalStateException("Video file missing after download");
|
||||||
if(expectedFile.length() != videoFileSize)
|
if (videoSource?.container != "application/vnd.apple.mpegurl") {
|
||||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
if (expectedFile.length() != videoFileSize)
|
||||||
|
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(audioSource != null) {
|
if(audioSource != null) {
|
||||||
if(audioFilePath == null)
|
if(audioFilePath == null)
|
||||||
@ -481,8 +647,10 @@ class VideoDownload {
|
|||||||
val expectedFile = File(audioFilePath!!);
|
val expectedFile = File(audioFilePath!!);
|
||||||
if(!expectedFile.exists())
|
if(!expectedFile.exists())
|
||||||
throw IllegalStateException("Audio file missing after download");
|
throw IllegalStateException("Audio file missing after download");
|
||||||
if(expectedFile.length() != audioFileSize)
|
if (audioSource?.container != "application/vnd.apple.mpegurl") {
|
||||||
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
|
if (expectedFile.length() != audioFileSize)
|
||||||
|
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(subtitleSource != null) {
|
if(subtitleSource != null) {
|
||||||
if(subtitleFilePath == null)
|
if(subtitleFilePath == null)
|
||||||
@ -560,7 +728,7 @@ class VideoDownload {
|
|||||||
const val GROUP_PLAYLIST = "Playlist";
|
const val GROUP_PLAYLIST = "Playlist";
|
||||||
|
|
||||||
fun videoContainerToExtension(container: String): String? {
|
fun videoContainerToExtension(container: String): String? {
|
||||||
if (container.contains("video/mp4"))
|
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||||
return "mp4";
|
return "mp4";
|
||||||
else if (container.contains("application/x-mpegURL"))
|
else if (container.contains("application/x-mpegURL"))
|
||||||
return "m3u8";
|
return "m3u8";
|
||||||
@ -585,6 +753,8 @@ class VideoDownload {
|
|||||||
return "mp3";
|
return "mp3";
|
||||||
else if (container.contains("audio/webm"))
|
else if (container.contains("audio/webm"))
|
||||||
return "webma";
|
return "webma";
|
||||||
|
else if (container == "application/vnd.apple.mpegurl")
|
||||||
|
return "mp4";
|
||||||
else
|
else
|
||||||
return "audio";
|
return "audio";
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,11 @@ package com.futo.platformplayer.helpers
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
@ -20,11 +23,23 @@ import com.google.android.exoplayer2.upstream.ResolvingDataSource
|
|||||||
class VideoHelper {
|
class VideoHelper {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun isDownloadable(detail: IPlatformVideoDetails) =
|
fun isDownloadable(detail: IPlatformVideoDetails): Boolean {
|
||||||
(detail.video.videoSources.any { isDownloadable(it) }) ||
|
if (detail.video.videoSources.any { isDownloadable(it) }) {
|
||||||
(if (detail is VideoUnMuxedSourceDescriptor) detail.audioSources.any { isDownloadable(it) } else false);
|
return true
|
||||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource;
|
}
|
||||||
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource;
|
|
||||||
|
val descriptor = detail.video
|
||||||
|
if (descriptor is VideoUnMuxedSourceDescriptor) {
|
||||||
|
if (descriptor.audioSources.any { isDownloadable(it) }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource;
|
||||||
|
fun isDownloadable(source: IAudioSource) = source is IAudioUrlSource || source is IHLSManifestAudioSource;
|
||||||
|
|
||||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||||
|
@ -1,8 +1,22 @@
|
|||||||
package com.futo.platformplayer.parsers
|
package com.futo.platformplayer.parsers
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
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.models.streams.sources.HLSVariantAudioUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||||
|
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||||
|
import com.futo.platformplayer.states.StateDownloads
|
||||||
import com.futo.platformplayer.toYesNo
|
import com.futo.platformplayer.toYesNo
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||||
import com.futo.platformplayer.yesNoToBoolean
|
import com.futo.platformplayer.yesNoToBoolean
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@ -85,6 +99,48 @@ class HLS {
|
|||||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||||
|
val masterPlaylist: MasterPlaylist
|
||||||
|
try {
|
||||||
|
masterPlaylist = parseMasterPlaylist(content, url)
|
||||||
|
return masterPlaylist.getVideoSources()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
|
return if (source is IHLSManifestSource) {
|
||||||
|
listOf(HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, url))
|
||||||
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
|
listOf()
|
||||||
|
} else {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
|
||||||
|
val masterPlaylist: MasterPlaylist
|
||||||
|
try {
|
||||||
|
masterPlaylist = parseMasterPlaylist(content, url)
|
||||||
|
return masterPlaylist.getAudioSources()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
|
return if (source is IHLSManifestSource) {
|
||||||
|
listOf()
|
||||||
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
|
listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url))
|
||||||
|
} else {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: getSubtitleSources
|
||||||
|
|
||||||
private fun resolveUrl(baseUrl: String, url: String): String {
|
private fun resolveUrl(baseUrl: String, url: String): String {
|
||||||
val baseUri = URI(baseUrl)
|
val baseUri = URI(baseUrl)
|
||||||
val urlUri = URI(url)
|
val urlUri = URI(url)
|
||||||
@ -269,6 +325,49 @@ class HLS {
|
|||||||
|
|
||||||
return builder.toString()
|
return builder.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||||
|
return variantPlaylistsRefs.map {
|
||||||
|
var width: Int? = null
|
||||||
|
var height: Int? = null
|
||||||
|
val resolutionTokens = it.streamInfo.resolution?.split('x')
|
||||||
|
if (resolutionTokens?.isNotEmpty() == true) {
|
||||||
|
width = resolutionTokens[0].toIntOrNull()
|
||||||
|
height = resolutionTokens[1].toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
|
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||||
|
return mediaRenditions.mapNotNull {
|
||||||
|
if (it.uri == null) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
|
return@mapNotNull when (it.type) {
|
||||||
|
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSubtitleSources(): List<HLSVariantSubtitleUrlSource> {
|
||||||
|
return mediaRenditions.mapNotNull {
|
||||||
|
if (it.uri == null) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||||
|
return@mapNotNull when (it.type) {
|
||||||
|
"SUBTITLE" -> HLSVariantSubtitleUrlSource(it.name?.ifEmpty { "Subtitle (${suffix})" } ?: "Subtitle (${suffix})", it.uri, "application/vnd.apple.mpegurl")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
|
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
|
||||||
|
@ -162,6 +162,8 @@ class DownloadService : Service() {
|
|||||||
Logger.i(TAG, "doDownloading - Ending Downloads");
|
Logger.i(TAG, "doDownloading - Ending Downloads");
|
||||||
stopService(this);
|
stopService(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private suspend fun doDownload(download: VideoDownload) {
|
private suspend fun doDownload(download: VideoDownload) {
|
||||||
if(!Settings.instance.downloads.shouldDownload())
|
if(!Settings.instance.downloads.shouldDownload())
|
||||||
throw IllegalStateException("Downloading disabled on current network");
|
throw IllegalStateException("Downloading disabled on current network");
|
||||||
@ -183,14 +185,14 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
Logger.i(TAG, "Preparing [${download.name}] started");
|
Logger.i(TAG, "Preparing [${download.name}] started");
|
||||||
if(download.state == VideoDownload.State.PREPARING)
|
if(download.state == VideoDownload.State.PREPARING)
|
||||||
download.prepare();
|
download.prepare(_client);
|
||||||
download.changeState(VideoDownload.State.DOWNLOADING);
|
download.changeState(VideoDownload.State.DOWNLOADING);
|
||||||
notifyDownload(download);
|
notifyDownload(download);
|
||||||
|
|
||||||
var lastNotifyTime: Long = 0L;
|
var lastNotifyTime: Long = 0L;
|
||||||
Logger.i(TAG, "Downloading [${download.name}] started");
|
Logger.i(TAG, "Downloading [${download.name}] started");
|
||||||
//TODO: Use plugin client?
|
//TODO: Use plugin client?
|
||||||
download.download(_client) { progress ->
|
download.download(applicationContext, _client) { progress ->
|
||||||
download.progress = progress;
|
download.progress = progress;
|
||||||
|
|
||||||
val currentTime = System.currentTimeMillis();
|
val currentTime = System.currentTimeMillis();
|
||||||
|
@ -73,8 +73,9 @@ class SlideUpMenuOverlay : RelativeLayout {
|
|||||||
item.setParentClickListener { hide() };
|
item.setParentClickListener { hide() };
|
||||||
else if(item is SlideUpMenuItem)
|
else if(item is SlideUpMenuItem)
|
||||||
item.setParentClickListener { hide() };
|
item.setParentClickListener { hide() };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_groupItems = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun init(animated: Boolean, okText: String?){
|
private fun init(animated: Boolean, okText: String?){
|
||||||
|
Loading…
x
Reference in New Issue
Block a user