From 0190bbffddf3f77b786f8d0a8b3e1f39f1677cb7 Mon Sep 17 00:00:00 2001
From: Kai <git@seaoflaurels.com>
Date: Wed, 22 Jan 2025 16:05:44 -0600
Subject: [PATCH 1/3] improve support for HLS and DASH downloads

Changelog: added
---
 .../futo/platformplayer/UISlideOverlays.kt    | 198 ++++++--
 .../streams/sources/HLSManifestSource.kt      |  17 +-
 .../streams/sources/IDashManifestSource.kt    |  26 +-
 .../streams/sources/IHLSManifestSource.kt     |   6 +-
 .../js/models/sources/JSDashManifestSource.kt |   2 -
 .../sources/JSHLSManifestAudioSource.kt       |  20 +-
 .../js/models/sources/JSHLSManifestSource.kt  |  27 +-
 .../platforms/js/models/sources/JSSource.kt   |  11 +-
 .../platformplayer/downloads/VideoDownload.kt | 446 +++++++++++++++---
 .../platformplayer/downloads/VideoExport.kt   |   4 +-
 .../platformplayer/helpers/VideoHelper.kt     |   3 +-
 .../com/futo/platformplayer/parsers/HLS.kt    | 103 ++--
 12 files changed, 723 insertions(+), 140 deletions(-)

diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
index 2a930734..116153ce 100644
--- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
+++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
@@ -4,8 +4,13 @@ import android.app.NotificationManager
 import android.content.ContentResolver
 import android.content.Context
 import android.content.Intent
+import android.net.Uri
 import android.view.View
 import android.view.ViewGroup
+import androidx.annotation.OptIn
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.dash.manifest.DashManifestParser
 import androidx.recyclerview.widget.RecyclerView
 import com.futo.platformplayer.activities.MainActivity
 import com.futo.platformplayer.activities.SettingsActivity
@@ -13,10 +18,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
 import com.futo.platformplayer.api.media.models.ResultCapabilities
 import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
 import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
+import com.futo.platformplayer.api.media.models.streams.sources.DashManifestAudioSourceDelegate
+import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSourceDelegate
 import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
 import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
 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.IDashManifestSource
 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
@@ -28,6 +36,10 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
 import com.futo.platformplayer.api.media.platforms.js.JSClient
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
 import com.futo.platformplayer.downloads.VideoLocal
 import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
 import com.futo.platformplayer.helpers.VideoHelper
@@ -36,6 +48,7 @@ import com.futo.platformplayer.models.ImageVariable
 import com.futo.platformplayer.models.Playlist
 import com.futo.platformplayer.models.Subscription
 import com.futo.platformplayer.models.SubscriptionGroup
+import com.futo.platformplayer.others.Language
 import com.futo.platformplayer.parsers.HLS
 import com.futo.platformplayer.states.StateApp
 import com.futo.platformplayer.states.StateDownloads
@@ -63,6 +76,7 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import java.io.ByteArrayInputStream
 
 class UISlideOverlays {
     companion object {
@@ -269,14 +283,114 @@ class UISlideOverlays {
 
         }
 
-        fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
+        @OptIn(UnstableApi::class)
+        fun showDashPicker(video: IPlatformVideoDetails, source: JSDashManifestSource, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
+            val items = arrayListOf<View>(LoaderView(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 manifestResponse = ManagedHttpClient().get(sourceUrl)
+                check(manifestResponse.isOk) { "Failed to get DASH manifest: ${manifestResponse.code}" }
+
+                val manifestContent = manifestResponse.body?.string()
+                    ?: throw Exception("Manifest content is empty")
+
+                val videoButtons = arrayListOf<SlideUpMenuItem>()
+                val audioButtons = arrayListOf<SlideUpMenuItem>()
+                //TODO: Implement subtitles
+                //val subtitleButtons = arrayListOf<SlideUpMenuItem>()
+
+                var selectedVideoVariant: IDashManifestSource? = null
+                var selectedAudioVariant: IAudioSource? = null
+                //TODO: Implement subtitles
+                //var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
+
+                val manifestStream = ByteArrayInputStream(manifestContent.toByteArray())
+                val playlist = DashManifestParser().parse(Uri.parse(sourceUrl), manifestStream)
+
+                playlist.getPeriod(0).adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO }
+                    .flatMap { it.representations }.forEach {
+                        audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.format.containerMimeType
+                            ?: "", listOf(it.format.language, it.format.codecs).mapNotNull { x -> x?.ifEmpty { null } }
+                            .joinToString(", "), it.format.codecs, tag = it, call = {
+                            selectedAudioVariant = DashManifestAudioSourceDelegate(
+                                source, it.format.language
+                                    ?: Language.UNKNOWN, it.format.bitrate, it.format.containerMimeType!!
+                            )
+
+                            slideUpMenuOverlay.selectOption(audioButtons, it)
+                            slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                        }, invokeParent = 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))
+                }*/
+
+                playlist.getPeriod(0).adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO }
+                    .flatMap { it.representations }.forEach {
+                        videoButtons.add(
+                            SlideUpMenuItem(
+                                container.context, R.drawable.ic_movie, it.format.containerMimeType
+                                    ?: "", "${it.format.width}x${it.format.height}", it.format.codecs, tag = it, call = {
+                                    selectedVideoVariant =
+                                        DashManifestSourceDelegate(source, it.format.width, it.format.height, it.format.containerMimeType!!)
+                                    slideUpMenuOverlay.selectOption(videoButtons, it)
+                                    slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                                }, invokeParent = 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)
+                }
+            }
+
+            return slideUpMenuOverlay.apply { show() }
+        }
+
+        fun showHlsPicker(video: IPlatformVideoDetails, source: JSSource, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
             val items = arrayListOf<View>(LoaderView(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)
+
+                val masterPlaylistResponse = if (source.hasRequestModifier) {
+                    val request = source.getRequestModifier()!!.modifyRequest(sourceUrl, mapOf())
+                    ManagedHttpClient().get(request.url!!, request.headers.toMutableMap())
+                } else {
+                    ManagedHttpClient().get(sourceUrl)
+                }
                 check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
 
+                val resolvedSourceUrl = masterPlaylistResponse.url
+
                 val masterPlaylistContent = masterPlaylistResponse.body?.string()
                     ?: throw Exception("Master playlist content is empty")
 
@@ -285,14 +399,14 @@ class UISlideOverlays {
                 //TODO: Implement subtitles
                 //val subtitleButtons = arrayListOf<SlideUpMenuItem>()
 
-                var selectedVideoVariant: HLSVariantVideoUrlSource? = null
-                var selectedAudioVariant: HLSVariantAudioUrlSource? = null
+                var selectedVideoVariant: IHLSManifestSource? = null
+                var selectedAudioVariant: IAudioSource? = null
                 //TODO: Implement subtitles
                 //var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
 
                 val masterPlaylist: HLS.MasterPlaylist
                 try {
-                    masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
+                    masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, masterPlaylistResponse.url, source is IHLSManifestAudioSource)
 
                     masterPlaylist.getAudioSources().forEach { it ->
 
@@ -306,7 +420,19 @@ class UISlideOverlays {
                             (prefix + it.codec).trim(),
                             tag = it,
                             call = {
-                                selectedAudioVariant = it
+                                if (source is JSHLSManifestAudioSource) {
+                                    source.setPreferredBitrate(it.bitrate)
+                                    source.setPreferredLanguage(it.language)
+                                    source.setPreferredContainer(it.container)
+                                    selectedAudioVariant = source
+                                } else if (source is JSHLSManifestSource) {
+                                    source.setPreferredBitrate(it.bitrate)
+                                    source.setPreferredLanguage(it.language)
+                                    selectedAudioVariant = source
+                                } else {
+                                    throw Exception("Expected HLS Source")
+                                }
+
                                 slideUpMenuOverlay.selectOption(audioButtons, it)
                                 slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
                             },
@@ -333,7 +459,12 @@ class UISlideOverlays {
                             (prefix + it.codec).trim(),
                             tag = it,
                             call = {
-                                selectedVideoVariant = it
+                                if (source !is JSHLSManifestSource){
+                                    throw Exception("Expected HLS Source")
+                                }
+                                source.setPreferredWidth(it.width)
+                                source.setPreferredHeight(it.height)
+                                selectedVideoVariant = source
                                 slideUpMenuOverlay.selectOption(videoButtons, it)
                                 slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
                             },
@@ -366,11 +497,11 @@ class UISlideOverlays {
                     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)
+                                StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedSourceUrl), 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)
+                                StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, resolvedSourceUrl), null)
                                 UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
                                 slideUpMenuOverlay.hide()
                             } else {
@@ -417,7 +548,7 @@ class UISlideOverlays {
             }
 
             items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
-                listOf(listOf(SlideUpMenuItem(
+                listOf((if (audioSources != null) listOf(SlideUpMenuItem(
                     container.context,
                     R.drawable.ic_movie,
                     container.context.getString(R.string.none),
@@ -430,12 +561,12 @@ class UISlideOverlays {
                             menu?.setOk(container.context.getString(R.string.download));
                     },
                     invokeParent = false
-                )) +
+                )) else listOf()) +
                 videoSources
                 .filter { it.isDownloadable() }
                 .map {
                     when (it) {
-                        is IVideoUrlSource -> {
+                        is JSDashManifestRawSource -> {
                             val estSize = VideoHelper.estimateSourceSize(it);
                             val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
                             SlideUpMenuItem(
@@ -455,35 +586,43 @@ class UISlideOverlays {
                             )
                         }
 
-                        is JSDashManifestRawSource -> {
-                            val estSize = VideoHelper.estimateSourceSize(it);
-                            val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                        is JSDashManifestSource -> {
+                            SlideUpMenuItem(
+                                container.context, R.drawable.ic_movie, it.name, "DASH", tag = it, call = {
+                                    showDashPicker(video, it, it.url, container)
+                                }, invokeParent = false
+                            )
+                        }
+
+                        is JSHLSManifestSource -> {
                             SlideUpMenuItem(
                                 container.context,
                                 R.drawable.ic_movie,
                                 it.name,
-                                "${it.width}x${it.height}",
-                                (prefix + it.codec).trim(),
+                                "HLS",
                                 tag = it,
                                 call = {
-                                    selectedVideo = it
-                                    menu?.selectOption(videoSources, it);
-                                    if(selectedAudio != null || !requiresAudio)
-                                        menu?.setOk(container.context.getString(R.string.download));
+                                    showHlsPicker(video, it, it.url, container)
                                 },
                                 invokeParent = false
                             )
                         }
 
-                        is IHLSManifestSource -> {
+                        is IVideoUrlSource -> {
+                            val estSize = VideoHelper.estimateSourceSize(it);
+                            val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
                             SlideUpMenuItem(
                                 container.context,
                                 R.drawable.ic_movie,
                                 it.name,
-                                "HLS",
+                                "${it.width}x${it.height}",
+                                (prefix + it.codec).trim(),
                                 tag = it,
                                 call = {
-                                    showHlsPicker(video, it, it.url, container)
+                                    selectedVideo = it
+                                    menu?.selectOption(videoSources, it);
+                                    if(selectedAudio != null || !requiresAudio)
+                                        menu?.setOk(container.context.getString(R.string.download));
                                 },
                                 invokeParent = false
                             )
@@ -549,7 +688,7 @@ class UISlideOverlays {
                                 );
                             }
 
-                            is IHLSManifestAudioSource -> {
+                            is JSHLSManifestAudioSource -> {
                                 SlideUpMenuItem(
                                     container.context,
                                     R.drawable.ic_movie,
@@ -614,13 +753,18 @@ class UISlideOverlays {
 
             menu.onOK.subscribe {
                 val sv = selectedVideo
-                if (sv is IHLSManifestSource) {
+                if (sv is JSHLSManifestSource) {
                     showHlsPicker(video, sv, sv.url, container)
                     return@subscribe
                 }
 
+                if (sv is JSDashManifestSource) {
+                    showDashPicker(video, sv, sv.url, container)
+                    return@subscribe
+                }
+
                 val sa = selectedAudio
-                if (sa is IHLSManifestAudioSource) {
+                if (sa is JSHLSManifestAudioSource) {
                     showHlsPicker(video, sa, sa.url, container)
                     return@subscribe
                 }
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt
index 52304473..440e9e12 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSManifestSource.kt
@@ -1,18 +1,21 @@
 package com.futo.platformplayer.api.media.models.streams.sources
 
+import com.futo.platformplayer.others.Language
+
 class HLSManifestSource : IVideoSource, IHLSManifestSource {
-    override val width : Int = 0;
-    override val height : Int = 0;
-    override val container : String = "HLS";
+    override val width: Int = 0;
+    override val height: Int = 0;
+    override val container: String = "HLS";
     override val codec: String = "HLS";
-    override val name : String = "HLS";
-    override val bitrate : Int? = null;
-    override val url : String;
+    override val name: String = "HLS";
+    override val bitrate: Int = 0;
+    override val url: String;
     override val duration: Long = 0;
+    override val language: String = Language.UNKNOWN;
 
     override var priority: Boolean = false;
 
-    constructor(url : String) {
+    constructor(url: String) {
         this.url = url;
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt
index c6086d7e..c3d43fde 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IDashManifestSource.kt
@@ -1,5 +1,29 @@
 package com.futo.platformplayer.api.media.models.streams.sources
 
+import com.caoccao.javet.values.reference.V8ValueObject
+import com.futo.platformplayer.api.media.platforms.js.models.sources.IUnderlyingObject
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource
+
 interface IDashManifestSource : IVideoSource {
-    val url : String;
+    val url: String
+}
+
+interface DashWrapper {
+    val source: IDashManifestSource
+}
+
+class DashManifestAudioSourceDelegate(
+    override val source: JSDashManifestSource, override val language: String, override val bitrate: Int, override val container: String
+) : IDashManifestSource by source, IAudioSource, DashWrapper, IUnderlyingObject {
+    override fun getUnderlyingObject(): V8ValueObject? {
+        return source.getUnderlyingObject()
+    }
+}
+
+class DashManifestSourceDelegate(
+    override val source: JSDashManifestSource, override val width: Int, override val height: Int, override val container: String
+) : IDashManifestSource by source, DashWrapper, IUnderlyingObject {
+    override fun getUnderlyingObject(): V8ValueObject? {
+        return source.getUnderlyingObject()
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt
index 8993d044..d280cd15 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IHLSManifestSource.kt
@@ -1,8 +1,8 @@
 package com.futo.platformplayer.api.media.models.streams.sources
 
-interface IHLSManifestSource : IVideoSource {
-    val url : String;
+interface IHLSManifestSource : IVideoSource, IAudioSource {
+    val url : String
 }
 interface IHLSManifestAudioSource : IAudioSource {
-    val url : String;
+    val url : String
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt
index 3070a2d4..2edb01b2 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestSource.kt
@@ -4,8 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueObject
 import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
 import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
 import com.futo.platformplayer.api.media.platforms.js.JSClient
-import com.futo.platformplayer.engine.IV8PluginConfig
-import com.futo.platformplayer.engine.V8Plugin
 import com.futo.platformplayer.getOrNull
 import com.futo.platformplayer.getOrThrow
 
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt
index 41948802..a63f6b42 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt
@@ -2,23 +2,20 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
 
 import com.caoccao.javet.values.V8Value
 import com.caoccao.javet.values.reference.V8ValueObject
-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.platforms.js.JSClient
-import com.futo.platformplayer.engine.IV8PluginConfig
-import com.futo.platformplayer.engine.V8Plugin
 import com.futo.platformplayer.getOrNull
 import com.futo.platformplayer.getOrThrow
 import com.futo.platformplayer.orNull
 
 class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
-    override val container : String get() = "application/vnd.apple.mpegurl";
+    override var container : String = "application/vnd.apple.mpegurl";
     override val codec: String = "HLS";
     override val name : String;
-    override val bitrate : Int = 0;
+    override var bitrate : Int = 0;
     override val url : String;
     override val duration: Long;
-    override val language: String;
+    override var language: String;
 
     override var priority: Boolean = false;
 
@@ -34,6 +31,17 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
         priority = obj.getOrNull(config, "priority", contextName) ?: false;
     }
 
+    fun setPreferredBitrate(bitrate: Int) {
+        this@JSHLSManifestAudioSource.bitrate = bitrate;
+    }
+
+    fun setPreferredLanguage(language: String) {
+        this@JSHLSManifestAudioSource.language = language;
+    }
+
+    fun setPreferredContainer(container: String) {
+        this@JSHLSManifestAudioSource.container = container;
+    }
 
     companion object {
         fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt
index 606d107c..1a35fc64 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestSource.kt
@@ -2,22 +2,21 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
 
 import com.caoccao.javet.values.reference.V8ValueObject
 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.platforms.js.JSClient
-import com.futo.platformplayer.engine.IV8PluginConfig
-import com.futo.platformplayer.engine.V8Plugin
 import com.futo.platformplayer.getOrNull
 import com.futo.platformplayer.getOrThrow
+import com.futo.platformplayer.others.Language
 
 class JSHLSManifestSource : IHLSManifestSource, JSSource {
-    override val width : Int = 0;
-    override val height : Int = 0;
+    override var width : Int = 0;
+    override var height : Int = 0;
     override val container : String get() = "application/vnd.apple.mpegurl";
     override val codec: String = "HLS";
     override val name : String;
-    override val bitrate : Int? = null;
+    override var bitrate : Int = 0;
     override val url : String;
     override val duration: Long;
+    override var language: String = Language.UNKNOWN
 
     override var priority: Boolean = false;
 
@@ -31,4 +30,20 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
 
         priority = obj.getOrNull(config, "priority", contextName) ?: false;
     }
+
+    fun setPreferredWidth(width: Int) {
+        this@JSHLSManifestSource.width = width
+    }
+
+    fun setPreferredHeight(height: Int) {
+        this@JSHLSManifestSource.height = height
+    }
+
+    fun setPreferredBitrate(bitrate: Int) {
+        this@JSHLSManifestSource.bitrate = bitrate;
+    }
+
+    fun setPreferredLanguage(language: String) {
+        this@JSHLSManifestSource.language = language;
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt
index 3c76e23d..941c0b11 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt
@@ -1,7 +1,5 @@
 package com.futo.platformplayer.api.media.platforms.js.models.sources
 
-import androidx.media3.datasource.DefaultHttpDataSource
-import androidx.media3.datasource.HttpDataSource
 import com.caoccao.javet.values.V8Value
 import com.caoccao.javet.values.reference.V8ValueObject
 import com.futo.platformplayer.api.media.models.modifier.AdhocRequestModifier
@@ -17,9 +15,12 @@ import com.futo.platformplayer.engine.V8Plugin
 import com.futo.platformplayer.getOrDefault
 import com.futo.platformplayer.logging.Logger
 import com.futo.platformplayer.orNull
-import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
 
-abstract class JSSource {
+interface IUnderlyingObject {
+    fun getUnderlyingObject(): V8ValueObject?
+}
+
+abstract class JSSource : IUnderlyingObject {
     protected val _plugin: JSClient;
     protected val _config: IV8PluginConfig;
     protected val _obj: V8ValueObject;
@@ -88,7 +89,7 @@ abstract class JSSource {
     fun getUnderlyingPlugin(): JSClient? {
         return _plugin;
     }
-    fun getUnderlyingObject(): V8ValueObject? {
+    override fun getUnderlyingObject(): V8ValueObject? {
         return _obj;
     }
 
diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
index 84c019c8..7a8808b1 100644
--- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
+++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
@@ -1,7 +1,12 @@
 package com.futo.platformplayer.downloads
 
 import android.content.Context
+import android.net.Uri
 import android.util.Log
+import androidx.annotation.OptIn
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.dash.manifest.DashManifestParser
 import com.arthenica.ffmpegkit.FFmpegKit
 import com.arthenica.ffmpegkit.ReturnCode
 import com.arthenica.ffmpegkit.StatisticsCallback
@@ -10,6 +15,8 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
 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.AudioUrlSource
+import com.futo.platformplayer.api.media.models.streams.sources.DashManifestAudioSourceDelegate
+import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSourceDelegate
 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.IDashManifestSource
@@ -28,25 +35,27 @@ 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.SerializedPlatformVideoDetails
 import com.futo.platformplayer.api.media.platforms.js.JSClient
-import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
-import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
-import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
 import com.futo.platformplayer.api.media.platforms.js.models.sources.IJSDashManifestRawSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.IUnderlyingObject
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
 import com.futo.platformplayer.constructs.Event1
 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
 import com.futo.platformplayer.states.StatePlatform
-import com.futo.platformplayer.states.StatePlugins
 import com.futo.platformplayer.toHumanBitrate
 import com.futo.platformplayer.toHumanBytesSpeed
+import com.futo.polycentric.core.hexStringToByteArray
 import hasAnySource
 import isDownloadable
 import kotlinx.coroutines.CancellationException
@@ -59,6 +68,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withContext
 import kotlinx.serialization.Contextual
 import kotlinx.serialization.Transient
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
 import java.io.File
 import java.io.FileOutputStream
 import java.io.IOException
@@ -69,8 +80,10 @@ import java.util.concurrent.Executors
 import java.util.concurrent.ForkJoinPool
 import java.util.concurrent.ForkJoinTask
 import java.util.concurrent.ThreadLocalRandom
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
 import kotlin.coroutines.resumeWithException
-import kotlin.time.times
 
 @kotlinx.serialization.Serializable
 class VideoDownload {
@@ -119,18 +132,21 @@ class VideoDownload {
     var requiresLiveVideoSource: Boolean = false;
     @Contextual
     @kotlinx.serialization.Transient
-    var videoSourceLive: JSSource? = null;
+    var videoSourceLive: IUnderlyingObject? = null;
     val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
 
     var requiresLiveAudioSource: Boolean = false;
     @Contextual
     @kotlinx.serialization.Transient
-    var audioSourceLive: JSSource? = null;
+    var audioSourceLive: IUnderlyingObject? = null;
     val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
 
     var hasVideoRequestExecutor: Boolean = false;
     var hasAudioRequestExecutor: Boolean = false;
 
+    private var hasVideoRequestModifier: Boolean = false
+    private var hasAudioRequestModifier: Boolean = false
+
     var progress: Double = 0.0;
     var isCancelled = false;
 
@@ -191,8 +207,10 @@ class VideoDownload {
         this.prepareTime = OffsetDateTime.now();
         this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
         this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
-        this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
-        this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
+        this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier
+        this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier
+        this.requiresLiveVideoSource = this.hasVideoRequestExecutor || this.hasVideoRequestModifier || videoSource !is IVideoUrlSource
+        this.requiresLiveAudioSource = this.hasAudioRequestExecutor || this.hasAudioRequestModifier || audioSource !is IAudioUrlSource
         this.targetVideoName = videoSource?.name;
         this.targetAudioName = audioSource?.name;
         this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -227,6 +245,7 @@ class VideoDownload {
         return items.joinToString(" • ");
     }
 
+    @OptIn(UnstableApi::class)
     suspend fun prepare(client: ManagedHttpClient) {
         Logger.i(TAG, "VideoDownload Prepare [${name}]");
 
@@ -282,21 +301,57 @@ class VideoDownload {
             }
 
             videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
-            if(videoSource == null && targetPixelCount != null) {
+            if (videoSource == null && targetPixelCount != null) {
                 val videoSources = arrayListOf<IVideoSource>()
                 for (source in original.video.videoSources) {
                     if (source is IHLSManifestSource) {
                         try {
-                            val playlistResponse = client.get(source.url)
+                            val playlistResponse = if ((source as JSSource).hasRequestModifier) {
+                                val request =
+                                    source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
+                                client.get(request.url!!, request.headers.toMutableMap())
+                            } else {
+                                client.get(source.url)
+                            }
                             if (playlistResponse.isOk) {
                                 val playlistContent = playlistResponse.body?.string()
                                 if (playlistContent != null) {
-                                    videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
+                                    val variantSources =
+                                        HLS.parseAndGetVideoSources(source, playlistContent, source.url)
+                                    val target =
+                                        VideoHelper.selectBestVideoSource(variantSources, targetPixelCount!!.toInt(), arrayOf())
+                                    if (target != null) {
+                                        (source as JSHLSManifestSource).setPreferredWidth(target.width)
+                                        source.setPreferredHeight(target.height)
+                                        videoSources.add(source)
+                                    }
                                 }
                             }
                         } catch (e: Throwable) {
                             Log.i(TAG, "Failed to get HLS video sources", e)
                         }
+                    } else if (source is JSDashManifestSource) {
+                        val masterPlaylistResponse = ManagedHttpClient().get(source.url)
+                        check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
+
+                        val hlsManifestUrl = masterPlaylistResponse.url
+
+                        val masterPlaylistContent = masterPlaylistResponse.body?.string()
+                            ?: throw Exception("Master playlist content is empty")
+
+                        val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
+
+                        val playlist =
+                            DashManifestParser().parse(Uri.parse(hlsManifestUrl), inputStream)
+
+                        val period = playlist.getPeriod(0)
+
+                        val representation =
+                            period.adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO }
+                                .flatMap { it.representations }.filter {
+                                    (it.format.width * it.format.height).toLong() == targetPixelCount
+                                }[0]
+                        videoSources.add(DashManifestSourceDelegate(source, representation.format.width, representation.format.height, representation.format.containerMimeType!!))
                     } else {
                         videoSources.add(source)
                     }
@@ -320,22 +375,40 @@ class VideoDownload {
                     videoSource = VideoUrlSource.fromUrlSource(vsource)
                 else if(vsource is JSSource && requiresLiveVideoSource)
                     videoSourceLive = vsource;
+                else if (vsource is DashManifestSourceDelegate)
+                    videoSourceLive = vsource
                 else
                     throw DownloadException("Video source is not supported for downloading (yet) [" + vsource?.javaClass?.name + "]", false);
             }
 
-            if(audioSource == null && targetBitrate != null) {
+            if (audioSource == null && targetBitrate != null) {
                 var audioSources = mutableListOf<IAudioSource>()
                 val video = original.video
                 if (video is VideoUnMuxedSourceDescriptor) {
                     for (source in video.audioSources) {
                         if (source is IHLSManifestAudioSource) {
                             try {
-                                val playlistResponse = client.get(source.url)
+                                val playlistResponse =
+                                    if ((source as JSSource).hasRequestModifier) {
+                                        val request = source.getRequestModifier()!!
+                                            .modifyRequest(source.url, mapOf())
+                                        client.get(request.url!!, request.headers.toMutableMap())
+                                    } else {
+                                        client.get(source.url)
+                                    }
                                 if (playlistResponse.isOk) {
                                     val playlistContent = playlistResponse.body?.string()
                                     if (playlistContent != null) {
-                                        audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
+                                        val variantSources =
+                                            HLS.parseAndGetAudioSources(source, playlistContent, source.url, true)
+                                        val target =
+                                            VideoHelper.selectBestAudioSource(variantSources, arrayOf(), null, targetBitrate)
+                                        if (target != null) {
+                                            (source as JSHLSManifestAudioSource).setPreferredBitrate(target.bitrate)
+                                            source.setPreferredLanguage(target.language)
+                                            source.setPreferredContainer(target.container)
+                                            audioSources.add(source)
+                                        }
                                     }
                                 }
                             } catch (e: Throwable) {
@@ -346,6 +419,62 @@ class VideoDownload {
                         }
                     }
                 }
+                for (source in video.videoSources) {
+                    if (source is IHLSManifestSource) {
+                        try {
+                            val playlistResponse = if ((source as JSSource).hasRequestModifier) {
+                                val request =
+                                    source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
+                                client.get(request.url!!, request.headers.toMutableMap())
+                            } else {
+                                client.get(source.url)
+                            }
+                            if (playlistResponse.isOk) {
+                                val playlistContent = playlistResponse.body?.string()
+                                if (playlistContent != null) {
+                                    val variantSources =
+                                        HLS.parseAndGetAudioSources(source, playlistContent, source.url, true)
+                                    val target =
+                                        VideoHelper.selectBestAudioSource(variantSources, arrayOf(), null, targetBitrate)
+                                    if (target != null) {
+                                        (source as JSHLSManifestSource).setPreferredBitrate(target.bitrate)
+                                        source.setPreferredLanguage(target.language)
+                                        audioSources.add(source)
+                                    }
+                                }
+                            }
+                        } catch (e: Throwable) {
+                            Log.i(TAG, "Failed to get HLS audio sources", e)
+                        }
+                    } else if (source is JSDashManifestSource) {
+                        val masterPlaylistResponse = ManagedHttpClient().get(source.url)
+                        check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
+
+                        val hlsManifestUrl = masterPlaylistResponse.url
+
+                        val masterPlaylistContent = masterPlaylistResponse.body?.string()
+                            ?: throw Exception("Master playlist content is empty")
+
+                        val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
+
+                        val playlist =
+                            DashManifestParser().parse(Uri.parse(hlsManifestUrl), inputStream)
+
+                        val period = playlist.getPeriod(0)
+
+                        val representation =
+                            period.adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO }
+                                .flatMap { it.representations }.filter {
+                                    it.format.bitrate.toLong() == targetBitrate
+                                }[0]
+                        audioSources.add(
+                            DashManifestAudioSourceDelegate(
+                                source, representation.format.language
+                                    ?: Language.UNKNOWN, representation.format.bitrate, representation.format.containerMimeType!!
+                            )
+                        )
+                    }
+                }
 
                 var asource: IAudioSource? = null;
                 if(targetAudioName != null) {
@@ -370,6 +499,8 @@ class VideoDownload {
                     audioSource = AudioUrlSource.fromUrlSource(asource)
                 else if(asource is JSSource && requiresLiveAudioSource)
                     audioSourceLive = asource;
+                else if (asource is DashManifestAudioSourceDelegate)
+                    audioSourceLive = asource
                 else
                     throw DownloadException("Audio source is not supported for downloading (yet) [" + asource?.javaClass?.name + "]", false);
             }
@@ -448,16 +579,23 @@ class VideoDownload {
                     }
                 }
 
-                if(actualVideoSource is IVideoUrlSource)
-                    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)
+                videoFileSize = when (actualVideoSource) {
+                    is IVideoUrlSource -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
+                    is JSDashManifestRawSource -> {
+                        downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback)
+                    }
+
+                    is JSHLSManifestSource -> {
+                        downloadHlsSource(context, "Video", client, actualVideoSource, false, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback)
                     }
-                else if(actualVideoSource is JSDashManifestRawSource) {
-                    videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
+
+                    is DashManifestSourceDelegate -> {
+                        downloadDashSource(context, "Video", client, actualVideoSource.source, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback)
+                    }
+
+                    else -> throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name)
                 }
-                else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
-            });
+            })
         }
         if(actualAudioSource != null) {
             sourcesToDownload.add(async {
@@ -488,16 +626,27 @@ class VideoDownload {
                     }
                 }
 
-                if(actualAudioSource is IAudioUrlSource)
-                    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)
+                audioFileSize = when (actualAudioSource) {
+                    is IVideoUrlSource -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
+                    is JSDashManifestRawAudioSource -> {
+                        downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback)
                     }
-                else if(actualAudioSource is JSDashManifestRawAudioSource) {
-                    audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
+
+                    is JSHLSManifestAudioSource -> {
+                        downloadHlsSource(context, "Audio", client, actualAudioSource, false, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback)
+                    }
+
+                    is JSHLSManifestSource -> {
+                        downloadHlsSource(context, "Audio", client, actualAudioSource, true, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback)
+                    }
+
+                    is DashManifestAudioSourceDelegate -> {
+                        downloadDashSource(context, "Audio", client, actualAudioSource.source, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback)
+                    }
+
+                    else -> throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name)
                 }
-                else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
-            });
+            })
         }
         if (subtitleSource != null) {
             sourcesToDownload.add(async {
@@ -544,7 +693,108 @@ class VideoDownload {
         }
     }
 
-    private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
+    @OptIn(UnstableApi::class)
+    private suspend fun downloadDashSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsManifestUrl2: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
+        if (targetFile.exists()) targetFile.delete()
+
+        var downloadedTotalLength = 0L
+
+        val segmentFiles = arrayListOf<File>()
+        try {
+            val manifestResponse = ManagedHttpClient().get(hlsManifestUrl2)
+            check(manifestResponse.isOk) { "Failed to get DASH manifest: ${manifestResponse.code}" }
+
+            val resolvedUrl = manifestResponse.url
+
+            val manifestContent = manifestResponse.body?.string()
+                ?: throw Exception("Manifest content is empty")
+
+            val inputStream = ByteArrayInputStream(manifestContent.toByteArray())
+            val playlist = DashManifestParser().parse(Uri.parse(resolvedUrl), inputStream)
+
+            val period = playlist.getPeriod(0)
+
+            val representation = when (name) {
+                "Audio" -> {
+                    period.adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO }
+                        .flatMap { it.representations }.filter {
+                            it.format.bitrate.toLong() == targetBitrate
+                        }[0]
+                }
+                "Video" -> {
+                    period.adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO }
+                        .flatMap { it.representations }.filter {
+                            (it.format.width * it.format.height).toLong() == targetPixelCount
+                        }[0]
+                }
+                else -> {
+                    throw Exception("Unknown type")
+                }
+            }
+
+            val segmentIndex = representation.index
+
+            if (segmentIndex != null) {
+                val baseUrl = representation.baseUrls[0]
+                val count = segmentIndex.getSegmentCount(C.TIME_UNSET)
+                for (index in 0 until count) {
+                    val segmentUrl = if (index != 0L) segmentIndex.getSegmentUrl(index)
+                        .resolveUriString(baseUrl.url)
+                    else {
+                        val init = representation.initializationUri ?: continue
+                        init.resolveUriString(baseUrl.url)
+                    }
+                    Logger.i(TAG, "Download '$name' segment $index Sequential")
+                    val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
+                    val outputStream = segmentFile.outputStream()
+                    outputStream.use { os ->
+                        segmentFiles.add(segmentFile)
+
+                        val segmentLength =
+                            downloadSource_Sequential(client, os, segmentUrl, null) { segmentLength, totalRead, lastSpeed ->
+                                val averageSegmentLength =
+                                    if (index == 0L) segmentLength else downloadedTotalLength / index
+                                val expectedTotalLength =
+                                    averageSegmentLength * (count - 1) + segmentLength
+                                onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
+                            }
+
+                        downloadedTotalLength += segmentLength
+                    }
+                }
+            } else {
+                println("No segment index available for representation: ${representation.format.id}")
+            }
+
+            Logger.i(TAG, "Combining segments into $targetFile")
+            combineSegments(context, segmentFiles, targetFile)
+
+            Logger.i(TAG, "$name downloadSource Finished")
+        } catch (ioex: IOException) {
+            if (targetFile.exists()) targetFile.delete()
+            if (ioex.message?.contains("ENOSPC") == true
+            ) throw Exception("Not enough space on device", ioex)
+            else throw ioex
+        } catch (ex: Throwable) {
+            if (targetFile.exists()) targetFile.delete()
+            throw ex
+        } finally {
+            for (segmentFile in segmentFiles) {
+                segmentFile.delete()
+            }
+        }
+        return downloadedTotalLength
+    }
+
+    private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
+        val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+        val secretKey = SecretKeySpec(key, "AES")
+        val ivSpec = IvParameterSpec(iv)
+        cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
+        return cipher.doFinal(encryptedSegment)
+    }
+
+    private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, audio: Boolean, hlsManifestUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
         if(targetFile.exists())
             targetFile.delete();
 
@@ -552,13 +802,68 @@ class VideoDownload {
 
         val segmentFiles = arrayListOf<File>()
         try {
-            val response = client.get(hlsUrl)
+            val masterPlaylistResponse = ManagedHttpClient().get(hlsManifestUrl)
+            check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
+
+            val resolvedSourceUrl = masterPlaylistResponse.url
+
+            val masterPlaylistContent = masterPlaylistResponse.body?.string()
+                ?: throw Exception("Master playlist content is empty")
+
+            val variantUrl = if (source is JSHLSManifestAudioSource){
+                val audioTracks = HLS.parseAndGetAudioSources(source, masterPlaylistContent, resolvedSourceUrl, true)
+
+                val variant = VideoHelper.selectBestAudioSource(audioTracks, arrayOf(), source.language, targetBitrate)
+                if (variant !is IAudioUrlSource){
+                    throw Exception("Variant is not an audio source")
+                }
+                variant.getAudioUrl()
+            }else if (audio && source is JSHLSManifestSource){
+                val audioTracks = HLS.parseAndGetAudioSources(source, masterPlaylistContent, resolvedSourceUrl, true)
+
+                val variant = VideoHelper.selectBestAudioSource(audioTracks, arrayOf(), source.language, targetBitrate)
+                if (variant !is IAudioUrlSource){
+                    throw Exception("Variant is not an audio source")
+                }
+                variant.getAudioUrl()
+            }else if (source is JSHLSManifestSource) {
+                val variants = HLS.parseAndGetVideoSources(source, masterPlaylistContent, resolvedSourceUrl)
+
+                val variant = VideoHelper.selectBestVideoSource(variants, targetPixelCount!!.toInt(), arrayOf())
+                if (variant !is IVideoUrlSource){
+                    throw Exception("Variant is not a video source")
+                }
+                variant.getVideoUrl()
+            }  else {
+                throw Exception("Source is not a HLS manifest")
+            }
+
+            val response = if (source.hasRequestModifier) {
+                val request = source.getRequestModifier()!!.modifyRequest(variantUrl, mapOf())
+                client.get(request.url!!, request.headers.toMutableMap())
+            } else {
+                client.get(variantUrl)
+            }
             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)
+            val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantUrl)
+            val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
+                val keyResponse = if (source.hasRequestModifier) {
+                    val request = source.getRequestModifier()!!.modifyRequest(variantPlaylist.decryptionInfo.keyUrl, mapOf())
+                    client.get(request.url!!, request.headers.toMutableMap())
+                } else {
+                    client.get(variantPlaylist.decryptionInfo.keyUrl)
+                }
+                check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
+
+                DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv.hexStringToByteArray())
+            } else {
+                null
+            }
+
             variantPlaylist.segments.forEachIndexed { index, segment ->
                 if (segment !is HLS.MediaSegment) {
                     return@forEachIndexed
@@ -570,7 +875,7 @@ class VideoDownload {
                 try {
                     segmentFiles.add(segmentFile)
 
-                    val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
+                    val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { 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)
@@ -608,12 +913,11 @@ class VideoDownload {
         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}\""
+    private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) =
+        withContext(Dispatchers.IO) {
+            suspendCancellableCoroutine { continuation ->
+                val cmd =
+                    "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
 
             val statisticsCallback = StatisticsCallback { _ ->
                 //TODO: Show progress?
@@ -623,7 +927,6 @@ class VideoDownload {
             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)) {
@@ -631,7 +934,6 @@ class VideoDownload {
                         } else {
                             "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
                         }
-                        fileList.delete()
                         continuation.resumeWithException(RuntimeException(errorMessage))
                     }
                 },
@@ -751,7 +1053,7 @@ class VideoDownload {
             else {
                 Logger.i(TAG, "Download $name Sequential");
                 try {
-                    sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
+                    sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress);
                 } catch (e: Throwable) {
                     Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
                     throw e
@@ -778,7 +1080,31 @@ class VideoDownload {
         }
         return sourceLength!!;
     }
-    private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
+    // methods are auto generated
+    data class DecryptionInfo(
+        val key: ByteArray,
+        val iv: ByteArray
+    ) {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (javaClass != other?.javaClass) return false
+
+            other as DecryptionInfo
+
+            if (!key.contentEquals(other.key)) return false
+            if (!iv.contentEquals(other.iv)) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = key.contentHashCode()
+            result = 31 * result + iv.contentHashCode()
+            return result
+        }
+    }
+
+    private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, onProgress: (Long, Long, Long) -> Unit): Long {
         val progressRate: Int = 4096 * 5;
         var lastProgressCount: Int = 0;
         val speedRate: Int = 4096 * 5;
@@ -798,6 +1124,8 @@ class VideoDownload {
         val sourceLength = result.body.contentLength();
         val sourceStream = result.body.byteStream();
 
+        val segmentBuffer = ByteArrayOutputStream()
+
         var totalRead: Long = 0;
         try {
             var read: Int;
@@ -808,7 +1136,7 @@ class VideoDownload {
                 if (read < 0)
                     break;
 
-                fileStream.write(buffer, 0, read);
+                segmentBuffer.write(buffer, 0, read);
 
                 totalRead += read;
 
@@ -834,6 +1162,13 @@ class VideoDownload {
             result.body.close()
         }
 
+        if(decryptionInfo != null){
+            val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, decryptionInfo.iv)
+            fileStream.write(decryptedData)
+        }else {
+            fileStream.write(segmentBuffer.toByteArray())
+        }
+
         onProgress(sourceLength, totalRead, 0);
         return sourceLength;
     }
@@ -1025,7 +1360,7 @@ class VideoDownload {
             val expectedFile = File(videoFilePath!!);
             if(!expectedFile.exists())
                 throw IllegalStateException("Video file missing after download");
-            if (videoSource?.container != "application/vnd.apple.mpegurl") {
+            if (videoSourceLive !is IHLSManifestSource && videoSourceLive !is IDashManifestSource) {
                 if (expectedFile.length() != videoFileSize)
                     throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
             }
@@ -1036,7 +1371,7 @@ class VideoDownload {
             val expectedFile = File(audioFilePath!!);
             if(!expectedFile.exists())
                 throw IllegalStateException("Audio file missing after download");
-            if (audioSource?.container != "application/vnd.apple.mpegurl") {
+            if (audioSourceLive !is IHLSManifestAudioSource && audioSourceLive !is IHLSManifestSource && audioSourceLive !is IDashManifestSource) {
                 if (expectedFile.length() != audioFileSize)
                     throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
             }
@@ -1121,7 +1456,7 @@ class VideoDownload {
         val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
 
         fun videoContainerToExtension(container: String): String? {
-            if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
+            if (container.contains("video/mp4"))
                 return "mp4";
             else if (container.contains("application/x-mpegURL"))
                 return "m3u8";
@@ -1133,21 +1468,26 @@ class VideoDownload {
                 return "webm";
             else if (container.contains("video/x-matroska"))
                 return "mkv";
+            else if (container.contains("video/mp2t"))
+                return "m2ts"
+            else if (container == "application/vnd.apple.mpegurl")
+                return "mp4"
             else
                 return "video";
         }
 
         fun audioContainerToExtension(container: String): String {
             if (container.contains("audio/mp4"))
-                return "mp4a";
+                return "m4a";
             else if (container.contains("audio/mpeg"))
-                return "mpga";
+                return "mp3";
+//                return "mpga";
             else if (container.contains("audio/mp3"))
                 return "mp3";
+            else if (container == "application/vnd.apple.mpegurl")
+                return "m4a"
             else if (container.contains("audio/webm"))
                 return "webma";
-            else if (container == "application/vnd.apple.mpegurl")
-                return "mp4";
             else
                 return "audio";
         }
diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt
index 7c1c4e09..b9899946 100644
--- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt
+++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt
@@ -69,7 +69,7 @@ class VideoExport {
             outputFile = f;
         } else if (v != null) {
             val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
-            val f = downloadRoot.createFile(v.container, outputFileName)
+            val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp2t" else v.container, outputFileName)
                 ?: throw Exception("Failed to create file in external directory.");
 
             Logger.i(TAG, "Copying video.");
@@ -81,7 +81,7 @@ class VideoExport {
             outputFile = f;
         } else if (a != null) {
             val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
-            val f = downloadRoot.createFile(a.container, outputFileName)
+            val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "audio/mp3" else a.container, outputFileName)
                 ?: throw Exception("Failed to create file in external directory.");
 
             Logger.i(TAG, "Copying audio.");
diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
index 87e8f051..11db26c2 100644
--- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
+++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
@@ -13,6 +13,7 @@ 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.sources.IAudioSource
 import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
+import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
 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
@@ -46,7 +47,7 @@ class VideoHelper {
             return false
         }
 
-        fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
+        fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource || source is IDashManifestSource) && source !is IWidevineSource
         fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
 
         fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
index 9d1a3faa..a2c09297 100644
--- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
+++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
@@ -1,5 +1,11 @@
 package com.futo.platformplayer.parsers
 
+import android.net.Uri
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
+import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
+import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
 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
@@ -7,13 +13,20 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
 import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
 import com.futo.platformplayer.toYesNo
 import com.futo.platformplayer.yesNoToBoolean
+import java.io.ByteArrayInputStream
 import java.net.URI
+import java.net.URLConnection
 import java.time.ZonedDateTime
 import java.time.format.DateTimeFormatter
 
 class HLS {
     companion object {
-        fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
+        @OptIn(UnstableApi::class)
+        fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist {
+            val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
+            val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
+                .parse(Uri.parse(sourceUrl), inputStream)
+
             val baseUrl = URI(sourceUrl).resolve("./").toString()
 
             val variantPlaylists = mutableListOf<VariantPlaylistReference>()
@@ -21,27 +34,38 @@ class HLS {
             val sessionDataList = mutableListOf<SessionData>()
             var independentSegments = false
 
-            masterPlaylistContent.lines().forEachIndexed { index, line ->
-                when {
-                    line.startsWith("#EXT-X-STREAM-INF") -> {
-                        val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
-                            ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
-                        val url = resolveUrl(baseUrl, nextLine)
-
-                        variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
-                    }
+            if (playlist is HlsMediaPlaylist) {
+                independentSegments = playlist.hasIndependentSegments
+                if (isAudioSource == true) {
+                    val firstSegmentUrlFile =
+                        Uri.parse(playlist.segments[0].url).buildUpon().clearQuery().fragment(null)
+                            .build().toString()
+                    mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile)))
+                } else {
+                    variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
+                }
+            } else if (playlist is HlsMultivariantPlaylist) {
+                masterPlaylistContent.lines().forEachIndexed { index, line ->
+                    when {
+                        line.startsWith("#EXT-X-STREAM-INF") -> {
+                            val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
+                                ?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
+                            val url = resolveUrl(baseUrl, nextLine)
+                            variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
+                        }
 
-                    line.startsWith("#EXT-X-MEDIA") -> {
-                        mediaRenditions.add(parseMediaRendition(line, baseUrl))
-                    }
+                        line.startsWith("#EXT-X-MEDIA") -> {
+                            mediaRenditions.add(parseMediaRendition(line, baseUrl))
+                        }
 
-                    line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
-                        independentSegments = true
-                    }
+                        line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
+                            independentSegments = true
+                        }
 
-                    line.startsWith("#EXT-X-SESSION-DATA") -> {
-                        val sessionData = parseSessionData(line)
-                        sessionDataList.add(sessionData)
+                        line.startsWith("#EXT-X-SESSION-DATA") -> {
+                            val sessionData = parseSessionData(line)
+                            sessionDataList.add(sessionData)
+                        }
                     }
                 }
             }
@@ -61,7 +85,25 @@ class HLS {
             val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
             val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
 
+            val keyInfo =
+                lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
+
+            val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
+            val iv =
+                keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
+
+            val decryptionInfo: DecryptionInfo? = key?.let { k ->
+                iv?.let { i ->
+                    DecryptionInfo(k, i)
+                }
+            }
+
+            val initSegment =
+                lines.find { it.startsWith("#EXT-X-MAP:URI=") }?.substringAfter("=")?.trim('"')
             val segments = mutableListOf<Segment>()
+            if (initSegment != null) {
+                segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
+            }
             var currentSegment: MediaSegment? = null
             lines.forEach { line ->
                 when {
@@ -86,7 +128,7 @@ class HLS {
                 }
             }
 
-            return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
+            return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
         }
 
         fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
@@ -109,10 +151,10 @@ class HLS {
             }
         }
 
-        fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
+        fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List<HLSVariantAudioUrlSource> {
             val masterPlaylist: MasterPlaylist
             try {
-                masterPlaylist = parseMasterPlaylist(content, url)
+                masterPlaylist = parseMasterPlaylist(content, url, isAudioSource)
                 return masterPlaylist.getAudioSources()
             } catch (e: Throwable) {
                 if (content.lines().any { it.startsWith("#EXTINF:") }) {
@@ -203,10 +245,10 @@ class HLS {
         private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
         private fun shouldQuote(key: String, value: String?): Boolean {
             if (value == null)
-                return false;
+                return false
 
             if (value.contains(','))
-                return true;
+                return true
 
             return _quoteList.contains(key)
         }
@@ -270,7 +312,8 @@ class HLS {
         val name: String?,
         val isDefault: Boolean?,
         val isAutoSelect: Boolean?,
-        val isForced: Boolean?
+        val isForced: Boolean?,
+        val container: String? = null
     ) {
         fun toM3U8Line(): String = buildString {
             append("#EXT-X-MEDIA:")
@@ -340,7 +383,7 @@ class HLS {
 
                 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)
+                    "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, it.container?: "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
                     else -> null
                 }
             }
@@ -368,6 +411,11 @@ class HLS {
         }
     }
 
+    data class DecryptionInfo(
+        val keyUrl: String,
+        val iv: String
+    )
+
     data class VariantPlaylist(
         val version: Int?,
         val targetDuration: Int?,
@@ -376,7 +424,8 @@ class HLS {
         val programDateTime: ZonedDateTime?,
         val playlistType: String?,
         val streamInfo: StreamInfo?,
-        val segments: List<Segment>
+        val segments: List<Segment>,
+        val decryptionInfo: DecryptionInfo? = null
     ) {
         fun buildM3U8(): String = buildString {
             append("#EXTM3U\n")
-- 
GitLab


From 8c879c68d985c006bfe7dff765b2b798de549139 Mon Sep 17 00:00:00 2001
From: Kai <git@seaoflaurels.com>
Date: Wed, 22 Jan 2025 16:26:50 -0600
Subject: [PATCH 2/3] fix PeerTube HLS downloads

Changelog: changed
---
 app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
index a2c09297..e7a853cd 100644
--- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
+++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
@@ -99,7 +99,8 @@ class HLS {
             }
 
             val initSegment =
-                lines.find { it.startsWith("#EXT-X-MAP:URI=") }?.substringAfter("=")?.trim('"')
+                lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
+                    ?.substringAfter("=")?.trim('"')
             val segments = mutableListOf<Segment>()
             if (initSegment != null) {
                 segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
-- 
GitLab


From f31abac409808e271eb454672d07a676520f2a54 Mon Sep 17 00:00:00 2001
From: Kai <git@seaoflaurels.com>
Date: Wed, 22 Jan 2025 21:03:36 -0600
Subject: [PATCH 3/3] require the selection of an audio source before
 downloading

Changelog: changed
---
 .../main/java/com/futo/platformplayer/UISlideOverlays.kt  | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
index 116153ce..ce68b361 100644
--- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
+++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
@@ -343,7 +343,9 @@ class UISlideOverlays {
                                     selectedVideoVariant =
                                         DashManifestSourceDelegate(source, it.format.width, it.format.height, it.format.containerMimeType!!)
                                     slideUpMenuOverlay.selectOption(videoButtons, it)
-                                    slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                                    if (audioButtons.isEmpty()){
+                                        slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                                    }
                                 }, invokeParent = false
                             )
                         )
@@ -466,7 +468,9 @@ class UISlideOverlays {
                                 source.setPreferredHeight(it.height)
                                 selectedVideoVariant = source
                                 slideUpMenuOverlay.selectOption(videoButtons, it)
-                                slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                                if (audioButtons.isEmpty()){
+                                    slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                                }
                             },
                             invokeParent = false
                         ))
-- 
GitLab