Compare commits

..

1 Commits

Author SHA1 Message Date
Kai 2697107f76 add support for hls sources with request modifiers
add support for encrypted hls streams

Changelog: changed
2025-02-11 00:16:57 -06:00
45 changed files with 268 additions and 403 deletions
+1 -1
View File
@@ -83,7 +83,7 @@
path = app/src/stable/assets/sources/dailymotion
url = ../plugins/dailymotion.git
[submodule "app/src/stable/assets/sources/apple-podcast"]
path = app/src/stable/assets/sources/apple-podcasts
path = app/src/stable/assets/sources/apple-podcast
url = ../plugins/apple-podcasts.git
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
path = app/src/unstable/assets/sources/apple-podcasts
@@ -644,9 +644,6 @@ class Settings : FragmentedStorageFileJson() {
@Serializable
class Plugins {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false;
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -28,6 +28,9 @@ 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.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
@@ -79,36 +82,6 @@ class UISlideOverlays {
return menu;
}
fun showQueueOptionsOverlay(context: Context, container: ViewGroup) {
UISlideOverlays.showOverlay(container, "Queue options", null, {
}, SlideUpMenuItem(context, R.drawable.ic_playlist, "Save as playlist", "", "Creates a new playlist with queue as videos", null, {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text.trim()
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
StatePlayer.instance.saveQueueAsPlaylist(text);
UIDialogs.appToast("Playlist [${text}] created");
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
}, false));
}
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>();
@@ -299,12 +272,17 @@ class UISlideOverlays {
}
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
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 masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -365,9 +343,7 @@ class UISlideOverlays {
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
@@ -387,7 +363,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, if (source is JSSource) source.hasRequestModifier else false);
slideUpMenuOverlay.hide()
}
@@ -449,7 +425,7 @@ class UISlideOverlays {
}
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
listOf(listOf(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
container.context.getString(R.string.none),
@@ -462,7 +438,7 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)) else listOf()) +
)) +
videoSources
.filter { it.isDownloadable() }
.map {
@@ -507,7 +483,7 @@ class UISlideOverlays {
)
}
is IHLSManifestSource -> {
is JSHLSManifestSource -> {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
@@ -581,7 +557,7 @@ class UISlideOverlays {
);
}
is IHLSManifestAudioSource -> {
is JSHLSManifestAudioSource -> {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
@@ -646,13 +622,13 @@ class UISlideOverlays {
menu.onOK.subscribe {
val sv = selectedVideo
if (sv is IHLSManifestSource) {
if (sv is JSHLSManifestSource) {
showHlsPicker(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
}
@@ -939,7 +915,7 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
if(!isLimited && !video.isLive)
if(!isLimited)
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
@@ -1075,9 +1051,8 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false);
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true);
UIDialogs.appToast("Added to watch later", false);
}),
)
);
@@ -73,7 +73,7 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
current += bytesToSend.toLong()
if (current > end) {
if (current >= end) {
Logger.i(TAG, "Expected amount of bytes sent")
break
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.streams.sources
import android.net.Uri
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
class HLSVariantVideoUrlSource(
override val name: String,
@@ -12,7 +13,8 @@ class HLSVariantVideoUrlSource(
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
val url: String,
val jsSource: JSSource? = null,
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
@@ -27,7 +29,8 @@ class HLSVariantAudioUrlSource(
override val language: String,
override val duration: Long?,
override val priority: Boolean,
val url: String
val url: String,
val jsSource: JSSource? = null,
) : IAudioUrlSource {
override fun getAudioUrl(): String {
return url
@@ -33,13 +33,13 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
}
companion object {
fun fromSource(source: IAudioSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalAudioSource {
fun fromSource(source: IAudioSource, path: String, fileSize: Long): LocalAudioSource {
return LocalAudioSource(
source.name,
path,
fileSize,
source.bitrate,
overrideContainer ?: source.container,
source.container,
source.codec,
source.language
);
@@ -35,7 +35,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
}
companion object {
fun fromSource(source: IVideoSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalVideoSource {
fun fromSource(source: IVideoSource, path: String, fileSize: Long): LocalVideoSource {
return LocalVideoSource(
source.name,
path,
@@ -43,7 +43,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
source.width,
source.height,
source.duration,
overrideContainer ?: source.container,
source.container,
source.codec,
source.bitrate?:0
);
@@ -4,8 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
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.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
@@ -16,8 +14,8 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String;
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource {
override val container : String = "application/dash+xml";
override val name : String;
override val codec: String;
override val bitrate: Int;
@@ -31,14 +29,11 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override val hasGenerate: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrThrow(config, "manifest", contextName);
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
@@ -55,28 +50,15 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
var result: String? = null;
if(_plugin is DevJSClient)
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate");
}
}
else
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate");
}
if(result != null){
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
}
return result;
}
}
@@ -6,8 +6,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.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.other.IStreamMetaDataSource
import com.futo.platformplayer.api.media.models.streams.sources.other.StreamMetaData
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
@@ -22,8 +20,8 @@ interface IJSDashManifestRawSource {
var manifest: String?;
fun generate(): String?;
}
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String;
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource {
override val container : String = "application/dash+xml";
override val name : String;
override val width: Int;
override val height: Int;
@@ -38,14 +36,11 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
override val hasGenerate: Boolean;
val canMerge: Boolean;
override var streamMetaData: StreamMetaData? = null;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
@@ -62,30 +57,17 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
return manifest;
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
var result: String? = null;
if(_plugin is DevJSClient) {
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate");
});
}
}
else
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate");
});
if(result != null){
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
}
return result;
}
}
@@ -118,16 +100,12 @@ class JSDashManifestMergingRawSource(
if(videoDash == null) return null;
//TODO: Temporary simple solution..make more reliable version
var result: String? = null;
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
if(audioAdaptationSet != null) {
result = videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
}
else
result = videoDash;
return result;
return videoDash;
}
companion object {
@@ -64,7 +64,7 @@ class StateCasting {
private val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer();
private val _castServer = ManagedHttpServer(9999);
private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf();
@@ -10,11 +10,10 @@ 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.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
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
@@ -28,12 +27,11 @@ 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.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
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
@@ -44,9 +42,9 @@ 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 +57,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -69,8 +68,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 {
@@ -93,10 +94,10 @@ class VideoDownload {
var audioSource: AudioUrlSource?;
@Contextual
@Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
val videoSourceToUse: IVideoSource? get () = if (videoSource?.container == "application/vnd.apple.mpegurl") videoSource else if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@Contextual
@Transient
val audioSourceToUse: IAudioSource? get () = if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
val audioSourceToUse: IAudioSource? get () = if (audioSource?.container == "application/vnd.apple.mpegurl") audioSource else if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
var requireVideoSource: Boolean = false;
var requireAudioSource: Boolean = false;
@@ -131,6 +132,9 @@ class VideoDownload {
var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false;
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
var progress: Double = 0.0;
var isCancelled = false;
@@ -141,17 +145,11 @@ class VideoDownload {
var error: String? = null;
var videoFilePath: String? = null;
var videoFileNameBase: String? = null;
var videoFileNameExt: String? = null;
val videoFileName: String? get() = if(videoFileNameBase.isNullOrEmpty()) null else videoFileNameBase + (if(!videoFileNameExt.isNullOrEmpty()) "." + videoFileNameExt else "");
var videoOverrideContainer: String? = null;
var videoFileName: String? = null;
var videoFileSize: Long? = null;
var audioFilePath: String? = null;
var audioFileNameBase: String? = null;
var audioFileNameExt: String? = null;
val audioFileName: String? get() = if(audioFileNameBase.isNullOrEmpty()) null else audioFileNameBase + (if(!audioFileNameExt.isNullOrEmpty()) "." + audioFileNameExt else "");
var audioOverrideContainer: String? = null;
var audioFileName: String? = null;
var audioFileSize: Long? = null;
var subtitleFilePath: String? = null;
@@ -186,7 +184,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, hasHLSRequestModifier: Boolean = false) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -197,8 +195,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 || hasHLSRequestModifier
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier || hasHLSRequestModifier
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || this.hasVideoRequestModifier || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || this.hasAudioRequestModifier || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -241,13 +241,11 @@ class VideoDownload {
videoDetails = null;
videoSource = null;
videoSourceLive = null;
videoOverrideContainer = null;
}
if(requiresLiveAudioSource && !isLiveAudioSourceValid) {
videoDetails = null;
audioSource = null;
videoSourceLive = null;
audioOverrideContainer = null;
}
if(video == null && videoDetails == null)
throw IllegalStateException("Missing information for download to complete");
@@ -293,9 +291,14 @@ class VideoDownload {
if(videoSource == null && targetPixelCount != null) {
val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
if (source is JSHLSManifestSource) {
try {
val playlistResponse = client.get(source.url)
val playlistResponse = if (source.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) {
@@ -328,6 +331,10 @@ class VideoDownload {
if(original.video.videoSources.size == 0)
requireVideoSource = false;
}
else if (vsource is HLSVariantVideoUrlSource && vsource.container == "application/vnd.apple.mpegurl") {
videoSource = VideoUrlSource.fromUrlSource(vsource)
videoSourceLive = vsource.jsSource!!
}
else if(vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource)
else if(vsource is JSSource && requiresLiveVideoSource)
@@ -341,9 +348,14 @@ class VideoDownload {
val video = original.video
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) {
if (source is JSHLSManifestAudioSource) {
try {
val playlistResponse = client.get(source.url)
val playlistResponse = if (source.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) {
@@ -358,6 +370,26 @@ class VideoDownload {
}
}
}
for (source in video.videoSources) {
if (source is JSHLSManifestSource) {
try {
val playlistResponse = if (source.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))
}
}
} catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS audio sources", e)
}
}
}
var asource: IAudioSource? = null;
if(targetAudioName != null) {
@@ -375,8 +407,8 @@ class VideoDownload {
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
}
if(asource == null) {
@@ -384,6 +416,10 @@ class VideoDownload {
if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
requireVideoSource = false;
}
else if (asource is HLSVariantAudioUrlSource && asource.container == "application/vnd.apple.mpegurl") {
audioSource = AudioUrlSource.fromUrlSource(asource)
audioSourceLive = asource.jsSource!!
}
else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource)
else if(asource is JSSource && requiresLiveAudioSource)
@@ -418,13 +454,11 @@ class VideoDownload {
else audioSource;
if(actualVideoSource != null) {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName();
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
}
if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
audioFileNameExt = audioContainerToExtension(actualAudioSource!!.container);
audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName();
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
if(subtitleSource != null) {
@@ -468,9 +502,9 @@ class VideoDownload {
}
}
if(actualVideoSource is IVideoUrlSource)
if(videoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
@@ -508,9 +542,9 @@ class VideoDownload {
}
}
if(actualAudioSource is IAudioUrlSource)
if(audioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (audioSourceLive is JSSource) audioSourceLive else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
@@ -564,7 +598,15 @@ class VideoDownload {
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
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?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -572,13 +614,33 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>()
try {
val response = client.get(hlsUrl)
val response = if (source is JSSource && source.hasRequestModifier) {
val request = source.getRequestModifier()!!.modifyRequest(hlsUrl, mapOf())
client.get(request.url!!, request.headers.toMutableMap())
} else {
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)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = if (source is JSSource && 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
@@ -590,7 +652,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)
@@ -630,10 +692,8 @@ class VideoDownload {
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
@@ -643,7 +703,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)) {
@@ -651,7 +710,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))
}
},
@@ -771,7 +829,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
@@ -798,7 +856,31 @@ class VideoDownload {
}
return sourceLength!!;
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
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;
@@ -818,6 +900,8 @@ class VideoDownload {
val sourceLength = result.body.contentLength();
val sourceStream = result.body.byteStream();
val segmentBuffer = ByteArrayOutputStream()
var totalRead: Long = 0;
try {
var read: Int;
@@ -828,7 +912,7 @@ class VideoDownload {
if (read < 0)
break;
fileStream.write(buffer, 0, read);
segmentBuffer.write(buffer, 0, read);
totalRead += read;
@@ -854,6 +938,14 @@ 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;
}
@@ -1072,8 +1164,8 @@ class VideoDownload {
fun complete() {
Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1154,7 +1246,7 @@ class VideoDownload {
else if (container.contains("video/x-matroska"))
return "mkv";
else
return "video";//throw IllegalStateException("Unknown container: " + container)
return "video";
}
fun audioContainerToExtension(container: String): String {
@@ -1165,11 +1257,11 @@ class VideoDownload {
else if (container.contains("audio/mp3"))
return "mp3";
else if (container.contains("audio/webm"))
return "webm";
return "webma";
else if (container == "application/vnd.apple.mpegurl")
return "mp4a";
return "mp4";
else
return "audio";// throw IllegalStateException("Unknown container: " + container)
return "audio";
}
fun subtitleContainerToExtension(container: String?): String {
@@ -39,7 +39,7 @@ class VideoExport {
this.subtitleSource = subtitleSource;
}
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null, documentRoot: DocumentFile? = null): DocumentFile = coroutineScope {
suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
val v = videoSource;
val a = audioSource;
val s = subtitleSource;
@@ -50,7 +50,7 @@ class VideoExport {
if (s != null) sourceCount++;
val outputFile: DocumentFile?;
val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
if (sourceCount > 1) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile("video/mp4", outputFileName)
@@ -238,8 +238,8 @@ class ChannelFragment : MainFragment() {
}
adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
adapter.onUrlClicked.subscribe { url ->
@@ -82,8 +82,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
};
adapter.onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]");
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
};
adapter.onLongPress.subscribe(this) {
@@ -10,7 +10,6 @@ import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -27,7 +26,6 @@ class CreatorsFragment : MainFragment() {
private var _overlayContainer: FrameLayout? = null;
private var _containerSearch: FrameLayout? = null;
private var _editSearch: EditText? = null;
private var _textMeta: TextView? = null;
private var _buttonClearSearch: ImageButton? = null
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -36,7 +34,6 @@ class CreatorsFragment : MainFragment() {
val editSearch: EditText = view.findViewById(R.id.edit_search);
val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
_editSearch = editSearch
_textMeta = view.findViewById(R.id.text_meta);
_buttonClearSearch = buttonClearSearch
buttonClearSearch.setOnClickListener {
editSearch.text.clear()
@@ -44,11 +41,7 @@ class CreatorsFragment : MainFragment() {
_buttonClearSearch?.visibility = View.INVISIBLE;
}
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs ->
_textMeta?.let {
it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}";
}
};
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
@@ -22,7 +22,6 @@ import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder
@@ -216,7 +215,7 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE;
} else {
_listDownloadedHeader.visibility = VISIBLE;
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()}${if(downloaded.size > 0) ", ${downloaded.sumOf { it.duration }.toHumanDuration(false)}" else ""})";
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})";
}
lastDownloads = downloaded;
@@ -8,7 +8,6 @@ import android.view.ViewGroup
import androidx.core.app.ShareCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -79,14 +78,6 @@ class PlaylistFragment : MainFragment() {
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput);
_buttonExport.setOnClickListener {
_playlist?.let {
val context = StateApp.instance.contextOrNull ?: return@let;
if(context is IWithResultLauncher)
StateDownloads.instance.exportPlaylist(context, it.id);
}
};
_buttonDownload.visibility = View.VISIBLE;
editPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
@@ -185,7 +176,6 @@ class PlaylistFragment : MainFragment() {
setVideos(parameter.videos, true)
setMetadata(parameter.videos.size, parameter.videos.sumOf { it.duration })
setButtonDownloadVisible(true)
setButtonExportVisible(false)
setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
@@ -882,7 +882,7 @@ class VideoDetailView : ConstraintLayout {
_slideUpOverlay?.hide();
}
else null,
if(!isLimitedVersion && !(video?.isLive ?: false))
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let {
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
@@ -2595,13 +2595,8 @@ class VideoDetailView : ConstraintLayout {
onAddToWatchLaterClicked.subscribe(this) {
if(it is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true))
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
}
onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it);
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true);
UIDialogs.toast("Added to watch later\n[${it.name}]");
}
}
})
@@ -34,7 +34,6 @@ abstract class VideoListEditorView : LinearLayout {
protected var overlayContainer: FrameLayout
private set;
protected var _buttonDownload: ImageButton;
protected var _buttonExport: ImageButton;
private var _buttonShare: ImageButton;
private var _buttonEdit: ImageButton;
@@ -55,8 +54,6 @@ abstract class VideoListEditorView : LinearLayout {
_buttonEdit = findViewById(R.id.button_edit);
_buttonDownload = findViewById(R.id.button_download);
_buttonDownload.visibility = View.GONE;
_buttonExport = findViewById(R.id.button_export);
_buttonExport.visibility = View.GONE;
_buttonShare = findViewById(R.id.button_share);
val onShare = _onShare;
@@ -71,7 +68,6 @@ abstract class VideoListEditorView : LinearLayout {
buttonShuffle.setOnClickListener { onShuffleClick(); };
_buttonEdit.setOnClickListener { onEditClick(); };
setButtonExportVisible(false);
setButtonDownloadVisible(canEdit());
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
@@ -112,7 +108,6 @@ abstract class VideoListEditorView : LinearLayout {
_buttonDownload.setBackgroundResource(R.drawable.background_button_round);
if(isDownloading) {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
_buttonDownload.setOnClickListener {
@@ -122,7 +117,6 @@ abstract class VideoListEditorView : LinearLayout {
}
}
else if(isDownloaded) {
setButtonExportVisible(true)
_buttonDownload.setImageResource(R.drawable.ic_download_off);
_buttonDownload.setOnClickListener {
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
@@ -131,7 +125,6 @@ abstract class VideoListEditorView : LinearLayout {
}
}
else {
setButtonExportVisible(false);
_buttonDownload.setImageResource(R.drawable.ic_download);
_buttonDownload.setOnClickListener {
onDownload();
@@ -178,9 +171,6 @@ abstract class VideoListEditorView : LinearLayout {
protected fun setButtonDownloadVisible(isVisible: Boolean) {
_buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE;
}
protected fun setButtonExportVisible(isVisible: Boolean) {
_buttonExport.visibility = if (isVisible) View.VISIBLE else View.GONE;
}
protected fun setButtonEditVisible(isVisible: Boolean) {
_buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE;
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtit
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.platforms.js.models.sources.JSSource
import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean
import java.net.URI
@@ -61,7 +62,28 @@ 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:") }?.substringAfter(":")?.split(",")?.get(0)
?.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,14 +108,14 @@ 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> {
fun parseAndGetVideoSources(source: JSSource, content: String, url: String): List<HLSVariantVideoUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getVideoSources()
return masterPlaylist.getVideoSources(source)
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
@@ -109,11 +131,11 @@ class HLS {
}
}
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
fun parseAndGetAudioSources(source: JSSource, content: String, url: String): List<HLSVariantAudioUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getAudioSources()
return masterPlaylist.getAudioSources(source)
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
@@ -317,7 +339,7 @@ class HLS {
return builder.toString()
}
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
fun getVideoSources(source: JSSource? = null): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map {
var width: Int? = null
var height: Int? = null
@@ -328,11 +350,11 @@ class HLS {
}
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)
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url, source)
}
}
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
fun getAudioSources(source: JSSource? = null): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
@@ -340,7 +362,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, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri, source)
else -> null
}
}
@@ -368,6 +390,11 @@ class HLS {
}
}
data class DecryptionInfo(
val keyUrl: String,
val iv: String
)
data class VariantPlaylist(
val version: Int?,
val targetDuration: Int?,
@@ -376,7 +403,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")
@@ -3,11 +3,9 @@ package com.futo.platformplayer.states
import android.content.ContentResolver
import android.content.Context
import android.os.StatFs
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
@@ -48,17 +46,6 @@ class StateDownloads {
private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath);
private val _downloaded = FragmentedStorage.storeJson<VideoLocal>("downloaded")
.withOnModified({
synchronized(_downloadedSet) {
if(!_downloadedSet.contains(it.id))
_downloadedSet.add(it.id);
}
}, {
synchronized(_downloadedSet) {
if(_downloadedSet.contains(it.id))
_downloadedSet.remove(it.id);
}
})
.load()
.apply { afterLoadingDownloaded(this) };
private val _downloading = FragmentedStorage.storeJson<VideoDownload>("downloading")
@@ -98,6 +85,9 @@ class StateDownloads {
Logger.i("StateDownloads", "Deleting local video ${id.value}");
val downloaded = getCachedVideo(id);
if(downloaded != null) {
synchronized(_downloadedSet) {
_downloadedSet.remove(id);
}
_downloaded.delete(downloaded);
}
onDownloadedChanged.emit();
@@ -271,6 +261,9 @@ class StateDownloads {
if(existing.groupID == null) {
existing.groupID = VideoDownload.GROUP_WATCHLATER;
existing.groupType = VideoDownload.GROUP_WATCHLATER;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing);
}
}
@@ -313,6 +306,9 @@ class StateDownloads {
if(existing.groupID == null) {
existing.groupID = playlist.id;
existing.groupType = VideoDownload.GROUP_PLAYLIST;
synchronized(_downloadedSet) {
_downloadedSet.add(existing.id);
}
_downloaded.save(existing);
}
}
@@ -340,8 +336,8 @@ class StateDownloads {
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
download(VideoDownload(video, targetPixelcount, targetBitrate));
}
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, hasRequestModifier: Boolean = false) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource, hasRequestModifier));
}
private fun download(videoState: VideoDownload, notify: Boolean = true) {
@@ -470,65 +466,6 @@ class StateDownloads {
return _downloadsDirectory;
}
fun exportPlaylist(context: Context, playlistId: String) {
if(context is IWithResultLauncher)
StateApp.instance.requestDirectoryAccess(context, "Export Playlist", "To export playlist to directory", null) {
if (it == null)
return@requestDirectoryAccess;
val root = DocumentFile.fromTreeUri(context, it!!);
val playlist = StatePlaylists.instance.getPlaylist(playlistId);
var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId);
if(playlist != null) {
val missing = playlist.videos
.filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } }
.map { getCachedVideo(it.id) }
.filterNotNull();
if(missing.size > 0)
localVideos = localVideos + missing;
};
var lastNotifyTime = -1L;
UIDialogs.showDialogProgress(context) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
it.setText("Exporting videos..");
var i = 0;
var success = 0;
for (video in localVideos) {
withContext(Dispatchers.Main) {
it.setText("Exporting videos...(${i}/${localVideos.size})");
//it.setProgress(i.toDouble() / localVideos.size);
}
try {
val export = VideoExport(video, video.videoSource.firstOrNull(), video.audioSource.firstOrNull(), video.subtitlesSources.firstOrNull());
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
val file = export.export(context, { progress ->
val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress);
lastNotifyTime = now;
}
}, root);
success++;
} catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${video.name}]: ${ex.message}", ex);
}
i++;
}
withContext(Dispatchers.Main) {
it.setProgress(1f);
it.dismiss();
UIDialogs.appToast("Finished exporting playlist (${success} videos${if(i < success) ", ${i} errors" else ""})");
}
};
}
}
}
fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) {
var lastNotifyTime = -1L;
@@ -540,13 +477,13 @@ class StateDownloads {
try {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
val file = export.export(context, { progress ->
val file = export.export(context) { progress ->
val now = System.currentTimeMillis();
if (lastNotifyTime == -1L || now - lastNotifyTime > 100) {
it.setProgress(progress);
lastNotifyTime = now;
}
}, null);
}
withContext(Dispatchers.Main) {
it.setProgress(100.0f)
@@ -13,7 +13,6 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
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.SerializedPlatformVideo
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
@@ -131,12 +130,6 @@ class StatePlayer {
closeMediaSession();
}
fun saveQueueAsPlaylist(name: String){
val videos = _queue.toList();
val playlist = Playlist(name, videos.map { SerializedPlatformVideo.fromVideo(it) });
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
}
//Notifications
fun hasMediaSession() : Boolean {
return MediaPlaybackService.getService() != null;
@@ -177,11 +177,8 @@ class StatePlaylists {
StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER);
}
}
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean {
var wasNew = false;
fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1) {
synchronized(_watchlistStore) {
if(!_watchlistStore.hasItem { it.url == video.url })
wasNew = true;
_watchlistStore.saveAsync(video);
if(orderPosition == -1)
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
@@ -201,7 +198,6 @@ class StatePlaylists {
}
StateDownloads.instance.checkForOutdatedPlaylists();
return wasNew;
}
fun getLastPlayedPlaylist() : Playlist? {
@@ -2,7 +2,6 @@ package com.futo.platformplayer.states
import android.content.Context
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.LoginActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -102,8 +101,6 @@ class StatePlugins {
if (availableClient !is JSClient) {
continue
}
if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && !StatePlatform.instance.isClientEnabled(availableClient.id))
continue;
val newConfig = checkForUpdates(availableClient.config);
if (newConfig != null) {
@@ -33,9 +33,6 @@ class ManagedStore<T>{
val className: String? get() = _class.classifier?.assume<KClass<*>>()?.simpleName;
private var _onModificationCreate: ((T) -> Unit)? = null;
private var _onModificationDelete: ((T) -> Unit)? = null;
val name: String;
constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer<T>, niceName: String? = null) {
@@ -65,12 +62,6 @@ class ManagedStore<T>{
return this;
}
fun withOnModified(created: (T)->Unit, deleted: (T)->Unit): ManagedStore<T> {
_onModificationCreate = created;
_onModificationDelete = deleted;
return this;
}
fun load(): ManagedStore<T> {
synchronized(_files) {
_files.clear();
@@ -274,7 +265,6 @@ class ManagedStore<T>{
file = saveNew(obj);
if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction))
saveReconstruction(file, obj);
_onModificationCreate?.invoke(obj)
}
}
}
@@ -310,7 +300,6 @@ class ManagedStore<T>{
_files.remove(item);
Logger.v(TAG, "Deleting file ${logName(file.id)}");
file.delete();
_onModificationDelete?.invoke(item)
}
}
}
@@ -16,7 +16,6 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
private lateinit var _sortedDataset: List<Subscription>;
private val _inflater: LayoutInflater;
private val _confirmationMessage: String;
private val _onDatasetChanged: ((List<Subscription>)->Unit)?;
var onClick = Event1<Subscription>();
var onSettings = Event1<Subscription>();
@@ -31,10 +30,9 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
updateDataset();
}
constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List<Subscription>)->Unit)? = null) : super() {
constructor(inflater: LayoutInflater, confirmationMessage: String) : super() {
_inflater = inflater;
_confirmationMessage = confirmationMessage;
_onDatasetChanged = onDatasetChanged;
StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper())
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() }
@@ -80,8 +78,6 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
.filter { (queryLower.isNullOrBlank() || it.channel.name.lowercase().contains(queryLower)) }
.toList();
_onDatasetChanged?.invoke(_sortedDataset);
notifyDataSetChanged();
}
@@ -132,7 +132,7 @@ open class PreviewVideoView : LinearLayout {
fun hideAddTo() {
_button_add_to.visibility = View.GONE
//_button_add_to_queue.visibility = View.GONE
_button_add_to_queue.visibility = View.GONE
}
protected open fun inflate(feedStyle: FeedStyle) {
@@ -2,26 +2,16 @@ package com.futo.platformplayer.views.overlays
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.views.lists.VideoListEditorView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
class QueueEditorOverlay : LinearLayout {
private val _topbar : OverlayTopbar;
private val _editor : VideoListEditorView;
private val _btnSettings: ImageView;
private val _overlayContainer: FrameLayout;
val onClose = Event0();
@@ -29,9 +19,6 @@ class QueueEditorOverlay : LinearLayout {
inflate(context, R.layout.overlay_queue, this)
_topbar = findViewById(R.id.topbar);
_editor = findViewById(R.id.editor);
_btnSettings = findViewById(R.id.button_settings);
_overlayContainer = findViewById(R.id.overlay_container_queue);
_topbar.onClose.subscribe(this, onClose::emit);
_editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) }
@@ -41,10 +28,6 @@ class QueueEditorOverlay : LinearLayout {
}
_editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) }
_btnSettings.setOnClickListener {
handleSettings();
}
_topbar.setInfo(context.getString(R.string.queue), "");
}
@@ -57,8 +40,4 @@ class QueueEditorOverlay : LinearLayout {
fun cleanup() {
_topbar.onClose.remove(this);
}
fun handleSettings() {
UISlideOverlays.showQueueOptionsOverlay(context, _overlayContainer);
}
}
+1 -10
View File
@@ -16,7 +16,7 @@
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="110dp"
android:layout_height="100dp"
android:minHeight="0dp"
app:layout_scrollFlags="scroll"
app:contentInsetStart="0dp"
@@ -77,16 +77,7 @@
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
</LinearLayout>
<TextView
android:id="@+id/text_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="9dp"
android:textAlignment="center"
android:textColor="#333333"
android:text="0 creators" />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>
@@ -54,22 +54,6 @@
<ImageButton
android:id="@+id/button_export"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/cd_button_download"
android:background="@drawable/background_button_round"
android:gravity="center"
android:layout_marginStart="10dp"
android:orientation="horizontal"
app:srcCompat="@drawable/ic_export"
app:layout_constraintRight_toLeftOf="@id/button_share"
app:layout_constraintTop_toTopOf="@id/button_share"
app:tint="@color/white"
android:padding="8dp"
android:layout_marginRight="10dp"
android:scaleType="fitCenter" />
<ImageButton
android:id="@+id/button_share"
android:layout_width="40dp"
-15
View File
@@ -21,20 +21,5 @@
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/topbar"
app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/button_settings"
android:background="@drawable/background_pill"
android:padding="5dp"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:srcCompat="@drawable/ic_settings" />
<FrameLayout
android:id="@+id/overlay_container_queue"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>
-2
View File
@@ -286,8 +286,6 @@
<string name="also_removes_any_data_related_plugin_like_login_or_settings">Also removes any data related plugin like login or settings</string>
<string name="announcement">Announcement</string>
<string name="notifications">Notifications</string>
<string name="check_disabled_plugin_updates">Check disabled plugins for updates</string>
<string name="check_disabled_plugin_updates_description">Check disabled plugins for updates</string>
<string name="planned_content_notifications">Planned Content Notifications</string>
<string name="planned_content_notifications_description">Schedules discovered planned content as notifications, resulting in more accurate notifications for this content.</string>
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
+1 -2
View File
@@ -12,8 +12,7 @@
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json"
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"
+1 -2
View File
@@ -12,8 +12,7 @@
"cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json",
"4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json",
"9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json",
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json"
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json"
},
"SOURCES_EMBEDDED_DEFAULT": [
"35ae969a-a7db-11ed-afa1-0242ac120002"