From d4ccf232c1251241a416ee895a14415759856540 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 17:57:27 -0600 Subject: [PATCH 01/32] fix HLS download odysee nebula peertube Changelog: changed --- .../main/java/com/futo/platformplayer/UISlideOverlays.kt | 8 +++++--- .../com/futo/platformplayer/downloads/VideoDownload.kt | 8 ++------ app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 6 ++++++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 582a74f7..36f0b8e1 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -280,6 +280,8 @@ class UISlideOverlays { val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") + val resolvedPlaylistUrl = masterPlaylistResponse.url + val videoButtons = arrayListOf() val audioButtons = arrayListOf() //TODO: Implement subtitles @@ -292,7 +294,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl) masterPlaylist.getAudioSources().forEach { it -> @@ -366,11 +368,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, resolvedPlaylistUrl), 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, resolvedPlaylistUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { 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 8ba2814f..a9a4e2bb 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -620,10 +620,8 @@ class VideoDownload { private suspend fun combineSegments(context: Context, segmentFiles: List, 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? @@ -633,7 +631,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)) { @@ -641,7 +638,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)) } }, 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..eacdf546 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -61,7 +61,13 @@ 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 initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { From d5cab0910e9dbe7925c09a6266d71b3f21a78044 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 22:21:06 -0600 Subject: [PATCH 02/32] fix HLS audio download and download audio only Changelog: changed --- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../platformplayer/downloads/VideoDownload.kt | 14 ++-- .../platformplayer/downloads/VideoExport.kt | 2 +- .../com/futo/platformplayer/parsers/HLS.kt | 78 +++++++++++++------ 4 files changed, 63 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 582a74f7..a78cd611 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -292,7 +292,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource) masterPlaylist.getAudioSources().forEach { it -> 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 8ba2814f..011db8de 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -414,7 +414,7 @@ class VideoDownload { videoFilePath = File(downloadDir, videoFileName!!).absolutePath; } if(actualAudioSource != null) { - audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource!!.container)}".sanitizeFileName(); + audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(if (actualAudioSource.container == "application/vnd.apple.mpegurl") actualAudioSource.codec else actualAudioSource.container)}".sanitizeFileName(); audioFilePath = File(downloadDir, audioFileName!!).absolutePath; } if(subtitleSource != null) { @@ -620,10 +620,8 @@ class VideoDownload { private suspend fun combineSegments(context: Context, segmentFiles: List, 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? @@ -633,7 +631,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)) { @@ -641,7 +638,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)) } }, @@ -1150,8 +1146,10 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; + else if (container.contains("video/mp4")) + return "mp4"; else if (container.contains("audio/mpeg")) - return "mpga"; + return "mp3"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) 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..7ebb70ff 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -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") a.codec 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/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 9d1a3faa..bc01b4a6 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() @@ -21,27 +34,39 @@ class HLS { val sessionDataList = mutableListOf() 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) + if (playlist is HlsMediaPlaylist) { + independentSegments = playlist.hasIndependentSegments + if (isAudioSource == true) { + val firstSegmentUrlFile = + Uri.parse(playlist.segments[0].initializationSegment?.url ?: 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))) - } + 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 +86,13 @@ 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 initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -109,10 +140,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List { + fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List { 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:") }) { @@ -270,7 +301,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 +372,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.container ?: "", it.language ?: "", null, false, it.uri) else -> null } } @@ -376,7 +408,7 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From 3d258180bd1588a659b3079b4f1b887cd5fd43fe Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 11 Feb 2025 10:31:47 -0600 Subject: [PATCH 03/32] restore hard code HLS as mp4 Changelog: changed --- .../com/futo/platformplayer/downloads/VideoDownload.kt | 6 +++--- .../java/com/futo/platformplayer/downloads/VideoExport.kt | 6 +++--- app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 8 ++------ 3 files changed, 8 insertions(+), 12 deletions(-) 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 011db8de..76528cd8 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -410,11 +410,11 @@ class VideoDownload { else audioSource; if(actualVideoSource != null) { - videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource!!.container)}".sanitizeFileName(); + videoFileName = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}].${videoContainerToExtension(actualVideoSource.container)}".sanitizeFileName(); videoFilePath = File(downloadDir, videoFileName!!).absolutePath; } if(actualAudioSource != null) { - audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(if (actualAudioSource.container == "application/vnd.apple.mpegurl") actualAudioSource.codec else actualAudioSource.container)}".sanitizeFileName(); + audioFileName = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}].${audioContainerToExtension(actualAudioSource.container)}".sanitizeFileName(); audioFilePath = File(downloadDir, audioFileName!!).absolutePath; } if(subtitleSource != null) { @@ -1149,7 +1149,7 @@ class VideoDownload { else if (container.contains("video/mp4")) return "mp4"; else if (container.contains("audio/mpeg")) - return "mp3"; + return "mpga"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) 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 7ebb70ff..6761168c 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/mp4" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" 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/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index bc01b4a6..916bc74c 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -37,10 +37,7 @@ class HLS { if (playlist is HlsMediaPlaylist) { independentSegments = playlist.hasIndependentSegments if (isAudioSource == true) { - val firstSegmentUrlFile = - Uri.parse(playlist.segments[0].initializationSegment?.url ?: 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))) + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null)) } else { variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) } @@ -302,7 +299,6 @@ class HLS { val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean?, - val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -372,7 +368,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.container ?: "", 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) else -> null } } From 4bc561ceab6319ac42409aafa629664b9b595931 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 22:21:06 -0600 Subject: [PATCH 04/32] fix HLS audio download and download audio only Changelog: changed --- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../platformplayer/downloads/VideoDownload.kt | 12 ++- .../platformplayer/downloads/VideoExport.kt | 2 +- .../com/futo/platformplayer/parsers/HLS.kt | 78 +++++++++++++------ 4 files changed, 62 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index f1d42838..ff587dd5 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -292,7 +292,7 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource) masterPlaylist.getAudioSources().forEach { it -> 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 cd4ae885..0445da00 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -630,10 +630,8 @@ class VideoDownload { private suspend fun combineSegments(context: Context, segmentFiles: List, 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 +641,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 +648,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)) } }, @@ -1160,8 +1156,10 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; + else if (container.contains("video/mp4")) + return "mp4"; else if (container.contains("audio/mpeg")) - return "mpga"; + return "mp3"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) 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..7ebb70ff 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -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") a.codec 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/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 9d1a3faa..bc01b4a6 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() @@ -21,27 +34,39 @@ class HLS { val sessionDataList = mutableListOf() 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) + if (playlist is HlsMediaPlaylist) { + independentSegments = playlist.hasIndependentSegments + if (isAudioSource == true) { + val firstSegmentUrlFile = + Uri.parse(playlist.segments[0].initializationSegment?.url ?: 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))) - } + 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 +86,13 @@ 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 initSegment = + lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) + ?.substringAfter("=")?.trim('"') val segments = mutableListOf() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -109,10 +140,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String): List { + fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List { 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:") }) { @@ -270,7 +301,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 +372,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.container ?: "", it.language ?: "", null, false, it.uri) else -> null } } @@ -376,7 +408,7 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From ca781dfe153d3b8ed11b2d3d86c0f5c282442b67 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 11 Feb 2025 10:31:47 -0600 Subject: [PATCH 05/32] restore hard code HLS as mp4 Changelog: changed --- .../com/futo/platformplayer/downloads/VideoDownload.kt | 2 +- .../java/com/futo/platformplayer/downloads/VideoExport.kt | 6 +++--- app/src/main/java/com/futo/platformplayer/parsers/HLS.kt | 8 ++------ 3 files changed, 6 insertions(+), 10 deletions(-) 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 0445da00..f845cf92 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1159,7 +1159,7 @@ class VideoDownload { else if (container.contains("video/mp4")) return "mp4"; else if (container.contains("audio/mpeg")) - return "mp3"; + return "mpga"; else if (container.contains("audio/mp3")) return "mp3"; else if (container.contains("audio/webm")) 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 7ebb70ff..6761168c 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/mp4" else v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") a.codec else a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" 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/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index bc01b4a6..916bc74c 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -37,10 +37,7 @@ class HLS { if (playlist is HlsMediaPlaylist) { independentSegments = playlist.hasIndependentSegments if (isAudioSource == true) { - val firstSegmentUrlFile = - Uri.parse(playlist.segments[0].initializationSegment?.url ?: 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))) + mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null)) } else { variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) } @@ -302,7 +299,6 @@ class HLS { val isDefault: Boolean?, val isAutoSelect: Boolean?, val isForced: Boolean?, - val container: String? = null, ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -372,7 +368,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.container ?: "", 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) else -> null } } From 9014fb581dc780732f606f4723f0dfae86234f5d Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 20 Feb 2025 16:07:35 -0600 Subject: [PATCH 06/32] add support for downloading encrypted HLS streams Changelog: changed --- .../platformplayer/downloads/VideoDownload.kt | 63 +++++++++++++++++-- .../com/futo/platformplayer/parsers/HLS.kt | 30 ++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) 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 ede24707..10d48130 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -47,6 +47,7 @@ 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 +60,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,6 +71,9 @@ 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 @@ -564,6 +569,14 @@ class VideoDownload { } } + 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, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { if(targetFile.exists()) targetFile.delete(); @@ -579,6 +592,14 @@ class VideoDownload { ?: throw Exception("Variant playlist content is empty") val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) + val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { + val keyResponse = 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 +611,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) @@ -771,7 +792,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 +819,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 +863,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 +875,7 @@ class VideoDownload { if (read < 0) break; - fileStream.write(buffer, 0, read); + segmentBuffer.write(buffer, 0, read); totalRead += read; @@ -854,6 +901,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; } 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..1faee408 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -61,7 +61,27 @@ 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() + if (initSegment != null) { + segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) + } + var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -86,7 +106,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 { @@ -368,6 +388,11 @@ class HLS { } } + data class DecryptionInfo( + val keyUrl: String, + val iv: String + ) + data class VariantPlaylist( val version: Int?, val targetDuration: Int?, @@ -376,7 +401,8 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List + val segments: List, + val decryptionInfo: DecryptionInfo? = null ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") From 470b7bd2e5e68d5c7ef78c3b3c00440cc908f4a6 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 20 Feb 2025 21:27:44 -0600 Subject: [PATCH 07/32] add non iv version Changelog: added --- .../platformplayer/downloads/VideoDownload.kt | 34 +++++++++++-------- .../com/futo/platformplayer/parsers/HLS.kt | 6 ++-- 2 files changed, 21 insertions(+), 19 deletions(-) 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 10d48130..903671b9 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -65,6 +65,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.Thread.sleep +import java.nio.ByteBuffer import java.time.OffsetDateTime import java.util.UUID import java.util.concurrent.Executors @@ -593,9 +594,9 @@ class VideoDownload { val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { - val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl) + val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl, mutableMapOf("Cookie" to "sails.sid=s%3AeSKom53v4W3_0CliWJFFMj9k3hcAuyhx.Nf9lF1sUSQ0GUvCKBOM64bsV%2BZMOkiKke43eHO6gTZI;")) check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" } - DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv.hexStringToByteArray()) + DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray()) } else { null } @@ -611,7 +612,7 @@ class VideoDownload { try { segmentFiles.add(segmentFile) - val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { segmentLength, totalRead, lastSpeed -> + val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { 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) @@ -651,10 +652,8 @@ class VideoDownload { private suspend fun combineSegments(context: Context, segmentFiles: List, 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? @@ -664,7 +663,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)) { @@ -672,7 +670,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)) } }, @@ -792,7 +789,7 @@ class VideoDownload { else { Logger.i(TAG, "Download $name Sequential"); try { - sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress); + sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress); } catch (e: Throwable) { Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") throw e @@ -822,7 +819,7 @@ class VideoDownload { data class DecryptionInfo( val key: ByteArray, - val iv: ByteArray + val iv: ByteArray? ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -843,7 +840,7 @@ class VideoDownload { } } - private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, onProgress: (Long, Long, Long) -> Unit): Long { + private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long { val progressRate: Int = 4096 * 5; var lastProgressCount: Int = 0; val speedRate: Int = 4096 * 5; @@ -902,8 +899,15 @@ class VideoDownload { } if (decryptionInfo != null) { - val decryptedData = - decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, decryptionInfo.iv) + var iv = decryptionInfo.iv + if (iv == null) { + iv = ByteBuffer.allocate(16) + .putLong(0L) + .putLong(index.toLong()) + .array() + } + + val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!) fileStream.write(decryptedData) } else { fileStream.write(segmentBuffer.toByteArray()) @@ -1222,7 +1226,7 @@ class VideoDownload { else if (container.contains("audio/webm")) return "webm"; else if (container == "application/vnd.apple.mpegurl") - return "mp4a"; + return "mp4"; else return "audio";// throw IllegalStateException("Unknown container: " + container) } 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 1faee408..3d1ce821 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -69,9 +69,7 @@ class HLS { keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x") val decryptionInfo: DecryptionInfo? = key?.let { k -> - iv?.let { i -> - DecryptionInfo(k, i) - } + DecryptionInfo(k, iv) } val initSegment = @@ -390,7 +388,7 @@ class HLS { data class DecryptionInfo( val keyUrl: String, - val iv: String + val iv: String? ) data class VariantPlaylist( From 20fb1e0fd09cc6b7219ccfbc5bb70d0141348186 Mon Sep 17 00:00:00 2001 From: Tim B Date: Wed, 16 Apr 2025 02:44:40 +0200 Subject: [PATCH 08/32] Added monochrome launcher icon --- .../main/res/drawable/ic_launcher_monochrome.xml | 16 ++++++++++++++++ .../main/res/mipmap-anydpi-v26/ic_launcher.xml | 1 + .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 1 + 3 files changed, 18 insertions(+) create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..405bd330 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 036d09bc..081998b2 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 036d09bc..081998b2 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file From 50ecb909b4cc3f71b34a5de0f2d63c058f0c7baa Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 9 May 2025 10:08:24 -0500 Subject: [PATCH 09/32] Remove cookie Changelog: changed --- .../java/com/futo/platformplayer/downloads/VideoDownload.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 903671b9..6b1f0f78 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -594,7 +594,7 @@ class VideoDownload { val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { - val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl, mutableMapOf("Cookie" to "sails.sid=s%3AeSKom53v4W3_0CliWJFFMj9k3hcAuyhx.Nf9lF1sUSQ0GUvCKBOM64bsV%2BZMOkiKke43eHO6gTZI;")) + val keyResponse = 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 { From dc415df8c011f14803376e7076561022ef13acbd Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Fri, 9 May 2025 17:35:36 +0000 Subject: [PATCH 10/32] Edit VideoDownload.kt --- .../java/com/futo/platformplayer/downloads/VideoDownload.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6b1f0f78..861806f6 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1218,7 +1218,7 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) - return "mp4a"; + return "mp4"; else if (container.contains("audio/mpeg")) return "mpga"; else if (container.contains("audio/mp3")) From 5d0e6615abd24e806d54a42d0c41a7445d93e669 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Fri, 9 May 2025 17:36:27 +0000 Subject: [PATCH 11/32] Edit VideoDownload.kt --- .../java/com/futo/platformplayer/downloads/VideoDownload.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 861806f6..2ac61129 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1218,7 +1218,7 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) - return "mp4"; + return "mp4a"; else if (container.contains("audio/mpeg")) return "mpga"; else if (container.contains("audio/mp3")) @@ -1226,7 +1226,7 @@ class VideoDownload { else if (container.contains("audio/webm")) return "webm"; else if (container == "application/vnd.apple.mpegurl") - return "mp4"; + return "mp4a"; else return "audio";// throw IllegalStateException("Unknown container: " + container) } From 1d1728b92bf4e89fdd9d7fd621b8690869cd7652 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 22 May 2025 13:44:50 -0500 Subject: [PATCH 12/32] switch audio hls to be an mp4 file Changelog: changed --- .../java/com/futo/platformplayer/downloads/VideoDownload.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 060affeb..4a2da3d6 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1163,7 +1163,7 @@ class VideoDownload { else if (container.contains("audio/webm")) return "webm"; else if (container == "application/vnd.apple.mpegurl") - return "mp4a"; + return "mp4"; else return "audio";// throw IllegalStateException("Unknown container: " + container) } From 1509c11f6477abbdc4a163bbe5e262ebf6db0988 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 22 May 2025 15:00:34 -0500 Subject: [PATCH 13/32] check to see if an HLS playlist is a master playlist before parsing it Changelog: changed --- .../futo/platformplayer/UISlideOverlays.kt | 180 ++++++++++++------ .../com/futo/platformplayer/parsers/HLS.kt | 101 +++++----- 2 files changed, 172 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index ff587dd5..3a17305f 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -4,8 +4,14 @@ 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.util.UnstableApi +import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity @@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.parsers.HLS +import com.futo.platformplayer.parsers.HLS.MediaRendition +import com.futo.platformplayer.parsers.HLS.StreamInfo +import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateHistory @@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import androidx.core.net.toUri class UISlideOverlays { companion object { @@ -269,6 +280,7 @@ class UISlideOverlays { } + @OptIn(UnstableApi::class) fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { val items = arrayListOf(LoaderView(container.context)) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) @@ -292,55 +304,103 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource) + val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) + val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() + .parse(sourceUrl.toUri(), inputStream) - masterPlaylist.getAudioSources().forEach { it -> + if (playlist is HlsMediaPlaylist) { + if (source is IHLSManifestAudioSource) { + val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!! - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - audioButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_music, - it.name, - listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), - (prefix + it.codec).trim(), - tag = it, - call = { - selectedAudioVariant = it - 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)) - }*/ - - masterPlaylist.getVideoSources().forEach { - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - videoButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_movie, - it.name, - "${it.width}x${it.height}", - (prefix + it.codec).trim(), - tag = it, - call = { - selectedVideoVariant = it - slideUpMenuOverlay.selectOption(videoButtons, it) - if (audioButtons.isEmpty()){ + val estSize = VideoHelper.estimateSourceSize(variant); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + audioButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_music, + variant.name, + listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), + (prefix + variant.codec).trim(), + tag = variant, + call = { + selectedAudioVariant = variant + slideUpMenuOverlay.selectOption(audioButtons, variant) slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - } - }, - invokeParent = false - )) + }, + invokeParent = false + )) + } else { + val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) + + val estSize = VideoHelper.estimateSourceSize(variant); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + videoButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + variant.name, + "${variant.width}x${variant.height}", + (prefix + variant.codec).trim(), + tag = variant, + call = { + selectedVideoVariant = variant + slideUpMenuOverlay.selectOption(videoButtons, variant) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } + }, + invokeParent = false + )) + } + } else if (playlist is HlsMultivariantPlaylist) { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + + masterPlaylist.getAudioSources().forEach { it -> + + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + audioButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_music, + it.name, + listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), + (prefix + it.codec).trim(), + tag = it, + call = { + selectedAudioVariant = it + 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)) + }*/ + + masterPlaylist.getVideoSources().forEach { + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + videoButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "${it.width}x${it.height}", + (prefix + it.codec).trim(), + tag = it, + call = { + selectedVideoVariant = it + slideUpMenuOverlay.selectOption(videoButtons, it) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } + }, + invokeParent = false + )) + } } val newItems = arrayListOf() @@ -950,26 +1010,30 @@ class UISlideOverlays { + actions).filterNotNull() )); items.add( - SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", - SlideUpMenuItem(container.context, + SlideUpMenuGroup( + container.context, container.context.getString(R.string.add_to), "addto", + SlideUpMenuItem( + container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), tag = "queue", call = { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem(container.context, + SlideUpMenuItem( + container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), tag = "watch later", call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), - SlideUpMenuItem(container.context, + SlideUpMenuItem( + container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", tag = "history", call = { StateHistory.instance.markAsWatched(video); }), - )); + )); val playlistItems = arrayListOf(); playlistItems.add(SlideUpMenuItem( @@ -1033,22 +1097,26 @@ class UISlideOverlays { val queue = StatePlayer.instance.getQueue(); val watchLater = StatePlaylists.instance.getWatchLater(); items.add( - SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", - SlideUpMenuItem(container.context, + SlideUpMenuGroup( + container.context, container.context.getString(R.string.other), "other", + SlideUpMenuItem( + container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), tag = "queue", call = { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem(container.context, + SlideUpMenuItem( + container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), tag = "watch later", - call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); + call = { + StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); UIDialogs.appToast("Added to watch later", false); }), - ) + ) ); val playlistItems = arrayListOf(); 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 916bc74c..aa63bb7a 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -15,18 +15,14 @@ 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 +import kotlin.text.ifEmpty class HLS { companion object { @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) - + fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf() @@ -34,36 +30,27 @@ class HLS { val sessionDataList = mutableListOf() var independentSegments = false - if (playlist is HlsMediaPlaylist) { - independentSegments = playlist.hasIndependentSegments - if (isAudioSource == true) { - mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null)) - } 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) + 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))) - } + 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) } } } @@ -71,6 +58,31 @@ class HLS { return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) } + fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? { + if (rendition.uri == null) { + return null + } + + val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + return when (rendition.type) { + "AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, rendition.uri) + else -> null + } + } + + fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource { + var width: Int? = null + var height: Int? = null + val resolutionTokens = reference.streamInfo.resolution?.split('x') + if (resolutionTokens?.isNotEmpty() == true) { + width = resolutionTokens[0].toIntOrNull() + height = resolutionTokens[1].toIntOrNull() + } + + val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url) + } + fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { val lines = content.lines() val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() @@ -137,10 +149,10 @@ class HLS { } } - fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List { + fun parseAndGetAudioSources(source: Any, content: String, url: String): List { val masterPlaylist: MasterPlaylist try { - masterPlaylist = parseMasterPlaylist(content, url, isAudioSource) + masterPlaylist = parseMasterPlaylist(content, url) return masterPlaylist.getAudioSources() } catch (e: Throwable) { if (content.lines().any { it.startsWith("#EXTINF:") }) { @@ -347,30 +359,13 @@ class HLS { fun getVideoSources(): List { return variantPlaylistsRefs.map { - var width: Int? = null - var height: Int? = null - val resolutionTokens = it.streamInfo.resolution?.split('x') - if (resolutionTokens?.isNotEmpty() == true) { - width = resolutionTokens[0].toIntOrNull() - height = resolutionTokens[1].toIntOrNull() - } - - val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") - HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url) + variantReferenceToVariant(it) } } fun getAudioSources(): List { return mediaRenditions.mapNotNull { - if (it.uri == null) { - return@mapNotNull null - } - - val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") - return@mapNotNull when (it.type) { - "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) - else -> null - } + return@mapNotNull mediaRenditionToVariant(it) } } From 425a27e1300e57fdfefd25c56608687e76bc6192 Mon Sep 17 00:00:00 2001 From: Stefan <84-stefancruz@users.noreply.gitlab.futo.org> Date: Sun, 25 May 2025 22:05:49 +0100 Subject: [PATCH 14/32] Add Crunchyroll --- .gitmodules | 6 ++++++ app/src/stable/assets/sources/crunchyroll | 1 + app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/assets/sources/crunchyroll | 1 + app/src/unstable/res/raw/plugin_config.json | 3 ++- 5 files changed, 12 insertions(+), 2 deletions(-) create mode 160000 app/src/stable/assets/sources/crunchyroll create mode 160000 app/src/unstable/assets/sources/crunchyroll diff --git a/.gitmodules b/.gitmodules index 3ba3f3f6..00037939 100644 --- a/.gitmodules +++ b/.gitmodules @@ -100,3 +100,9 @@ [submodule "app/src/unstable/assets/sources/curiositystream"] path = app/src/unstable/assets/sources/curiositystream url = ../plugins/curiositystream.git +[submodule "app/src/unstable/assets/sources/crunchyroll"] + path = app/src/unstable/assets/sources/crunchyroll + url = ../plugins/crunchyroll.git +[submodule "app/src/stable/assets/sources/crunchyroll"] + path = app/src/stable/assets/sources/crunchyroll + url = ../plugins/crunchyroll.git diff --git a/app/src/stable/assets/sources/crunchyroll b/app/src/stable/assets/sources/crunchyroll new file mode 160000 index 00000000..1aa91f21 --- /dev/null +++ b/app/src/stable/assets/sources/crunchyroll @@ -0,0 +1 @@ +Subproject commit 1aa91f216c0a87604aed1669b63b7830e4288630 diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index 4dea0f2e..ce535388 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -15,7 +15,8 @@ "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", - "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json" + "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json", + "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/assets/sources/crunchyroll b/app/src/unstable/assets/sources/crunchyroll new file mode 160000 index 00000000..1aa91f21 --- /dev/null +++ b/app/src/unstable/assets/sources/crunchyroll @@ -0,0 +1 @@ +Subproject commit 1aa91f216c0a87604aed1669b63b7830e4288630 diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index 40b10e11..5abe547e 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -15,7 +15,8 @@ "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", - "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json" + "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json", + "9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" From c703d018bdff68371e3fae5c92657212d3abcfff Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 26 May 2025 14:22:49 +0200 Subject: [PATCH 15/32] Fixed issue where loop video would not reset when opening a playlist, causing the video to loop without being able to disable it. --- .../fragment/mainactivity/main/PlaylistsFragment.kt | 2 +- .../java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt | 2 ++ app/src/main/res/layout/activity_sync_pair.xml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index 58caabe1..5510ccbf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() { var playlistsToReturn = pls; if(!_listPlaylistsSearch.text.isNullOrEmpty()) playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; - if(!_ordering.value.isNullOrEmpty()){ + if(!_ordering.value.isNullOrEmpty()) { playlistsToReturn = when(_ordering.value){ "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index 1daa7808..e209f937 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -531,6 +531,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { fun setLoopVisible(visible: Boolean) { _control_loop.visibility = if (visible) View.VISIBLE else View.GONE; _control_loop_fullscreen.visibility = if (visible) View.VISIBLE else View.GONE; + if (StatePlayer.instance.loopVideo && !visible) + StatePlayer.instance.loopVideo = false } fun stopAllGestures() { diff --git a/app/src/main/res/layout/activity_sync_pair.xml b/app/src/main/res/layout/activity_sync_pair.xml index e5355ecc..2e5e5651 100644 --- a/app/src/main/res/layout/activity_sync_pair.xml +++ b/app/src/main/res/layout/activity_sync_pair.xml @@ -233,7 +233,7 @@ android:isScrollContainer="true" android:scrollbars="vertical" android:maxHeight="200dp" - android:text="An error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurred" /> + android:text="An error has occurred" /> \ No newline at end of file From fca5fe38bb755afdd770a9f6f9bf8d6600ba37ed Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 27 May 2025 15:49:20 +0200 Subject: [PATCH 16/32] WIP Article/WEb --- .../platformplayer/activities/MainActivity.kt | 3 + .../media/models/article/IPlatformArticle.kt | 9 + .../models/article/IPlatformArticleDetails.kt | 12 + .../api/media/models/contents/ContentType.kt | 1 + .../media/platforms/js/models/IJSContent.kt | 4 +- .../media/platforms/js/models/JSArticle.kt | 39 ++ .../platforms/js/models/JSArticleDetails.kt | 14 +- .../api/media/platforms/js/models/JSWeb.kt | 31 + .../media/platforms/js/models/JSWebDetails.kt | 41 ++ .../engine/packages/PackageBridge.kt | 17 + .../main/ArticleDetailFragment.kt | 658 ++++++++++++++++++ .../mainactivity/main/WebDetailFragment.kt | 187 +++++ .../res/layout/fragview_article_detail.xml | 296 ++++++++ .../main/res/layout/fragview_web_detail.xml | 37 + 14 files changed, 1342 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt create mode 100644 app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt create mode 100644 app/src/main/res/layout/fragview_article_detail.xml create mode 100644 app/src/main/res/layout/fragview_web_detail.xml diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 095ac521..ef99642d 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -71,6 +71,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment +import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment @@ -150,6 +151,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //Frags Main lateinit var _fragMainHome: HomeFragment; lateinit var _fragPostDetail: PostDetailFragment; + lateinit var _fragWebDetail: WebDetailFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; @@ -324,6 +326,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); + _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt new file mode 100644 index 00000000..818f8a3b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt @@ -0,0 +1,9 @@ +package com.futo.platformplayer.api.media.models.article + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent + +interface IPlatformArticle: IPlatformContent { + val summary: String?; + val thumbnails: Thumbnails?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt new file mode 100644 index 00000000..be7f816d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt @@ -0,0 +1,12 @@ +package com.futo.platformplayer.api.media.models.article + +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment + +interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails { + val segments: List; + val rating : IRating; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt index d181c6da..736e9090 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -8,6 +8,7 @@ enum class ContentType(val value: Int) { POST(2), ARTICLE(3), PLAYLIST(4), + WEB(7), URL(9), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt index fd1f0894..777981bf 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt @@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent { ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj); - ContentType.CHANNEL -> JSChannelContent(config, obj) + ContentType.CHANNEL -> JSChannelContent(config, obj); + ContentType.ARTICLE -> JSArticle(config, obj); + ContentType.WEB -> JSWeb(config, obj); else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt new file mode 100644 index 00000000..679de602 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt @@ -0,0 +1,39 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSArticle : JSContent, IPlatformArticle, IPluginSourced { + final override val contentType: ContentType get() = ContentType.POST; + + override val summary: String; + override val thumbnails: Thumbnails?; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformArticle"; + + summary = _content.getOrDefault(config, "summary", contextName, "") ?: ""; + thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt index 453c59d8..6ed30cc1 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt @@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent @@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.states.StateDeveloper -open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails { +open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { final override val contentType: ContentType get() = ContentType.ARTICLE; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; - val rating: IRating; + override val rating: IRating; - val summary: String; - val thumbnails: Thumbnails?; - val segments: List; + override val summary: String; + override val thumbnails: Thumbnails?; + override val segments: List; constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { - val contextName = "PlatformPost"; + val contextName = "PlatformArticle"; rating = obj.getOrDefault(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); summary = _content.getOrThrow(client.config, "summary", contextName); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt new file mode 100644 index 00000000..9f68293f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSWeb : JSContent, IPluginSourced { + final override val contentType: ContentType get() = ContentType.POST; + + constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { + val contextName = "PlatformWeb"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt new file mode 100644 index 00000000..02a274f4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt @@ -0,0 +1,41 @@ +package com.futo.platformplayer.api.media.platforms.js.models + +import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.post.TextType +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.DevJSClient +import com.futo.platformplayer.api.media.platforms.js.JSClient +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.states.StateDeveloper + +open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails { + final override val contentType: ContentType get() = ContentType.WEB; + + val html: String?; + //TODO: Options? + + + constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { + val contextName = "PlatformWeb"; + + html = obj.getOrDefault(client.config, "html", contextName, null); + } + + override fun getComments(client: IPlatformClient): IPager? = null; + override fun getPlaybackTracker(): IPlaybackTracker? = null; + override fun getContentRecommendations(client: IPlatformClient): IPager? = null; + +} diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index 12ea05af..d2d7cf04 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -77,6 +78,22 @@ class PackageBridge : V8Package { return "android"; } + @V8Property + fun supportedContent(): Array { + return arrayOf( + ContentType.MEDIA.value, + ContentType.POST.value, + ContentType.PLAYLIST.value, + ContentType.WEB.value, + ContentType.URL.value, + ContentType.NESTED_VIDEO.value, + ContentType.CHANNEL.value, + ContentType.LOCKED.value, + ContentType.PLACEHOLDER.value, + ContentType.DEFERRED.value + ) + } + @V8Function fun dispose(value: V8Value) { Logger.e(TAG, "Manual dispose: " + value.javaClass.name); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt new file mode 100644 index 00000000..c27bbe0c --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt @@ -0,0 +1,658 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.article.IPlatformArticle +import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class ArticleDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: PostDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = PostDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is IPlatformArticleDetails) { + _viewDetail?.clear(); + _viewDetail?.setArticleDetails(parameter); + } else if (parameter is IPlatformArticle) { + _viewDetail?.setArticleOverview(parameter); + } else if(parameter is String) { + _viewDetail?.setPostUrl(parameter); + } + } + + private class PostDetailView : ConstraintLayout { + private lateinit var _fragment: ArticleDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _article: IPlatformArticleDetails? = null; + private var _articleOverview: IPlatformArticle? = null; + private var _polycentricProfile: PolycentricProfile? = null; + private var _version = 0; + private var _isRepliesVisible: Boolean = false; + private var _repliesAnimator: ViewPropertyAnimator? = null; + + private val _creatorThumbnail: CreatorThumbnail; + private val _buttonSubscribe: SubscribeButton; + private val _channelName: TextView; + private val _channelMeta: TextView; + private val _textTitle: TextView; + private val _textMeta: TextView; + private val _containerSegments: LinearLayout; + private val _textContent: TextView; + private val _platformIndicator: PlatformIndicator; + private val _buttonShare: ImageButton; + + private val _layoutRating: LinearLayout; + private val _imageLikeIcon: ImageView; + private val _textLikes: TextView; + private val _imageDislikeIcon: ImageView; + private val _textDislikes: TextView; + + private val _addCommentView: AddCommentView; + + private val _rating: PillRatingLikesDislikes; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private val _imageActive: ImageView; + private val _layoutThumbnails: FlexboxLayout; + + private val _repliesOverlay: RepliesOverlay; + + private val _commentsList: CommentsList; + + private var _commentType: Boolean? = null; + private val _buttonPolycentric: Button + private val _buttonPlatform: Button + + private val _taskLoadPost = if(!isInEditMode) TaskHandler( + StateApp.instance.scopeGetter, + { + val result = StatePlatform.instance.getContentDetails(it).await(); + if(result !is IPlatformArticleDetails) + throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); + return@TaskHandler result; + }) + .success { setArticleDetails(it) } + .exception { + Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it); + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); + } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; + + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) + .success { it -> setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it); + }; + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_article_detail, this); + + val root = findViewById(R.id.root); + + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _channelName = findViewById(R.id.text_channel_name); + _channelMeta = findViewById(R.id.text_channel_meta); + _textTitle = findViewById(R.id.text_title); + _textMeta = findViewById(R.id.text_meta); + _containerSegments = findViewById(R.id.container_segments); + _platformIndicator = findViewById(R.id.platform_indicator); + _buttonShare = findViewById(R.id.button_share); + + + _layoutRating = findViewById(R.id.layout_rating); + _imageLikeIcon = findViewById(R.id.image_like_icon); + _textLikes = findViewById(R.id.text_likes); + _imageDislikeIcon = findViewById(R.id.image_dislike_icon); + _textDislikes = findViewById(R.id.text_dislikes); + + _commentsList = findViewById(R.id.comments_list); + _addCommentView = findViewById(R.id.add_comment_view); + + _rating = findViewById(R.id.rating); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + _imageActive = findViewById(R.id.image_active); + _layoutThumbnails = findViewById(R.id.layout_thumbnails); + + _repliesOverlay = findViewById(R.id.replies_overlay); + + _buttonPolycentric = findViewById(R.id.button_polycentric) + _buttonPlatform = findViewById(R.id.button_platform) + + _textContent.setPlatformPlayerLinkMovementMethod(context); + + _buttonSubscribe.onSubscribed.subscribe { + //TODO: add overlay to layout + //UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); + }; + + val layoutTop: LinearLayout = findViewById(R.id.layout_top); + root.removeView(layoutTop); + _commentsList.setPrependedView(layoutTop); + + /*TODO: Why is this here? + _commentsList.onCommentsLoaded.subscribe { + updateCommentType(false); + };*/ + + _commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0; + var metadata = ""; + if (replyCount > 0) { + metadata += "$replyCount " + context.getString(R.string.replies); + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c; + _repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c, + { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, + { + val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); + _commentsList.replaceComment(parentComment, newComment); + parentComment = newComment; + }); + } else { + _repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }); + } + + setRepliesOverlayVisible(isVisible = true, animate = true); + }; + + if (StatePolycentric.instance.enabled) { + _buttonPolycentric.setOnClickListener { + updateCommentType(false) + } + } else { + _buttonPolycentric.visibility = View.GONE + } + + _buttonPlatform.setOnClickListener { + updateCommentType(true) + } + + _addCommentView.onCommentAdded.subscribe { + _commentsList.addComment(it); + }; + + _repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); }; + + _buttonShare.setOnClickListener { share() }; + + _creatorThumbnail.onClick.subscribe { openChannel() }; + _channelName.setOnClickListener { openChannel() }; + _channelMeta.setOnClickListener { openChannel() }; + } + + private fun openChannel() { + val author = _article?.author ?: _articleOverview?.author ?: return; + _fragment.navigate(author); + } + + private fun share() { + try { + Logger.i(PreviewPostView.TAG, "sharePost") + + val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url; + _fragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND; + putExtra(Intent.EXTRA_TEXT, url); + type = "text/plain"; //TODO: Determine alt types? + }, null)); + } catch (e: Throwable) { + //Ignored + Logger.e(PreviewPostView.TAG, "Failed to share.", e); + } + } + + private fun updatePolycentricRating() { + _rating.visibility = View.GONE; + + val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return) + val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } + val version = _version; + + _rating.onLikeDislikeUpdated.remove(this); + + if (!StatePolycentric.instance.enabled) + return + + _fragment.lifecycleScope.launch(Dispatchers.IO) { + if (version != _version) { + return@launch; + } + + try { + val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data)).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( + ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data)).build() + ), + extraByteReferences = listOfNotNull(extraBytesRef) + ); + + if (version != _version) { + return@launch; + } + + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + + withContext(Dispatchers.Main) { + if (version != _version) { + return@withContext; + } + + _rating.visibility = VISIBLE; + _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); + _rating.onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); + } + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + Logger.i(TAG, "Started backfill"); + args.processHandle.fullyBackfillServersAnnounceExceptions(); + Logger.i(TAG, "Finished backfill"); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; + } + } + } + + private fun setPlatformRating(rating: IRating?) { + if (rating == null) { + _layoutRating.visibility = View.GONE; + return; + } + + _layoutRating.visibility = View.VISIBLE; + + when (rating) { + is RatingLikeDislikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.VISIBLE; + _textDislikes.visibility = View.VISIBLE; + _textDislikes.text = rating.dislikes.toHumanNumber(); + } + is RatingLikes -> { + _textLikes.visibility = View.VISIBLE; + _imageLikeIcon.visibility = View.VISIBLE; + _textLikes.text = rating.likes.toHumanNumber(); + + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + else -> { + _textLikes.visibility = View.GONE; + _imageLikeIcon.visibility = View.GONE; + _imageDislikeIcon.visibility = View.GONE; + _textDislikes.visibility = View.GONE; + } + } + } + + fun applyFragment(frag: ArticleDetailFragment): PostDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _taskLoadPolycentricProfile.cancel(); + _version++; + + updateCommentType(null) + _url = null; + _article = null; + _articleOverview = null; + _creatorThumbnail.clear(); + //_buttonSubscribe.setSubscribeChannel(null); TODO: clear button + _channelName.text = ""; + setChannelMeta(null); + _textTitle.text = ""; + _textMeta.text = ""; + _textContent.text = ""; + setPlatformRating(null); + _polycentricProfile = null; + _rating.visibility = View.GONE; + updatePolycentricRating(); + setRepliesOverlayVisible(isVisible = false, animate = false); + + _addCommentView.setContext(null, null); + _platformIndicator.clearPlatform(); + } + + fun setArticleDetails(value: IPlatformArticleDetails) { + _url = value.url; + _article = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + setPlatformRating(value.rating); + + //Fetch only when not already called in setPostOverview + if (_articleOverview == null) { + fetchPolycentricProfile(); + updatePolycentricRating(); + _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); + } + + val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1 + updateCommentType(commentType, true); + setLoading(false); + } + + fun setArticleOverview(value: IPlatformArticle) { + clear(); + _url = value.url; + _articleOverview = value; + + _creatorThumbnail.setThumbnail(value.author.thumbnail, false); + _buttonSubscribe.setSubscribeChannel(value.author.url); + _channelName.text = value.author.name; + setChannelMeta(value); + _textTitle.text = value.name; + _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? + + _platformIndicator.setPlatformFromClientID(value.id.pluginId); + _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); + + updatePolycentricRating(); + fetchPolycentricProfile(); + fetchPost(); + } + + + private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) { + if (_isRepliesVisible == isVisible) { + return; + } + + _isRepliesVisible = isVisible; + _repliesAnimator?.cancel(); + + if (isVisible) { + _repliesOverlay.visibility = View.VISIBLE; + + if (animate) { + _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); + + _repliesAnimator = _repliesOverlay.animate() + .setDuration(300) + .translationY(0f) + .withEndAction { + _repliesAnimator = null; + }.apply { start() }; + } + } else { + if (animate) { + _repliesOverlay.translationY = 0f; + + _repliesAnimator = _repliesOverlay.animate() + .setDuration(300) + .translationY(_repliesOverlay.height.toFloat()) + .withEndAction { + _repliesOverlay.visibility = GONE; + _repliesAnimator = null; + }.apply { start(); } + } else { + _repliesOverlay.visibility = View.GONE; + _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); + } + } + } + + private fun fetchPolycentricProfile() { + val author = _article?.author ?: _articleOverview?.author ?: return; + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(author.id); + } + + private fun setChannelMeta(value: IPlatformArticle?) { + val subscribers = value?.author?.subscribers; + if(subscribers != null && subscribers > 0) { + _channelMeta.visibility = View.VISIBLE; + _channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; + } else { + _channelMeta.visibility = View.GONE; + _channelMeta.text = ""; + } + } + + fun setPostUrl(url: String) { + clear(); + _url = url; + fetchPost(); + } + + fun onDestroy() { + _commentsList.cancel(); + _taskLoadPost.cancel(); + _repliesOverlay.cleanup(); + } + + private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _polycentricProfile = polycentricProfile; + + val pp = _polycentricProfile; + if (pp == null) { + _creatorThumbnail.setHarborAvailable(false, animate, null); + return; + } + + _creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto()); + } + + private fun fetchPost() { + Logger.i(TAG, "fetchVideo") + _article = null; + + val url = _url; + if (!url.isNullOrBlank()) { + setLoading(true); + _taskLoadPost.run(url); + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + _article?.let { + _commentsList.load(true) { StatePlatform.instance.getComments(it); }; + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val post = _article; + val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) } + val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + if (ref == null) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null") + _commentsList.clear(); + return + } + + _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); }; + } + + private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) { + val changed = commentType != _commentType + _commentType = commentType + + if (commentType == null) { + _buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac)) + _buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac)) + } else { + _buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac)) + _buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac)) + + if (commentType) { + _addCommentView.visibility = View.GONE; + + if (forceReload || changed) { + fetchComments(); + } + } else { + _addCommentView.visibility = View.VISIBLE; + + if (forceReload || changed) { + fetchPolycentricComments() + } + } + } + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + companion object { + const val TAG = "PostDetailFragment" + } + } + + companion object { + fun newInstance() = ArticleDetailFragment().apply {} + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt new file mode 100644 index 00000000..a3dd1dba --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt @@ -0,0 +1,187 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.post.IPlatformPost +import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.platforms.js.models.JSWeb +import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp +import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.images.GlideHelper.Companion.crossfade +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.adapters.ChannelTab +import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import userpackage.Protocol +import java.lang.Integer.min + +class WebDetailFragment : MainFragment { + override val isMainView: Boolean = true; + override val isTab: Boolean = true; + override val hasBottomBar: Boolean get() = true; + + private var _viewDetail: WebDetailView? = null; + + constructor() : super() { } + + override fun onBackPressed(): Boolean { + return false; + } + + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = WebDetailView(inflater.context).applyFragment(this); + _viewDetail = view; + return view; + } + + override fun onDestroyMainView() { + super.onDestroyMainView(); + _viewDetail?.onDestroy(); + _viewDetail = null; + } + + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + super.onShownWithView(parameter, isBack); + + if (parameter is JSWebDetails) { + _viewDetail?.clear(); + _viewDetail?.setWebDetails(parameter); + } + } + + private class WebDetailView : ConstraintLayout { + private lateinit var _fragment: WebDetailFragment; + private var _url: String? = null; + private var _isLoading = false; + private var _web: JSWebDetails? = null; + + private val _layoutLoadingOverlay: FrameLayout; + private val _imageLoader: ImageView; + + private val _webview: WebView; + + + constructor(context: Context) : super(context) { + inflate(context, R.layout.fragview_web_detail, this); + + val root = findViewById(R.id.root); + + _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); + _imageLoader = findViewById(R.id.image_loader); + + _webview = findViewById(R.id.webview); + _webview.webViewClient = object: WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url); + if(url != "about:blank") + setLoading(false); + } + } + } + + + fun applyFragment(frag: WebDetailFragment): WebDetailView { + _fragment = frag; + return this; + } + + fun clear() { + _webview.loadUrl("about:blank"); + } + + fun setWebDetails(value: JSWebDetails) { + _web = value; + setLoading(true); + _webview.loadUrl("about:blank"); + if(!value.html.isNullOrEmpty()) + _webview.loadData(value.html, "text/html", null); + else + _webview.loadUrl(value.url ?: "about:blank"); + } + + fun onDestroy() { + _webview.loadUrl("about:blank"); + } + + private fun setLoading(isLoading : Boolean) { + if (_isLoading == isLoading) { + return; + } + + _isLoading = isLoading; + + if(isLoading) { + (_imageLoader.drawable as Animatable?)?.start() + _layoutLoadingOverlay.visibility = View.VISIBLE; + } + else { + _layoutLoadingOverlay.visibility = View.GONE; + (_imageLoader.drawable as Animatable?)?.stop() + } + } + + companion object { + const val TAG = "WebDetailFragment" + } + } + + companion object { + fun newInstance() = WebDetailFragment().apply {} + } +} diff --git a/app/src/main/res/layout/fragview_article_detail.xml b/app/src/main/res/layout/fragview_article_detail.xml new file mode 100644 index 00000000..16bade8d --- /dev/null +++ b/app/src/main/res/layout/fragview_article_detail.xml @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +