From d4ccf232c1251241a416ee895a14415759856540 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 10 Feb 2025 17:57:27 -0600 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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 50ecb909b4cc3f71b34a5de0f2d63c058f0c7baa Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 9 May 2025 10:08:24 -0500 Subject: [PATCH 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 18aec34c0e114b0d4c71076206fabc57a7d7809f Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 27 May 2025 14:05:51 -0500 Subject: [PATCH 13/20] add smart exception dependency for usage by local ffmpegkit dependency Changelog: added --- app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle b/app/build.gradle index 88cd4558..fcbd422c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -198,6 +198,7 @@ dependencies { implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation fileTree(dir: 'aar', include: ['*.aar']) + implementation 'com.arthenica:smart-exception-java:0.2.1' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.google.zxing:core:3.4.1' From e0811cfd93d914ad9b6019a9d03717e0c687be7f Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 28 May 2025 20:08:22 +0200 Subject: [PATCH 14/20] Implemented new channel search. --- .../futo/platformplayer/UISlideOverlays.kt | 4 +- .../channel/tab/ChannelContentsFragment.kt | 64 +++++++++++++++---- .../mainactivity/main/ChannelFragment.kt | 16 ++--- .../main/ContentSearchResultsFragment.kt | 36 +++-------- .../mainactivity/main/SuggestionsFragment.kt | 9 +-- .../mainactivity/main/VideoDetailView.kt | 5 +- .../topbar/SearchTopBarFragment.kt | 3 +- .../futo/platformplayer/views/SearchView.kt | 23 ++++++- .../overlays/slideup/SlideUpMenuFilters.kt | 10 +-- 9 files changed, 100 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 61bdf97e..92683a7c 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -1121,8 +1121,8 @@ class UISlideOverlays { return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; } - fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>, isChannelSearch: Boolean = false): SlideUpMenuFilters { - val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch); + fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>): SlideUpMenuFilters { + val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues); overlay.show(); return overlay; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 788fd4c1..e38b4c1d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager @@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.exceptions.ChannelException @@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter @@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private var _results: ArrayList = arrayListOf(); private var _adapterResults: InsertedViewAdapterWithLoader? = null; private var _lastPolycentricProfile: PolycentricProfile? = null; + private var _query: String? = null + private var _searchView: SearchView? = null val onContentClicked = Event2(); val onContentUrlClicked = Event2(); @@ -68,17 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private fun getContentPager(channel: IPlatformChannel): IPager { Logger.i(TAG, "getContentPager"); - val lastPolycentricProfile = _lastPolycentricProfile; - var pager: IPager? = null; - if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) - pager = - StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); + var pager: IPager? = null + val query = _query + if (!query.isNullOrBlank()) { + if(subType != null) { + Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})") + pager = StatePlatform.instance.searchChannel(channel.url, query, subType); + } else { + Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})") + pager = StatePlatform.instance.searchChannel(channel.url, query); + } + } else { + val lastPolycentricProfile = _lastPolycentricProfile; + if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) { + pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); + Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})") + } - if(pager == null) { - if(subType != null) - pager = StatePlatform.instance.getChannelContent(channel.url, subType); - else - pager = StatePlatform.instance.getChannelContent(channel.url); + if(pager == null) { + if(subType != null) { + pager = StatePlatform.instance.getChannelContent(channel.url, subType); + Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})") + } else { + pager = StatePlatform.instance.getChannelContent(channel.url); + Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})") + } + } } return pager; } @@ -145,6 +166,9 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _taskLoadVideos.cancel(); + val pid = channel.id.pluginId + _searchView?.visibility = if (pid != null && StatePlatform.instance.getClientOrNull(pid)?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE + _query = null _channel = channel; _results.clear(); _adapterResults?.notifyDataSetChanged(); @@ -152,12 +176,26 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), loadInitial(); } + fun setQuery(query: String) { + _query = query + loadInitial() + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); + _query = null _recyclerResults = view.findViewById(R.id.recycler_videos); - _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { + val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply { + onEnter.subscribe { + setQuery(it) + } + visibility = View.GONE + } + _searchView = searchView + + _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); @@ -174,6 +212,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _recyclerResults?.layoutManager = _glmVideo; _recyclerResults?.addOnScrollListener(_scrollListener); + return view; } @@ -182,6 +221,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _recyclerResults?.removeOnScrollListener(_scrollListener); _recyclerResults = null; _pager = null; + _query = null + _searchView = null _taskLoadVideos.cancel(); _nextPageHandler.cancel(); @@ -304,6 +345,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), } private fun loadInitial() { + Logger.i(TAG, "loadInitial") val channel: IPlatformChannel = _channel ?: return; setLoading(true); _taskLoadVideos.run(channel); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 3ac71315..63b60c1f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -425,17 +425,15 @@ class ChannelFragment : MainFragment() { _fragment.lifecycleScope.launch(Dispatchers.IO) { val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) withContext(Dispatchers.Main) { - if (plugin != null && plugin.capabilities.hasSearchChannelContents) { - buttons.add(Pair(R.drawable.ic_search) { - _fragment.navigate( - SuggestionsFragmentData( - "", SearchType.VIDEO, channel.url - ) + buttons.add(Pair(R.drawable.ic_search) { + _fragment.navigate( + SuggestionsFragmentData( + "", SearchType.VIDEO ) - }) + ) + }) + _fragment.topBar?.assume()?.setMenuItems(buttons) - _fragment.topBar?.assume()?.setMenuItems(buttons) - } if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) { if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index 4d517b99..72094903 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() { private var _sortBy: String? = null; private var _filterValues: HashMap> = hashMapOf(); private var _enabledClientIds: List? = null; - private var _channelUrl: String? = null; private var _searchType: SearchType? = null; private val _taskSearch: TaskHandler>; @@ -98,17 +97,12 @@ class ContentSearchResultsFragment : MainFragment() { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> Logger.i(TAG, "Searching for: $query") - val channelUrl = _channelUrl; - if (channelUrl != null) { - StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) - } else { - when (_searchType) - { - SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) - SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query) - SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query) - else -> throw Exception("Search type must be specified") - } + when (_searchType) + { + SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) + SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query) + SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query) + else -> throw Exception("Search type must be specified") } }) .success { loadedResult(it); }.exception { } @@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() { fun onShown(parameter: Any?) { if(parameter is SuggestionsFragmentData) { setQuery(parameter.query, false); - setChannelUrl(parameter.channelUrl, false); setSearchType(parameter.searchType, false) fragment.topBar?.apply { @@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() { onFilterClick.subscribe(this) { _overlayContainer.let { val filterValuesCopy = HashMap(_filterValues); - val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null); + val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy); filtersOverlay.onOK.subscribe { enabledClientIds, changed -> if (changed) { setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); @@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() { fragment.lifecycleScope.launch(Dispatchers.IO) { try { - val commonCapabilities = - if(_channelUrl == null) - StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); - else - StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); + val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); val sorts = commonCapabilities?.sorts ?: listOf(); if (sorts.size > 1) { withContext(Dispatchers.Main) { @@ -282,15 +271,6 @@ class ContentSearchResultsFragment : MainFragment() { } } - private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) { - _channelUrl = channelUrl; - - if (updateResults) { - clearResults(); - loadResults(); - } - } - private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) { _searchType = searchType diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index 0dfb867d..e0a68cc5 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -21,7 +21,7 @@ import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter import com.futo.platformplayer.views.others.RadioGroupView import com.futo.platformplayer.views.others.TagsView -data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); +data class SuggestionsFragmentData(val query: String, val searchType: SearchType); class SuggestionsFragment : MainFragment { override val isMainView : Boolean = true; @@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment { private val _suggestions: ArrayList = ArrayList(); private var _query: String? = null; private var _searchType: SearchType = SearchType.VIDEO; - private var _channelUrl: String? = null; private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions); @@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment { _adapterSuggestions.onClicked.subscribe { suggestion -> val storage = FragmentedStorage.get(); storage.add(suggestion); - navigate(SuggestionsFragmentData(suggestion, _searchType, _channelUrl)); + navigate(SuggestionsFragmentData(suggestion, _searchType)); } _adapterSuggestions.onRemove.subscribe { suggestion -> val index = _suggestions.indexOf(suggestion); @@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment { if (parameter is SuggestionsFragmentData) { _searchType = parameter.searchType; - _channelUrl = parameter.channelUrl; } else if (parameter is SearchType) { _searchType = parameter; - _channelUrl = null; } _radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) @@ -135,7 +132,7 @@ class SuggestionsFragment : MainFragment { } } else - navigate(SuggestionsFragmentData(it, _searchType, _channelUrl)); + navigate(SuggestionsFragmentData(it, _searchType)); }; onTextChange.subscribe(this) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index d1a3e41e..fd2cb19b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -2680,9 +2680,10 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { - if(it.url.isNotBlank()) + if(it.url.isNotBlank()) { + fragment.minimizeVideoDetail() fragment.navigate(it) - else + } else UIDialogs.appToast("No author url present"); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt index 44d8a9ad..15952d0a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt @@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() { } else if (parameter is SuggestionsFragmentData) { this.setText(parameter.query); _searchType = parameter.searchType; - _channelUrl = parameter.channelUrl; } if(currentMain is SuggestionsFragment) @@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() { fun clear() { _editSearch?.text?.clear(); if (currentMain !is SuggestionsFragment) { - navigate(SuggestionsFragmentData("", _searchType, _channelUrl), false); + navigate(SuggestionsFragmentData("", _searchType), false); } else { onSearch.emit(""); } diff --git a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt index c36a04a5..c7d68127 100644 --- a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt @@ -3,6 +3,8 @@ package com.futo.platformplayer.views import android.content.Context import android.text.TextWatcher import android.util.AttributeSet +import android.view.View +import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton @@ -30,9 +32,26 @@ class SearchView : FrameLayout { textSearch = findViewById(R.id.edit_search) buttonClear = findViewById(R.id.button_clear_search) - buttonClear.setOnClickListener { textSearch.text = "" }; + buttonClear.setOnClickListener { + textSearch.text = "" + textSearch?.clearFocus() + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0) + onSearchChanged.emit("") + onEnter.emit("") + } + textSearch.setOnEditorActionListener { _, i, _ -> + if (i == EditorInfo.IME_ACTION_DONE) { + textSearch?.clearFocus() + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0) + onEnter.emit(textSearch.text.toString()) + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + + } textSearch.addTextChangedListener { - onSearchChanged.emit(it.toString()); + buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE + onSearchChanged.emit(it.toString()) }; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt index 75b50a26..9180a3f1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt @@ -28,17 +28,14 @@ class SlideUpMenuFilters { private var _changed: Boolean = false; private val _lifecycleScope: CoroutineScope; - private var _isChannelSearch = false; - var commonCapabilities: ResultCapabilities? = null; - constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>, isChannelSearch: Boolean = false) { + constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>) { _lifecycleScope = lifecycleScope; _container = container; _enabledClientsIds = enabledClientsIds; _filterValues = filterValues; - _isChannelSearch = isChannelSearch; _slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf()); _slideUpMenuOverlay.onOK.subscribe { onOK.emit(_enabledClientsIds, _changed); @@ -51,10 +48,7 @@ class SlideUpMenuFilters { private fun updateCommonCapabilities() { _lifecycleScope.launch(Dispatchers.IO) { try { - val caps = if(!_isChannelSearch) - StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); - else - StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds); + val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); synchronized(_filterValues) { if (caps != null) { val keysToRemove = arrayListOf(); From 0ec921709a63366fd49ed1466c4cf804dab4104c Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 28 May 2025 20:32:09 +0200 Subject: [PATCH 15/20] Fixed search visibility and channel loader when changing query. --- .../channel/tab/ChannelContentsFragment.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index e38b4c1d..14be4fdf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -166,18 +166,26 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _taskLoadVideos.cancel(); - val pid = channel.id.pluginId - _searchView?.visibility = if (pid != null && StatePlatform.instance.getClientOrNull(pid)?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE _query = null _channel = channel; + updateSearchViewVisibility() _results.clear(); _adapterResults?.notifyDataSetChanged(); loadInitial(); } + private fun updateSearchViewVisibility() { + val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) } + Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}") + _searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE + } + fun setQuery(query: String) { _query = query + _taskLoadVideos.cancel() + _results.clear() + _adapterResults?.notifyDataSetChanged() loadInitial() } @@ -191,9 +199,9 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), onEnter.subscribe { setQuery(it) } - visibility = View.GONE } _searchView = searchView + updateSearchViewVisibility() _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); From c32ebe016b4d96cd74cbbc91e6a5b1135340b015 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 28 May 2025 17:33:35 -0500 Subject: [PATCH 16/20] fix extension 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 d0376bcf..98040393 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -1165,7 +1165,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 94ab3da0e46fabe9970f153fcfaa088c36d082b3 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 29 May 2025 11:51:59 +0200 Subject: [PATCH 17/20] Added separate error status code for transport rejection. Added unhandled exception handler for relay loop. Added additional booleans to keep track of the server/relay connections being up/down. Added additional messaging to let the user know when something is wrong. --- .../activities/SyncHomeActivity.kt | 17 +- .../futo/platformplayer/states/StateApp.kt | 16 +- .../futo/platformplayer/states/StateSync.kt | 18 +- .../sync/internal/SyncService.kt | 279 +++++++++++------- .../sync/internal/SyncSocketSession.kt | 2 +- 5 files changed, 198 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index e8e95f66..0fff7b06 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -9,6 +9,7 @@ import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp @@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() { } } - StateSync.instance.confirmStarted(this, { - StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) - }, { + StateSync.instance.confirmStarted(this, onStarted = { + if (StateSync.instance.syncService?.serverSocketFailedToStart == true) { + UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true) + } + if (StateSync.instance.syncService?.relayConnected == false) { + UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false) + } + if (StateSync.instance.syncService?.serverSocketStarted == false) { + UIDialogs.toast(this, "Listener not started, local connections will not work.", false) + } + }, onNotStarted = { finish() - }, { - StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) }) } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 2988b217..e2155e8b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -412,24 +412,12 @@ class StateApp { } if (Settings.instance.synchronization.enabled) { - StateSync.instance.start(context, { - try { - UIDialogs.toast("Failed to start sync, port in use") - } catch (e: Throwable) { - //Ignored - } - }) + StateSync.instance.start(context) } settingsActivityClosed.subscribe { if (Settings.instance.synchronization.enabled) { - StateSync.instance.start(context, { - try { - UIDialogs.toast("Failed to start sync, port in use") - } catch (e: Throwable) { - //Ignored - } - }) + StateSync.instance.start(context) } else { StateSync.instance.stop() } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 81efece4..fd08165c 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -51,7 +51,7 @@ class StateSync { val deviceRemoved: Event1 = Event1() val deviceUpdatedOrAdded: Event2 = Event2() - fun start(context: Context, onServerBindFail: () -> Unit) { + fun start(context: Context) { if (syncService != null) { Logger.i(TAG, "Already started.") return @@ -150,24 +150,14 @@ class StateSync { } } - syncService?.start(context, onServerBindFail) + syncService?.start(context) } - fun showFailedToBindDialogIfNecessary(context: Context) { - if (syncService?.serverSocketFailedToStart == true && Settings.instance.synchronization.localConnections) { - try { - UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use") - } catch (e: Throwable) { - //Ignored - } - } - } - - fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) { + fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) { if (syncService == null) { UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { Settings.instance.synchronization.enabled = true - start(context, onServerBindFail) + start(context) Settings.instance.save() onStarted.invoke() }, { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt index e6f9e6d8..5e5ad7de 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt @@ -72,6 +72,8 @@ class SyncService( private val _lastConnectTimesMdns: MutableMap = mutableMapOf() private val _lastConnectTimesIp: MutableMap = mutableMapOf() var serverSocketFailedToStart = false + var serverSocketStarted = false + var relayConnected = false //TODO: Should sync mdns and casting mdns be merged? //TODO: Decrease interval that devices are updated //TODO: Send less data @@ -212,7 +214,7 @@ class SyncService( var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null - fun start(context: Context, onServerBindFail: (() -> Unit)? = null) { + fun start(context: Context) { if (_started) { Logger.i(TAG, "Already started.") return @@ -273,10 +275,12 @@ class SyncService( Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") + serverSocketStarted = false if (settings.bindListener) { - startListener(onServerBindFail) + startListener() } + relayConnected = false if (settings.relayEnabled) { startRelayLoop() } @@ -286,13 +290,15 @@ class SyncService( } } - private fun startListener(onServerBindFail: (() -> Unit)? = null) { + private fun startListener() { serverSocketFailedToStart = false + serverSocketStarted = false _thread = Thread { try { val serverSocket = ServerSocket(settings.listenerPort) _serverSocket = serverSocket + serverSocketStarted = true Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") while (_started) { @@ -300,10 +306,12 @@ class SyncService( val session = createSocketSession(socket, true) session.startAsResponder() } + + serverSocketStarted = false } catch (e: Throwable) { Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) serverSocketFailedToStart = true - onServerBindFail?.invoke() + serverSocketStarted = false } }.apply { start() } } @@ -386,121 +394,192 @@ class SyncService( } private fun startRelayLoop() { + relayConnected = false _threadRelay = Thread { - var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) - var backoffIndex = 0; + try { + var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) + var backoffIndex = 0; - while (_started) { - try { - Log.i(TAG, "Starting relay session...") + while (_started) { + try { + Log.i(TAG, "Starting relay session...") + relayConnected = false - var socketClosed = false; - val socket = Socket(relayServer, 9000) - _relaySession = SyncSocketSession( - (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, - keyPair!!, - socket, - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, - onNewChannel = { _, c -> - val remotePublicKey = c.remotePublicKey - if (remotePublicKey == null) { - Log.e(TAG, "Remote public key should never be null in onNewChannel.") - return@SyncSocketSession - } - - Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") - - var session: SyncSession? - synchronized(_sessions) { - session = _sessions[remotePublicKey] - if (session == null) { - val remoteDeviceName = database.getDeviceName(remotePublicKey) - session = createNewSyncSession(remotePublicKey, remoteDeviceName) - _sessions[remotePublicKey] = session!! + var socketClosed = false; + val socket = Socket(relayServer, 9000) + _relaySession = SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + socket, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> + isHandshakeAllowed( + linkType, + syncSocketSession, + publicKey, + pairingCode, + appId + ) + }, + onNewChannel = { _, c -> + val remotePublicKey = c.remotePublicKey + if (remotePublicKey == null) { + Log.e( + TAG, + "Remote public key should never be null in onNewChannel." + ) + return@SyncSocketSession } - session!!.addChannel(c) - } - c.setDataHandler { _, channel, opcode, subOpcode, data -> - session?.handlePacket(opcode, subOpcode, data) - } - c.setCloseHandler { channel -> - session?.removeChannel(channel) - } - }, - onChannelEstablished = { _, channel, isResponder -> - handleAuthorization(channel, isResponder) - }, - onClose = { socketClosed = true }, - onHandshakeComplete = { relaySession -> - backoffIndex = 0 + Log.i( + TAG, + "New channel established from relay (pk: '$remotePublicKey')." + ) - Thread { - try { - while (_started && !socketClosed) { - val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf() - relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed) + var session: SyncSession? + synchronized(_sessions) { + session = _sessions[remotePublicKey] + if (session == null) { + val remoteDeviceName = + database.getDeviceName(remotePublicKey) + session = + createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(c) + } - Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") - val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } - Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") + c.setDataHandler { _, channel, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + c.setCloseHandler { channel -> + session?.removeChannel(channel) + } + }, + onChannelEstablished = { _, channel, isResponder -> + handleAuthorization(channel, isResponder) + }, + onClose = { socketClosed = true }, + onHandshakeComplete = { relaySession -> + backoffIndex = 0 - for ((targetKey, connectionInfo) in connectionInfos) { - val potentialLocalAddresses = connectionInfo.ipv4Addresses - .filter { it != connectionInfo.remoteIp } - if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { - Thread { + Thread { + try { + while (_started && !socketClosed) { + val unconnectedAuthorizedDevices = + database.getAllAuthorizedDevices() + ?.filter { !isConnected(it) }?.toTypedArray() + ?: arrayOf() + relaySession.publishConnectionInformation( + unconnectedAuthorizedDevices, + settings.listenerPort, + settings.relayConnectDirect, + false, + false, + settings.relayConnectRelayed + ) + + Logger.v( + TAG, + "Requesting ${unconnectedAuthorizedDevices.size} devices connection information" + ) + val connectionInfos = runBlocking { + relaySession.requestBulkConnectionInfo( + unconnectedAuthorizedDevices + ) + } + Logger.v( + TAG, + "Received ${connectionInfos.size} devices connection information" + ) + + for ((targetKey, connectionInfo) in connectionInfos) { + val potentialLocalAddresses = + connectionInfo.ipv4Addresses + .filter { it != connectionInfo.remoteIp } + if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { + Thread { + try { + Log.v( + TAG, + "Attempting to connect directly, locally to '$targetKey'." + ) + connect( + potentialLocalAddresses.map { it } + .toTypedArray(), + settings.listenerPort, + targetKey, + null + ) + } catch (e: Throwable) { + Log.e( + TAG, + "Failed to start direct connection using connection info with $targetKey.", + e + ) + } + }.start() + } + + if (connectionInfo.allowRemoteDirect) { + // TODO: Implement direct remote connection if needed + } + + if (connectionInfo.allowRemoteHolePunched) { + // TODO: Implement hole punching if needed + } + + if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { try { - Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") - connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null) + Logger.v( + TAG, + "Attempting relayed connection with '$targetKey'." + ) + runBlocking { + relaySession.startRelayedChannel( + targetKey, + appId, + null + ) + } } catch (e: Throwable) { - Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) + Logger.e( + TAG, + "Failed to start relayed channel with $targetKey.", + e + ) } - }.start() - } - - if (connectionInfo.allowRemoteDirect) { - // TODO: Implement direct remote connection if needed - } - - if (connectionInfo.allowRemoteHolePunched) { - // TODO: Implement hole punching if needed - } - - if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { - try { - Logger.v(TAG, "Attempting relayed connection with '$targetKey'.") - runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e) } } + + Thread.sleep(15000) } - - Thread.sleep(15000) + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in relay session.", e) + relaySession.stop() } - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in relay session.", e) - relaySession.stop() - } - }.start() + }.start() + } + ) + + _relaySession!!.authorizable = object : IAuthorizable { + override val isAuthorized: Boolean get() = true } - ) - _relaySession!!.authorizable = object : IAuthorizable { - override val isAuthorized: Boolean get() = true + relayConnected = true + _relaySession!!.runAsInitiator(relayPublicKey, appId, null) + + Log.i(TAG, "Started relay session.") + } catch (e: Throwable) { + Log.e(TAG, "Relay session failed.", e) + } finally { + relayConnected = false + _relaySession?.stop() + _relaySession = null + Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) } - - _relaySession!!.runAsInitiator(relayPublicKey, appId, null) - - Log.i(TAG, "Started relay session.") - } catch (e: Throwable) { - Log.e(TAG, "Relay session failed.", e) - } finally { - _relaySession?.stop() - _relaySession = null - Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) } + } catch (ex: Throwable) { + Log.i(TAG, "Unhandled exception in relay loop.", ex) } }.apply { start() } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index fb4e920f..cb67f934 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -529,7 +529,7 @@ class SyncSocketSession { val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true) if (!isAllowed) { val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) - rp.putInt(2) // Status code for not allowed + rp.putInt(7) // Status code for not allowed rp.putLong(connectionId) rp.putInt(requestId) rp.rewind() From 274942b5ba2686e90afaf45a6d81ac5cb64593c4 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 29 May 2025 11:55:21 +0200 Subject: [PATCH 18/20] Hide search for any tab that isn't videos. --- .../fragment/channel/tab/ChannelContentsFragment.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 14be4fdf..0939fbde 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -176,6 +176,11 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), } private fun updateSearchViewVisibility() { + if (subType != null) { + _searchView?.visibility = View.GONE + return + } + val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) } Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}") _searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE From ec22c58822368fc7735ee9b22c483cbe1da1aba6 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 29 May 2025 11:58:10 +0200 Subject: [PATCH 19/20] Made a fix for ui mode changes causing app restarts. --- app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 61910915..176dc044 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,7 +56,7 @@ Date: Thu, 29 May 2025 13:03:51 +0200 Subject: [PATCH 20/20] Changed getLocalUrl logic. --- .../casting/ChomecastCastingDevice.kt | 2 +- .../platformplayer/casting/StateCasting.kt | 45 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 32bee98d..b25240bc 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -536,7 +536,7 @@ class ChromecastCastingDevice : CastingDevice { Logger.i(TAG, "Player not found, launching."); launchPlayer(); } else { - Logger.i(TAG, "Player not found, disconnecting."); + Logger.i(TAG, "Player not found, disconnecting."); //TODO: Add recovery from this scenario ? stop(); } } else { diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 647aaae6..a1ece8b4 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -10,6 +10,8 @@ import android.os.Build import android.os.Looper import android.util.Base64 import android.util.Log +import java.net.NetworkInterface +import java.net.Inet4Address import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -55,6 +57,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import java.net.Inet6Address import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder @@ -483,7 +486,7 @@ class StateCasting { } } else { val proxyStreams = Settings.instance.casting.alwaysProxyRequests; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); if (videoSource is IVideoUrlSource) { @@ -578,7 +581,7 @@ class StateCasting { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; @@ -597,7 +600,7 @@ class StateCasting { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val audioPath = "/audio-${id}" val audioUrl = url + audioPath; @@ -616,7 +619,7 @@ class StateCasting { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { val ad = activeDevice ?: return listOf() - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" + val url = getLocalUrl(ad) val id = UUID.randomUUID() val hlsPath = "/hls-${id}" @@ -712,7 +715,7 @@ class StateCasting { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -762,7 +765,7 @@ class StateCasting { val ad = activeDevice ?: return listOf(); val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val videoPath = "/video-${id}" @@ -827,7 +830,7 @@ class StateCasting { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -997,7 +1000,7 @@ class StateCasting { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -1127,7 +1130,7 @@ class StateCasting { val ad = activeDevice ?: return listOf(); val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -1213,6 +1216,28 @@ class StateCasting { } } + private fun findFirstIPv4(): InetAddress? { + val interfaces = NetworkInterface.getNetworkInterfaces() + for (intf in interfaces) { + if (!intf.isUp || intf.isLoopback) continue + for (addr in intf.inetAddresses) { + if (addr is Inet4Address && !addr.isLoopbackAddress) { + return addr + } + } + } + return null + } + + private fun getLocalUrl(ad: CastingDevice): String { + var address = ad.localAddress!! + if (address is Inet6Address && address.isLinkLocalAddress) { + address = findFirstIPv4() ?: address + Logger.i(TAG, "Selected casting address: $address") + } + return "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + } + @OptIn(UnstableApi::class) private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); @@ -1220,7 +1245,7 @@ class StateCasting { cleanExecutors() _castServer.removeAllHandlers("castDashRaw") - val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val url = getLocalUrl(ad); val id = UUID.randomUUID(); val dashPath = "/dash-${id}"