From 2246f8cee2948ec5272bb2443e3366f0eb24939a Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 21 Nov 2023 15:12:09 +0000 Subject: [PATCH 01/23] Finished implementation of HLS proxying. --- .../futo/platformplayer/Extensions_Network.kt | 47 ++++ .../futo/platformplayer/Extensions_Syntax.kt | 4 + .../api/http/server/HttpContext.kt | 3 +- .../api/http/server/ManagedHttpServer.kt | 53 +++- .../api/http/server/handlers/HttpHandler.kt | 1 + .../http/server/handlers/HttpProxyHandler.kt | 148 +++++++++- .../platformplayer/builders/HlsBuilder.kt | 37 --- .../platformplayer/casting/StateCasting.kt | 150 +++++++++- .../com/futo/platformplayer/parsers/HLS.kt | 266 ++++++++++++++++++ .../parsers/HttpResponseParser.kt | 64 +++++ 10 files changed, 706 insertions(+), 67 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt create mode 100644 app/src/main/java/com/futo/platformplayer/parsers/HLS.kt create mode 100644 app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index f2310bfe..b99b33d5 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -1,11 +1,15 @@ package com.futo.platformplayer import com.google.common.base.CharMatcher +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.nio.ByteBuffer +import java.nio.charset.Charset private const val IPV4_PART_COUNT = 4; @@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List, port: Int): Socket? { return connectedSocket; } + +fun InputStream.readHttpHeaderBytes() : ByteArray { + val headerBytes = ByteArrayOutputStream() + var crlfCount = 0 + + while (crlfCount < 4) { + val b = read() + if (b == -1) { + throw IOException("Unexpected end of stream while reading headers") + } + + if (b == 0x0D || b == 0x0A) { // CR or LF + crlfCount++ + } else { + crlfCount = 0 + } + + headerBytes.write(b) + } + + return headerBytes.toByteArray() +} + +fun InputStream.readLine() : String? { + val line = ByteArrayOutputStream() + var crlfCount = 0 + + while (crlfCount < 2) { + val b = read() + if (b == -1) { + return null + } + + if (b == 0x0D || b == 0x0A) { // CR or LF + crlfCount++ + } else { + crlfCount = 0 + line.write(b) + } + } + + return String(line.toByteArray(), Charsets.UTF_8) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 41d1822e..0b79de90 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -12,4 +12,8 @@ inline fun Any.assume(cb: (T) -> R): R? { if(result != null) return cb(result); return null; +} + +fun String?.yesNoToBoolean(): Boolean { + return this?.uppercase() == "YES" } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt index 9302629d..f08610f5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt @@ -219,8 +219,7 @@ class HttpContext : AutoCloseable { headersToRespond.put("keep-alive", "timeout=5, max=1000"); } - val responseHeader = HttpResponse(status, headers); - + val responseHeader = HttpResponse(status, headersToRespond); responseStream.write(responseHeader.getHttpHeaderBytes()); if(method != "HEAD") { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt index df695ed1..fa5eafac 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt @@ -17,6 +17,7 @@ import java.util.* import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.stream.IntStream.range +import kotlin.collections.HashMap class ManagedHttpServer(private val _requestedPort: Int = 0) { private val _client : ManagedHttpClient = ManagedHttpClient(); @@ -28,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) { var port = 0 private set; - private val _handlers = mutableListOf(); + private val _handlers = hashMapOf>() + private val _headHandlers = hashMapOf() private var _workerPool: ExecutorService? = null; @Synchronized @@ -114,32 +116,61 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) { fun getHandler(method: String, path: String) : HttpHandler? { synchronized(_handlers) { - //TODO: Support regex paths? - if(method == "HEAD") - return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") } - return _handlers.firstOrNull { it.method == method && it.path == path }; + if (method == "HEAD") { + return _headHandlers[path] + } + + val handlerMap = _handlers[method] ?: return null + return handlerMap[path] } } fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { synchronized(_handlers) { - _handlers.add(handler); handler.allowHEAD = withHEAD; + + var handlerMap: HashMap? = _handlers[handler.method]; + if (handlerMap == null) { + handlerMap = hashMapOf() + _handlers[handler.method] = handlerMap + } + + handlerMap[handler.path] = handler; + if (handler.allowHEAD || handler.method == "HEAD") { + _headHandlers[handler.path] = handler + } } return handler; } fun removeHandler(method: String, path: String) { synchronized(_handlers) { - val handler = getHandler(method, path); - if(handler != null) - _handlers.remove(handler); + val handlerMap = _handlers[method] ?: return + val handler = handlerMap.remove(path) ?: return + if (method == "HEAD" || handler.allowHEAD) { + _headHandlers.remove(path) + } } } fun removeAllHandlers(tag: String? = null) { synchronized(_handlers) { if(tag == null) _handlers.clear(); - else - _handlers.removeIf { it.tag == tag }; + else { + for (pair in _handlers) { + val toRemove = ArrayList() + for (innerPair in pair.value) { + if (innerPair.value.tag == tag) { + toRemove.add(innerPair.key) + + if (pair.key == "HEAD" || innerPair.value.allowHEAD) { + _headHandlers.remove(innerPair.key) + } + } + } + + for (x in toRemove) + pair.value.remove(x) + } + } } } fun addBridgeHandlers(obj: Any, tag: String? = null) { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt index 509c1b40..5097ef42 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpHandler.kt @@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) { headers.put(key, value); return this; } + fun withContentType(contentType: String) = withHeader("Content-Type", contentType); fun withTag(tag: String) : HttpHandler { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt index f6774c1e..afc2589e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt @@ -1,12 +1,20 @@ package com.futo.platformplayer.api.http.server.handlers import android.net.Uri +import android.util.Log import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.parsers.HttpResponseParser +import com.futo.platformplayer.readLine +import java.io.InputStream +import java.io.OutputStream +import java.lang.Exception +import java.net.Socket +import javax.net.ssl.SSLSocketFactory -class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) { +class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) { var content: String? = null; var contentType: String? = null; @@ -18,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt private var _injectHost = false; private var _injectReferer = false; - private val _client = ManagedHttpClient(); override fun handle(context: HttpContext) { + if (useTcp) { + handleWithTcp(context) + } else { + handleWithOkHttp(context) + } + } + + private fun handleWithOkHttp(context: HttpContext) { val proxyHeaders = HashMap(); for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) proxyHeaders[header.key] = header.value; @@ -35,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt proxyHeaders.put("Referer", targetUrl); val useMethod = if (method == "inherit") context.method else method; - Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}"); - Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); + Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}"); + Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); val resp = when (useMethod) { "GET" -> _client.get(targetUrl, proxyHeaders); @@ -46,7 +61,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt }; Logger.i(TAG, "Proxied Response [${resp.code}]"); - val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }); + val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) }); for(newHeader in headers) headersFiltered.put(newHeader.key, newHeader.value); @@ -66,6 +81,129 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt } } + private fun handleWithTcp(context: HttpContext) { + if (content != null) + throw NotImplementedError("Content body is not supported") + + val proxyHeaders = HashMap(); + for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) + proxyHeaders[header.key] = header.value; + for (injectHeader in _injectRequestHeader) + proxyHeaders[injectHeader.first] = injectHeader.second; + + val parsed = Uri.parse(targetUrl); + if(_injectHost) + proxyHeaders.put("Host", parsed.host!!); + if(_injectReferer) + proxyHeaders.put("Referer", targetUrl); + + val useMethod = if (method == "inherit") context.method else method; + Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${targetUrl}"); + Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); + + val requestBuilder = StringBuilder() + requestBuilder.append("$useMethod $targetUrl HTTP/1.1\r\n") + proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") } + requestBuilder.append("\r\n") + + val port = if (parsed.port == -1) { + when (parsed.scheme) { + "https" -> 443 + "http" -> 80 + else -> throw Exception("Unhandled scheme") + } + } else { + parsed.port + } + + val socket = if (parsed.scheme == "https") { + val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory + sslSocketFactory.createSocket(parsed.host, port) + } else { + Socket(parsed.host, port) + } + + socket.use { s -> + s.getOutputStream().write(requestBuilder.toString().encodeToByteArray()) + + val inputStream = s.getInputStream() + val resp = HttpResponseParser(inputStream) + val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true) + val contentLength = resp.contentLength.toInt() + + val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) }); + for(newHeader in headers) + headersFiltered.put(newHeader.key, newHeader.value); + + context.respond(resp.statusCode, headersFiltered) { responseStream -> + if (isChunked) { + Logger.i(TAG, "handleWithTcp handleChunkedTransfer"); + handleChunkedTransfer(inputStream, responseStream) + } else if (contentLength != -1) { + Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength"); + transferFixedLengthContent(inputStream, responseStream, contentLength) + } else { + Logger.i(TAG, "handleWithTcp transferUntilEndOfStream"); + transferUntilEndOfStream(inputStream, responseStream) + } + } + } + } + + private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) { + var line: String? + val buffer = ByteArray(8192) + + while (inputStream.readLine().also { line = it } != null) { + val size = line!!.trim().toInt(16) + Logger.i(TAG, "handleWithTcp handleChunkedTransfer chunk size $size") + + responseStream.write(line!!.encodeToByteArray()) + responseStream.write("\r\n".encodeToByteArray()) + + if (size == 0) { + inputStream.skip(2) + responseStream.write("\r\n".encodeToByteArray()) + break + } + + var totalRead = 0 + while (totalRead < size) { + val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead)) + if (read == -1) break + responseStream.write(buffer, 0, read) + totalRead += read + } + + inputStream.skip(2) + responseStream.write("\r\n".encodeToByteArray()) + responseStream.flush() + } + } + + private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) { + val buffer = ByteArray(8192) + var totalRead = 0 + while (totalRead < contentLength) { + val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead)) + if (read == -1) break + responseStream.write(buffer, 0, read) + totalRead += read + } + + responseStream.flush() + } + + private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) { + val buffer = ByteArray(8192) + var read: Int + while (inputStream.read(buffer).also { read = it } >= 0) { + responseStream.write(buffer, 0, read) + } + + responseStream.flush() + } + fun withContent(body: String) : HttpProxyHandler { this.content = body; return this; diff --git a/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt b/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt deleted file mode 100644 index 2744e8a0..00000000 --- a/app/src/main/java/com/futo/platformplayer/builders/HlsBuilder.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.futo.platformplayer.builders - -import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource -import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource -import java.io.PrintWriter -import java.io.StringWriter - -class HlsBuilder { - companion object{ - fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String { - val hlsBuilder = StringWriter() - PrintWriter(hlsBuilder).use { writer -> - writer.println("#EXTM3U") - - // Audio - if (audioSource != null && audioUrl != null) { - val audioFormat = audioSource.container.substringAfter("/") - writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&")}\",FORMAT=\"$audioFormat\"") - } - - // Subtitles - if (subtitleSource != null && subtitleUrl != null) { - val subtitleFormat = subtitleSource.format ?: "text/vtt" - writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&")}\",FORMAT=\"$subtitleFormat\"") - } - - // Video - val videoFormat = vidSource.container.substringAfter("/") - writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"") - writer.println(vidUrl.replace("&", "&")) - } - - return hlsBuilder.toString() - } - } -} \ No newline at end of file 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 6a3055e7..34b3da87 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -4,6 +4,7 @@ import android.content.ContentResolver import android.content.Context import android.os.Looper import com.futo.platformplayer.* +import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.handlers.* import com.futo.platformplayer.api.media.models.streams.sources.* @@ -15,6 +16,7 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo +import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp import kotlinx.coroutines.* import java.net.InetAddress @@ -45,6 +47,7 @@ class StateCasting { val onActiveDevicePlayChanged = Event1(); val onActiveDeviceTimeChanged = Event1(); var activeDevice: CastingDevice? = null; + private val _client = ManagedHttpClient(); val isCasting: Boolean get() = activeDevice != null; @@ -354,14 +357,22 @@ class StateCasting { } } else { if (videoSource is IVideoUrlSource) - ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); - else if(videoSource is IHLSManifestSource) - ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); else if (audioSource is IAudioUrlSource) - ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); - else if(audioSource is IHLSManifestAudioSource) - ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); - else if (videoSource is LocalVideoSource) + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); + else if(videoSource is IHLSManifestSource) { + if (ad is ChromecastCastingDevice) { + castHlsIndirect(video, videoSource.url, resumePosition); + } else { + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); + } + } else if(audioSource is IHLSManifestAudioSource) { + if (ad is ChromecastCastingDevice) { + castHlsIndirect(video, audioSource.url, resumePosition); + } else { + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); + } + } else if (videoSource is LocalVideoSource) castLocalVideo(video, videoSource, resumePosition); else if (audioSource is LocalAudioSource) castLocalAudio(video, audioSource, resumePosition); @@ -405,7 +416,7 @@ class StateCasting { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; @@ -424,7 +435,7 @@ class StateCasting { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val audioPath = "/audio-${id}" val audioUrl = url + audioPath; @@ -444,7 +455,7 @@ class StateCasting { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -505,7 +516,7 @@ class StateCasting { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val subtitlePath = "/subtitle-${id}"; @@ -547,11 +558,126 @@ class StateCasting { return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); } + private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List { + _castServer.removeAllHandlers("castHlsIndirectMaster") + + val ad = activeDevice ?: return listOf(); + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; + + val id = UUID.randomUUID(); + val hlsPath = "/hls-${id}" + val hlsUrl = url + hlsPath + Logger.i(TAG, "HLS url: $hlsUrl"); + + _castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext -> + _castServer.removeAllHandlers("castHlsIndirectVariant") + + val headers = masterContext.headers.clone() + headers["Content-Type"] = "application/vnd.apple.mpegurl"; + + val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl) + val newVariantPlaylistRefs = arrayListOf() + val newMediaRenditions = arrayListOf() + val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.independentSegments) + + for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + + newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( + newPlaylistUrl, + variantPlaylistRef.streamInfo + )) + } + + for (mediaRendition in masterPlaylist.mediaRenditions) { + val playlistId = UUID.randomUUID(); + val newPlaylistPath = "/hls-playlist-${playlistId}" + val newPlaylistUrl = url + newPlaylistPath; + + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + + newMediaRenditions.add(HLS.MediaRendition( + mediaRendition.type, + newPlaylistUrl, + mediaRendition.groupID, + mediaRendition.language, + mediaRendition.name, + mediaRendition.isDefault, + mediaRendition.isAutoSelect, + mediaRendition.isForced + )) + } + + masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectMaster") + + Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + + return listOf(hlsUrl); + } + + private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist): HLS.VariantPlaylist { + val newSegments = arrayListOf() + + variantPlaylist.segments.forEachIndexed { index, segment -> + val sequenceNumber = variantPlaylist.mediaSequence + index.toLong() + newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) + } + + return HLS.VariantPlaylist( + variantPlaylist.version, + variantPlaylist.targetDuration, + variantPlaylist.mediaSequence, + variantPlaylist.discontinuitySequence, + variantPlaylist.programDateTime, + newSegments + ) + } + + private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { + val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" + val newSegmentUrl = url + newSegmentPath; + + if (_castServer.getHandler("GET", newSegmentPath) == null) { + _castServer.addHandler( + HttpProxyHandler("GET", newSegmentPath, segment.uri, true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant") + } + + return HLS.Segment( + segment.duration, + newSegmentUrl + ) + } + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); val proxyStreams = ad !is FastCastCastingDevice; - val url = "http://${ad.localAddress}:${_castServer.port}"; + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; Logger.i(TAG, "DASH url: $url"); val id = UUID.randomUUID(); diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt new file mode 100644 index 00000000..c3fa6245 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -0,0 +1,266 @@ +package com.futo.platformplayer.parsers + +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.yesNoToBoolean +import java.net.URI +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class HLS { + companion object { + fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, sourceUrl: String): MasterPlaylist { + val masterPlaylistResponse = client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + val baseUrl = URI(sourceUrl).resolve("./").toString() + + val variantPlaylists = mutableListOf() + val mediaRenditions = 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) + + variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line))) + } + + line.startsWith("#EXT-X-MEDIA") -> { + mediaRenditions.add(parseMediaRendition(client, line, baseUrl)) + } + + line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { + independentSegments = true + } + } + } + + return MasterPlaylist(variantPlaylists, mediaRenditions, independentSegments) + } + + fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist { + val response = client.get(sourceUrl) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val content = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val lines = content.lines() + val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3 + val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull() + ?: throw Exception("Target duration not found in variant playlist") + val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() ?: 0 + val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() ?: 0 + val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { + ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) + } + + val segments = mutableListOf() + var currentSegment: Segment? = null + lines.forEach { line -> + when { + line.startsWith("#EXTINF:") -> { + val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() + ?: throw Exception("Invalid segment duration format") + currentSegment = Segment(duration = duration) + } + line.startsWith("#") -> { + // Handle other tags if necessary + } + else -> { + currentSegment?.let { + it.uri = line + segments.add(it) + } + currentSegment = null + } + } + } + + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, segments) + } + + private fun resolveUrl(baseUrl: String, url: String): String { + return if (URI(url).isAbsolute) url else baseUrl + url + } + + + private fun parseStreamInfo(content: String): StreamInfo { + val attributes = parseAttributes(content) + return StreamInfo( + bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(), + resolution = attributes["RESOLUTION"], + codecs = attributes["CODECS"], + frameRate = attributes["FRAME-RATE"], + videoRange = attributes["VIDEO-RANGE"], + audio = attributes["AUDIO"], + closedCaptions = attributes["CLOSED-CAPTIONS"] + ) + } + + private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition { + val attributes = parseAttributes(line) + val uri = attributes["URI"]!! + val url = resolveUrl(baseUrl, uri) + return MediaRendition( + type = attributes["TYPE"], + uri = url, + groupID = attributes["GROUP-ID"], + language = attributes["LANGUAGE"], + name = attributes["NAME"], + isDefault = attributes["DEFAULT"]?.yesNoToBoolean(), + isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(), + isForced = attributes["FORCED"]?.yesNoToBoolean() + ) + } + + private fun parseAttributes(content: String): Map { + val attributes = mutableMapOf() + val attributePairs = content.substringAfter(":").splitToSequence(',') + + var currentPair = StringBuilder() + for (pair in attributePairs) { + currentPair.append(pair) + if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even + val (key, value) = currentPair.toString().split('=') + attributes[key.trim()] = value.trim().removeSurrounding("\"") + currentPair = StringBuilder() // Reset for the next attribute + } else { + currentPair.append(',') // Continue building the current attribute pair + } + } + + return attributes + } + + private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO") + private fun shouldQuote(key: String, value: String?): Boolean { + if (value == null) + return false; + + if (value.contains(',')) + return true; + + return _quoteList.contains(key) + } + private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair) { + attributes.filter { it.second != null } + .joinToString(",") { + val value = it.second + "${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}" + } + .let { if (it.isNotEmpty()) stringBuilder.append(it) } + } + } + + data class StreamInfo( + val bandwidth: Int?, + val resolution: String?, + val codecs: String?, + val frameRate: String?, + val videoRange: String?, + val audio: String?, + val closedCaptions: String? + ) + + data class MediaRendition( + val type: String?, + val uri: String, + val groupID: String?, + val language: String?, + val name: String?, + val isDefault: Boolean?, + val isAutoSelect: Boolean?, + val isForced: Boolean? + ) { + fun toM3U8Line(): String = buildString { + append("#EXT-X-MEDIA:") + appendAttributes(this, + "TYPE" to type, + "URI" to uri, + "GROUP-ID" to groupID, + "LANGUAGE" to language, + "NAME" to name, + "DEFAULT" to isDefault?.toString()?.uppercase(), + "AUTOSELECT" to isAutoSelect?.toString()?.uppercase(), + "FORCED" to isForced?.toString()?.uppercase() + ) + append("\n") + } + } + + data class MasterPlaylist( + val variantPlaylistsRefs: List, + val mediaRenditions: List, + val independentSegments: Boolean + ) { + fun buildM3U8(): String { + val builder = StringBuilder() + builder.append("#EXTM3U\n") + if (independentSegments) { + builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n") + } + + mediaRenditions.forEach { rendition -> + builder.append(rendition.toM3U8Line()) + } + + variantPlaylistsRefs.forEach { variant -> + builder.append(variant.toM3U8Line()) + } + + return builder.toString() + } + } + + data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) { + fun toM3U8Line(): String = buildString { + append("#EXT-X-STREAM-INF:") + appendAttributes(this, + "BANDWIDTH" to streamInfo.bandwidth?.toString(), + "RESOLUTION" to streamInfo.resolution, + "CODECS" to streamInfo.codecs, + "FRAME-RATE" to streamInfo.frameRate, + "VIDEO-RANGE" to streamInfo.videoRange, + "AUDIO" to streamInfo.audio, + "CLOSED-CAPTIONS" to streamInfo.closedCaptions + ) + append("\n$url\n") + } + } + + data class VariantPlaylist( + val version: Int, + val targetDuration: Int, + val mediaSequence: Long, + val discontinuitySequence: Int, + val programDateTime: ZonedDateTime?, + val segments: List + ) { + fun buildM3U8(): String = buildString { + append("#EXTM3U\n") + append("#EXT-X-VERSION:$version\n") + append("#EXT-X-TARGETDURATION:$targetDuration\n") + append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n") + append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n") + programDateTime?.let { + append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") + } + + segments.forEach { segment -> + append("#EXTINF:${segment.duration},\n") + append(segment.uri + "\n") + } + } + } + + data class Segment( + val duration: Double, + var uri: String = "" + ) +} diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt b/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt new file mode 100644 index 00000000..2209ba24 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt @@ -0,0 +1,64 @@ +package com.futo.platformplayer.parsers + +import com.futo.platformplayer.api.http.server.HttpHeaders +import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException +import com.futo.platformplayer.readHttpHeaderBytes +import java.io.ByteArrayInputStream +import java.io.InputStream + +class HttpResponseParser : AutoCloseable { + private val _inputStream: InputStream; + + var head: String = ""; + var headers: HttpHeaders = HttpHeaders(); + + var contentType: String? = null; + var transferEncoding: String? = null; + var contentLength: Long = -1L; + + var statusCode: Int = -1; + + constructor(inputStream: InputStream) { + _inputStream = inputStream; + + val headerBytes = inputStream.readHttpHeaderBytes() + ByteArrayInputStream(headerBytes).use { + val reader = it.bufferedReader(Charsets.UTF_8) + head = reader.readLine() ?: throw EmptyRequestException("No head found"); + + val statusLineParts = head.split(" ") + if (statusLineParts.size < 3) { + throw IllegalStateException("Invalid status line") + } + + statusCode = statusLineParts[1].toInt() + + while (true) { + val line = reader.readLine(); + val headerEndIndex = line.indexOf(":"); + if (headerEndIndex == -1) + break; + + val headerKey = line.substring(0, headerEndIndex).lowercase() + val headerValue = line.substring(headerEndIndex + 1).trim(); + headers[headerKey] = headerValue; + + when(headerKey) { + "content-length" -> contentLength = headerValue.toLong(); + "content-type" -> contentType = headerValue; + "transfer-encoding" -> transferEncoding = headerValue; + } + if(line.isNullOrEmpty()) + break; + } + } + } + + override fun close() { + _inputStream.close(); + } + + companion object { + private val TAG = "HttpResponse"; + } +} \ No newline at end of file From e221b508d31ae628482bdf7d53547f07056f4c50 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 21 Nov 2023 23:31:26 +0100 Subject: [PATCH 02/23] Improved notifications, experimental scheduled notifications --- app/src/main/AndroidManifest.xml | 3 + .../java/com/futo/platformplayer/Settings.kt | 24 +-- .../com/futo/platformplayer/SettingsDev.kt | 9 ++ .../futo/platformplayer/UISlideOverlays.kt | 103 ++++++------ .../background/BackgroundWorker.kt | 70 +++------ .../cache/ChannelContentCache.kt | 8 + .../receivers/PlannedNotificationReceiver.kt | 48 ++++++ .../states/StateNotifications.kt | 147 ++++++++++++++++++ .../stores/FragmentedStorage.kt | 2 +- app/src/main/res/values/strings.xml | 3 + 10 files changed, 313 insertions(+), 104 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt create mode 100644 app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fd04972..a659758a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + + (); - var menu: SlideUpMenuOverlay? = null; val originalNotif = subscription.doNotifications; val originalLive = subscription.doFetchLive; @@ -62,54 +62,69 @@ class UISlideOverlays { val originalVideo = subscription.doFetchVideos; val originalPosts = subscription.doFetchPosts; - items.addAll(listOf( - SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { - subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; - }, false), - SlideUpMenuGroup(container.context, "Fetch Settings", - "Depending on the platform you might not need to enable a type for it to be available.", - -1, listOf()), - SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", { - subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", { - subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive; - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive; - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", { - subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive; - }, false))); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ + val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); + val capabilities = plugin.getChannelCapabilities(); - menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items); + withContext(Dispatchers.Main) { - if(subscription.doNotifications) - menu.selectOption(null, "notifications", true, true); - if(subscription.doFetchLive) - menu.selectOption(null, "fetchLive", true, true); - if(subscription.doFetchStreams) - menu.selectOption(null, "fetchStreams", true, true); - if(subscription.doFetchVideos) - menu.selectOption(null, "fetchVideos", true, true); - if(subscription.doFetchPosts) - menu.selectOption(null, "fetchPosts", true, true); + var menu: SlideUpMenuOverlay? = null; - menu.onOK.subscribe { - subscription.save(); - menu.hide(true); - }; - menu.onCancel.subscribe { - subscription.doNotifications = originalNotif; - subscription.doFetchLive = originalLive; - subscription.doFetchStreams = originalStream; - subscription.doFetchVideos = originalVideo; - subscription.doFetchPosts = originalPosts; - }; - menu.setOk("Save"); + items.addAll(listOf( + SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { + subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; + }, false), + SlideUpMenuGroup(container.context, "Fetch Settings", + "Depending on the platform you might not need to enable a type for it to be available.", + -1, listOf()), + if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", { + subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; + }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", { + subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; + }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) + SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", { + subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; + }, false) else null).filterNotNull()); - menu.show(); + menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items); + + if(subscription.doNotifications) + menu.selectOption(null, "notifications", true, true); + if(subscription.doFetchLive) + menu.selectOption(null, "fetchLive", true, true); + if(subscription.doFetchStreams) + menu.selectOption(null, "fetchStreams", true, true); + if(subscription.doFetchVideos) + menu.selectOption(null, "fetchVideos", true, true); + if(subscription.doFetchPosts) + menu.selectOption(null, "fetchPosts", true, true); + + menu.onOK.subscribe { + subscription.save(); + menu.hide(true); + }; + menu.onCancel.subscribe { + subscription.doNotifications = originalNotif; + subscription.doFetchLive = originalLive; + subscription.doFetchStreams = originalStream; + subscription.doFetchVideos = originalVideo; + subscription.doFetchPosts = originalPosts; + }; + + menu.setOk("Save"); + + menu.show(); + } + } } fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? { diff --git a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt index e2566c40..fbebe2b2 100644 --- a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt +++ b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt @@ -6,33 +6,25 @@ import android.app.PendingIntent import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable -import android.media.MediaSession2Service.MediaNotification -import androidx.concurrent.futures.CallbackToFutureAdapter -import androidx.concurrent.futures.ResolvableFuture import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker -import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.Settings import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.IPlatformVideo -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateNotifications import com.futo.platformplayer.states.StateSubscriptions -import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder -import com.google.common.util.concurrent.ListenableFuture +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNowDiffStringMinDay import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.time.OffsetDateTime @@ -54,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, private val workerParams this.setSound(null, null); }; notificationManager.createNotificationChannel(notificationChannel); + val contentChannel = StateNotifications.instance.contentNotifChannel + notificationManager.createNotificationChannel(contentChannel); try { - doSubscriptionUpdating(notificationManager, notificationChannel); + doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel); } catch(ex: Throwable) { exception = ex; @@ -77,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, private val workerParams } - suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) { - val notif = NotificationCompat.Builder(appContext, notificationChannel.id) + suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) { + val notif = NotificationCompat.Builder(appContext, backgroundChannel.id) .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) .setContentTitle("Grayjay") .setContentText("Updating subscriptions...") .setSilent(true) - .setChannelId(notificationChannel.id) + .setChannelId(backgroundChannel.id) .setProgress(1, 0, true); manager.notify(12, notif.build()); @@ -94,6 +88,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams val newItems = mutableListOf(); val now = OffsetDateTime.now(); + val threeDays = now.minusDays(4); val contentNotifs = mutableListOf>(); withContext(Dispatchers.IO) { val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total -> @@ -111,8 +106,14 @@ class BackgroundWorker(private val appContext: Context, private val workerParams synchronized(newSubChanges) { if(!newSubChanges.contains(sub)) { newSubChanges.add(sub); - if(sub.doNotifications && content.datetime?.let { it < now } == true) - contentNotifs.add(Pair(sub, content)); + if(sub.doNotifications) { + if(content.datetime != null) { + if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays) + contentNotifs.add(Pair(sub, content)); + else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification) + StateNotifications.instance.scheduleContentNotification(applicationContext, content); + } + } } newItems.add(content); } @@ -135,22 +136,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams val items = contentNotifs.take(5).toList() for(i in items.indices) { val contentNotif = items.get(i); - val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail() - else null; - if(thumbnail != null) - Glide.with(appContext).asBitmap() - .load(thumbnail) - .into(object: CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource); - } - override fun onLoadCleared(placeholder: Drawable?) {} - override fun onLoadFailed(errorDrawable: Drawable?) { - notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null); - } - }) - else - notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null); + StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second); } } catch(ex: Throwable) { @@ -165,20 +151,4 @@ class BackgroundWorker(private val appContext: Context, private val workerParams .setSilent(true) .setChannelId(notificationChannel.id).build());*/ } - - fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) { - val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id) - .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) - .setContentTitle("New by [${sub.channel.name}]") - .setContentText("${content.name}") - .setSilent(true) - .setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setChannelId(notificationChannel.id); - if(thumbnail != null) { - //notifBuilder.setLargeIcon(thumbnail); - notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?)); - } - manager.notify(id, notifBuilder.build()); - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt index 5a5faa6b..87614cc0 100644 --- a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt +++ b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt @@ -58,6 +58,14 @@ class ChannelContentCache { uncacheContent(content); } } + fun clearToday() { + val yesterday = OffsetDateTime.now().minusDays(1); + synchronized(_channelContents) { + for(channel in _channelContents) + for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true }) + uncacheContent(content); + } + } fun getChannelCachePager(channelUrl: String): PlatformContentPager { val validID = channelUrl.toSafeFileName(); diff --git a/app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt new file mode 100644 index 00000000..e1805c23 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.receivers + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.futo.platformplayer.Settings +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateNotifications + + +class PlannedNotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + try { + Logger.i(TAG, "Planned Notification received"); + if(!Settings.instance.notifications.plannedContentNotification) + return; + if(StateApp.instance.contextOrNull == null) + StateApp.instance.initializeFiles(); + + val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true); + if(!notifs.isEmpty() && context != null) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + val channel = StateNotifications.instance.contentNotifChannel; + notificationManager.createNotificationChannel(channel); + var i = 0; + for (notif in notifs) { + StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif); + i++; + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex); + } + } + + companion object { + private val TAG = "PlannedNotificationReceiver" + + fun getIntent(context: Context): PendingIntent { + return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt new file mode 100644 index 00000000..1ce15a20 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt @@ -0,0 +1,147 @@ +package com.futo.platformplayer.states + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Build +import androidx.core.app.NotificationCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.receivers.PlannedNotificationReceiver +import com.futo.platformplayer.serializers.PlatformContentSerializer +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNowDiffStringMinDay +import java.time.OffsetDateTime + +class StateNotifications { + private val _alarmManagerLock = Object(); + private var _alarmManager: AlarmManager? = null; + val plannedWarningMinutesEarly: Long = 10; + + val contentNotifChannel = NotificationChannel("contentChannel", "Content Notifications", + NotificationManager.IMPORTANCE_HIGH).apply { + this.enableVibration(false); + this.setSound(null, null); + }; + + private val _plannedContent = FragmentedStorage.storeJson("planned_content_notifs", PlatformContentSerializer()) + .load(); + + private fun getAlarmManager(context: Context): AlarmManager { + synchronized(_alarmManagerLock) { + if(_alarmManager == null) + _alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager; + return _alarmManager!!; + } + } + + fun scheduleContentNotification(context: Context, content: IPlatformContent) { + try { + var existing = _plannedContent.findItem { it.url == content.url }; + if(existing != null) { + _plannedContent.delete(existing); + existing = null; + } + if(existing == null && content.datetime != null) { + val item = SerializedPlatformContent.fromContent(content); + _plannedContent.saveAsync(item); + + val manager = getAlarmManager(context); + val notifyDateTime = content.datetime!!.minusMinutes(plannedWarningMinutesEarly); + if(Build.VERSION.SDK_INT >= 31 && !manager.canScheduleExactAlarms()) { + Logger.i(TAG, "Scheduling in-exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}") + manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context)); + } + else { + Logger.i(TAG, "Scheduling exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}") + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context)) + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "scheduleContentNotification failed for [${content.name}]", ex); + } + } + fun removeChannelPlannedContent(channelUrl: String) { + val toDeletes = _plannedContent.findItems { it.author.url == channelUrl }; + for(toDelete in toDeletes) + _plannedContent.delete(toDelete); + } + + fun getScheduledNotifications(secondsFuture: Long, deleteReturned: Boolean = false): List { + val minDate = OffsetDateTime.now().plusSeconds(secondsFuture); + val toNotify = _plannedContent.findItems { it.datetime?.let { it.isBefore(minDate) } == true } + + if(deleteReturned) { + for(toDelete in toNotify) + _plannedContent.delete(toDelete); + } + return toNotify; + } + + fun notifyNewContentWithThumbnail(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent) { + val thumbnail = if(content is IPlatformVideo) (content as IPlatformVideo).thumbnails.getHQThumbnail() + else null; + if(thumbnail != null) + Glide.with(context).asBitmap() + .load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + notifyNewContent(context, manager, notificationChannel, id, content, resource); + } + override fun onLoadCleared(placeholder: Drawable?) {} + override fun onLoadFailed(errorDrawable: Drawable?) { + notifyNewContent(context, manager, notificationChannel, id, content, null); + } + }) + else + notifyNewContent(context, manager, notificationChannel, id, content, null); + } + + fun notifyNewContent(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent, thumbnail: Bitmap? = null) { + val notifBuilder = NotificationCompat.Builder(context, notificationChannel.id) + .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) + .setContentTitle("New by [${content.author.name}]") + .setContentText("${content.name}") + .setSubText(content.datetime?.toHumanNowDiffStringMinDay()) + .setSilent(true) + .setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.getVideoIntent(context, content.url), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setChannelId(notificationChannel.id); + if(thumbnail != null) { + //notifBuilder.setLargeIcon(thumbnail); + notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?)); + } + manager.notify(id, notifBuilder.build()); + } + + + companion object { + val TAG = "StateNotifications"; + private var _instance : StateNotifications? = null; + val instance : StateNotifications + get(){ + if(_instance == null) + _instance = StateNotifications(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt index a996a9c8..6426e948 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -31,7 +31,7 @@ class FragmentedStorage { fun initialize(filesDir: File) { _filesDir = filesDir; } - + inline fun storeJson(name: String, serializer: KSerializer? = null): ManagedStore = store(name, JsonStoreSerializer.create(serializer), null, null); inline fun storeJson(parentDir: File, name: String, serializer: KSerializer? = null): ManagedStore = store(name, JsonStoreSerializer.create(serializer), null, parentDir); inline fun storeJson(name: String, prettyName: String? = null, parentDir: File? = null): ManagedStore = store(name, JsonStoreSerializer.create(), prettyName, parentDir); inline fun store(name: String, serializer: StoreSerializer, prettyName: String? = null, parentDir: File? = null): ManagedStore { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 069318e6..c6514a13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,9 @@ A list of user-reported and self-reported issues Also removes any data related plugin like login or settings Announcement + Notifications + Planned Content Notifications + Schedules discovered planned content as notifications, resulting in more accurate notifications for this content. Attempt to utilize byte ranges Auto Update Auto-Rotate From fad1b216dfbc1d8515d0895e08ab4e7c70662592 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 09:32:52 +0100 Subject: [PATCH 03/23] Further extended HLS spec that is implemented. --- .../futo/platformplayer/Extensions_Syntax.kt | 26 ++++++++++ .../platformplayer/casting/StateCasting.kt | 36 ++++++++------ .../com/futo/platformplayer/parsers/HLS.kt | 47 ++++++++++++++++--- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 0b79de90..6ce03b70 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -1,5 +1,10 @@ package com.futo.platformplayer +import android.net.Uri +import java.net.URI +import java.net.URISyntaxException +import java.net.URLEncoder + //Syntax sugaring inline fun Any.assume(): T?{ if(this is T) @@ -16,4 +21,25 @@ inline fun Any.assume(cb: (T) -> R): R? { fun String?.yesNoToBoolean(): Boolean { return this?.uppercase() == "YES" +} + +fun String?.toURIRobust(): URI? { + if (this == null) { + return null + } + + try { + return URI(this) + } catch (e: URISyntaxException) { + val parts = this.split("\\?".toRegex(), 2) + if (parts.size < 2) { + return null + } + + val beforeQuery = parts[0] + val query = parts[1] + val encodedQuery = URLEncoder.encode(query, "UTF-8") + val rebuiltUrl = "$beforeQuery?$encodedQuery" + return URI(rebuiltUrl) + } } \ No newline at end of file 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 34b3da87..ad136bdb 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -361,13 +361,13 @@ class StateCasting { else if (audioSource is IAudioUrlSource) ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); else if(videoSource is IHLSManifestSource) { - if (ad is ChromecastCastingDevice) { + if (ad is ChromecastCastingDevice && video.isLive) { castHlsIndirect(video, videoSource.url, resumePosition); } else { ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); } } else if(audioSource is IHLSManifestAudioSource) { - if (ad is ChromecastCastingDevice) { + if (ad is ChromecastCastingDevice && video.isLive) { castHlsIndirect(video, audioSource.url, resumePosition); } else { ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); @@ -578,7 +578,7 @@ class StateCasting { val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl) val newVariantPlaylistRefs = arrayListOf() val newMediaRenditions = arrayListOf() - val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.independentSegments) + val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) { val playlistId = UUID.randomUUID(); @@ -606,15 +606,17 @@ class StateCasting { val newPlaylistPath = "/hls-playlist-${playlistId}" val newPlaylistUrl = url + newPlaylistPath; - _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> - val vpHeaders = vpContext.headers.clone() - vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + if (mediaRendition.uri != null) { + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + val vpHeaders = vpContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) - val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() - vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + } newMediaRenditions.add(HLS.MediaRendition( mediaRendition.type, @@ -637,12 +639,16 @@ class StateCasting { return listOf(hlsUrl); } - private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist): HLS.VariantPlaylist { + private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, proxySegments: Boolean = true): HLS.VariantPlaylist { val newSegments = arrayListOf() - variantPlaylist.segments.forEachIndexed { index, segment -> - val sequenceNumber = variantPlaylist.mediaSequence + index.toLong() - newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) + if (proxySegments) { + variantPlaylist.segments.forEachIndexed { index, segment -> + val sequenceNumber = variantPlaylist.mediaSequence + index.toLong() + newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) + } + } else { + newSegments.addAll(variantPlaylist.segments) } return HLS.VariantPlaylist( 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 c3fa6245..6f37e815 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.parsers import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.toURIRobust import com.futo.platformplayer.yesNoToBoolean import java.net.URI import java.time.ZonedDateTime @@ -14,10 +15,11 @@ class HLS { val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") - val baseUrl = URI(sourceUrl).resolve("./").toString() + val baseUrl = sourceUrl.toURIRobust()!!.resolve("./").toString() val variantPlaylists = mutableListOf() val mediaRenditions = mutableListOf() + val sessionDataList = mutableListOf() var independentSegments = false masterPlaylistContent.lines().forEachIndexed { index, line -> @@ -37,10 +39,15 @@ class HLS { line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { independentSegments = true } + + line.startsWith("#EXT-X-SESSION-DATA") -> { + val sessionData = parseSessionData(line) + sessionDataList.add(sessionData) + } } } - return MasterPlaylist(variantPlaylists, mediaRenditions, independentSegments) + return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) } fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist { @@ -86,7 +93,7 @@ class HLS { } private fun resolveUrl(baseUrl: String, url: String): String { - return if (URI(url).isAbsolute) url else baseUrl + url + return if (url.toURIRobust()!!.isAbsolute) url else baseUrl + url } @@ -105,11 +112,10 @@ class HLS { private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition { val attributes = parseAttributes(line) - val uri = attributes["URI"]!! - val url = resolveUrl(baseUrl, uri) + val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) } return MediaRendition( type = attributes["TYPE"], - uri = url, + uri = uri, groupID = attributes["GROUP-ID"], language = attributes["LANGUAGE"], name = attributes["NAME"], @@ -119,6 +125,13 @@ class HLS { ) } + private fun parseSessionData(line: String): SessionData { + val attributes = parseAttributes(line) + val dataId = attributes["DATA-ID"]!! + val value = attributes["VALUE"]!! + return SessionData(dataId, value) + } + private fun parseAttributes(content: String): Map { val attributes = mutableMapOf() val attributePairs = content.substringAfter(":").splitToSequence(',') @@ -158,6 +171,20 @@ class HLS { } } + data class SessionData( + val dataId: String, + val value: String + ) { + fun toM3U8Line(): String = buildString { + append("#EXT-X-SESSION-DATA:") + appendAttributes(this, + "DATA-ID" to dataId, + "VALUE" to value + ) + append("\n") + } + } + data class StreamInfo( val bandwidth: Int?, val resolution: String?, @@ -170,7 +197,7 @@ class HLS { data class MediaRendition( val type: String?, - val uri: String, + val uri: String?, val groupID: String?, val language: String?, val name: String?, @@ -194,9 +221,11 @@ class HLS { } } + data class MasterPlaylist( val variantPlaylistsRefs: List, val mediaRenditions: List, + val sessionDataList: List, val independentSegments: Boolean ) { fun buildM3U8(): String { @@ -214,6 +243,10 @@ class HLS { builder.append(variant.toM3U8Line()) } + sessionDataList.forEach { data -> + builder.append(data.toM3U8Line()) + } + return builder.toString() } } From 23d9c334067cd85a55565c966faeb69150e3cf33 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 10:27:35 +0100 Subject: [PATCH 04/23] Added support for v6 Odysee URLs. --- app/src/stable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/odysee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index a8bc4ff9..6ea20460 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit a8bc4ff91301ef70f8fbabf181e78bbed828156d +Subproject commit 6ea204605d4a27867702d7b024237506904d53c7 diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index a8bc4ff9..6ea20460 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit a8bc4ff91301ef70f8fbabf181e78bbed828156d +Subproject commit 6ea204605d4a27867702d7b024237506904d53c7 From aaea5cc9635d8a369971a408a5753bfd2ca7e1cf Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 10:38:04 +0100 Subject: [PATCH 05/23] Only close the app on closeSegment if there is no video playing. --- .../futo/platformplayer/activities/MainActivity.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 0f280a79..a9839361 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -8,6 +8,7 @@ import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.preference.PreferenceManager +import android.util.Log import android.util.TypedValue import android.view.View import android.widget.FrameLayout @@ -884,15 +885,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) { navigate(fragBeforeOverlay!!, null, false, true); - - } - else { + } else { val last = _queue.lastOrNull(); if (last != null) { _queue.remove(last); navigate(last.first, last.second, false, true); - } else - finish(); + } else { + if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { + finish(); + } + } } } From bfdcab0e8435fb4d340a00a4ba8e0913aaa21168 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 11:21:18 +0100 Subject: [PATCH 06/23] Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. --- dep/polycentricandroid | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dep/polycentricandroid b/dep/polycentricandroid index 7de4d54c..2085892a 160000 --- a/dep/polycentricandroid +++ b/dep/polycentricandroid @@ -1 +1 @@ -Subproject commit 7de4d54c25f087a2bc76a2704e575a6f9441987b +Subproject commit 2085892acb899cf3e1d8f9f0e04c983d86de0e60 From 4c022698d3b3a23ab4e397eb5bad9754a1ecba07 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 11:32:51 +0100 Subject: [PATCH 07/23] Quality selection overlay now properly closes when pressing the back button. --- .../platformplayer/fragment/mainactivity/main/VideoDetailView.kt | 1 + 1 file changed, 1 insertion(+) 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 5a366550..3eb39e43 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 @@ -1486,6 +1486,7 @@ class VideoDetailView : ConstraintLayout { _overlay_quality_selector?.selectOption("audio", _lastAudioSource); _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); _overlay_quality_selector?.show(); + _slideUpOverlay = _overlay_quality_selector; } fun prevVideo() { From 01787b6229ab994b331eb7618bde7a1c90272965 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 12:46:39 +0100 Subject: [PATCH 08/23] Added backfill exception printing to announcements. --- .../platformplayer/Extensions_Polycentric.kt | 22 +++++++++++++++++++ .../PolycentricCreateProfileActivity.kt | 3 ++- .../activities/PolycentricProfileActivity.kt | 3 ++- .../platformplayer/dialogs/CommentDialog.kt | 3 ++- .../mainactivity/main/PostDetailFragment.kt | 3 ++- .../mainactivity/main/VideoDetailView.kt | 2 +- .../views/adapters/CommentViewHolder.kt | 2 +- dep/polycentricandroid | 2 +- 8 files changed, 33 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt index 77a73917..7737df09 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt @@ -1,6 +1,11 @@ package com.futo.platformplayer +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.AnnouncementType +import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.adapters.CommentViewHolder +import com.futo.polycentric.core.ProcessHandle import userpackage.Protocol import kotlin.math.abs import kotlin.math.min @@ -39,4 +44,21 @@ fun Protocol.Claim.resolveChannelUrl(): String? { fun Protocol.Claim.resolveChannelUrls(): List { return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) +} + +suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() { + val exceptions = fullyBackfillServers() + for (pair in exceptions) { + val server = pair.key + val exception = pair.value + + StateAnnouncement.instance.registerAnnouncement( + "backfill-failed", + "Backfill failed", + "Failed to backfill server $server. $exception", + AnnouncementType.SESSION_RECURRING + ); + + Logger.e("Backfill", "Failed to backfill server $server.", exception) + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt index 2129eb75..32b4f42a 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt @@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp @@ -82,7 +83,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() { try { Logger.i(TAG, "Started backfill"); - processHandle.fullyBackfillServers(); + processHandle.fullyBackfillServersAnnounceExceptions(); Logger.i(TAG, "Finished backfill"); } catch (e: Throwable) { Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e); diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt index 4c03d06d..b207da44 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt @@ -19,6 +19,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.dialogs.CommentDialog import com.futo.platformplayer.dp +import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.selectBestImage @@ -194,7 +195,7 @@ class PolycentricProfileActivity : AppCompatActivity() { if (hasChanges) { try { Logger.i(TAG, "Started backfill"); - processHandle.fullyBackfillServers(); + processHandle.fullyBackfillServersAnnounceExceptions(); Logger.i(TAG, "Finished backfill"); withContext(Dispatchers.Main) { UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved)); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt index 2c78b694..584c8465 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt @@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.dp +import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp @@ -97,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { Logger.i(TAG, "Started backfill"); - processHandle.fullyBackfillServers() + processHandle.fullyBackfillServersAnnounceExceptions() Logger.i(TAG, "Finished backfill"); } catch (e: Throwable) { Logger.e(TAG, "Failed to backfill servers.", e); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index 4e8d8bca..b35cd912 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -31,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.fixHtmlWhitespace +import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.polycentric.PolycentricCache @@ -363,7 +364,7 @@ class PostDetailFragment : MainFragment { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServers(); + args.processHandle.fullyBackfillServersAnnounceExceptions(); Logger.i(TAG, "Finished backfill"); } catch (e: Throwable) { Logger.e(TAG, "Failed to backfill servers", e) 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 3eb39e43..d529062d 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 @@ -1181,7 +1181,7 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.IO) { try { Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServers(); + args.processHandle.fullyBackfillServersAnnounceExceptions(); Logger.i(TAG, "Finished backfill"); } catch (e: Throwable) { Logger.e(TAG, "Failed to backfill servers", e) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt index 65aa180e..79660207 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt @@ -75,7 +75,7 @@ class CommentViewHolder : ViewHolder { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServers(); + args.processHandle.fullyBackfillServersAnnounceExceptions(); Logger.i(TAG, "Finished backfill"); } catch (e: Throwable) { Logger.e(TAG, "Failed to backfill servers.", e) diff --git a/dep/polycentricandroid b/dep/polycentricandroid index 2085892a..839e4c4a 160000 --- a/dep/polycentricandroid +++ b/dep/polycentricandroid @@ -1 +1 @@ -Subproject commit 2085892acb899cf3e1d8f9f0e04c983d86de0e60 +Subproject commit 839e4c4a4f5ed6cb6f68047f88b26c5831e6e703 From b09d22e479312b539db96ac8a8465b30b930a024 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 14:49:34 +0100 Subject: [PATCH 09/23] Added historical time bars to videos. --- .../java/com/futo/platformplayer/Settings.kt | 40 ++++++++++++++----- .../platformplayer/activities/MainActivity.kt | 4 ++ .../channel/tab/ChannelContentsFragment.kt | 3 +- .../mainactivity/main/ContentFeedView.kt | 3 +- .../main/ContentSearchResultsFragment.kt | 1 + .../mainactivity/main/HomeFragment.kt | 1 + .../main/SubscriptionsFeedFragment.kt | 2 + .../feedtypes/PreviewContentListAdapter.kt | 6 ++- .../adapters/feedtypes/PreviewVideoView.kt | 19 ++++++++- .../feedtypes/PreviewVideoViewHolder.kt | 4 +- .../main/res/layout/list_video_preview.xml | 14 +++++++ .../main/res/layout/list_video_thumbnail.xml | 14 +++++++ app/src/main/res/values/strings.xml | 3 ++ 13 files changed, 97 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index fc4b1fb7..9ef64a86 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -446,8 +446,28 @@ class Settings : FragmentedStorageFileJson() { }*/ } + @FormField(R.string.time_bar, "group", R.string.configure_if_historical_time_bar_should_be_shown, 8) + var timeBars = TimeBars(); + @Serializable + class TimeBars { + @FormField(R.string.home, FieldForm.TOGGLE, -1, 0) + @Serializable(with = FlexibleBooleanSerializer::class) + var home: Boolean = true; - @FormField(R.string.logging, FieldForm.GROUP, -1, 8) + @FormField(R.string.subscriptions, FieldForm.TOGGLE, -1, 1) + @Serializable(with = FlexibleBooleanSerializer::class) + var subscriptions: Boolean = true; + + @FormField(R.string.search, FieldForm.TOGGLE, -1, 2) + @Serializable(with = FlexibleBooleanSerializer::class) + var search: Boolean = true; + + @FormField(R.string.channel, FieldForm.TOGGLE, -1, 3) + @Serializable(with = FlexibleBooleanSerializer::class) + var channel: Boolean = true; + } + + @FormField(R.string.logging, FieldForm.GROUP, -1, 9) var logging = Logging(); @Serializable class Logging { @@ -471,7 +491,7 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.announcement, FieldForm.GROUP, -1, 10) + @FormField(R.string.announcement, FieldForm.GROUP, -1, 11) var announcementSettings = AnnouncementSettings(); @Serializable class AnnouncementSettings { @@ -482,7 +502,7 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.notifications, FieldForm.GROUP, -1, 11) + @FormField(R.string.notifications, FieldForm.GROUP, -1, 12) var notifications = NotificationSettings(); @Serializable class NotificationSettings { @@ -490,7 +510,7 @@ class Settings : FragmentedStorageFileJson() { var plannedContentNotification: Boolean = true; } - @FormField(R.string.plugins, FieldForm.GROUP, -1, 12) + @FormField(R.string.plugins, FieldForm.GROUP, -1, 13) @Transient var plugins = Plugins(); @Serializable @@ -527,7 +547,7 @@ class Settings : FragmentedStorageFileJson() { } - @FormField(R.string.external_storage, FieldForm.GROUP, -1, 13) + @FormField(R.string.external_storage, FieldForm.GROUP, -1, 14) var storage = Storage(); @Serializable class Storage { @@ -561,7 +581,7 @@ class Settings : FragmentedStorageFileJson() { } - @FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 14) + @FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15) var autoUpdate = AutoUpdate(); @Serializable class AutoUpdate { @@ -643,7 +663,7 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.backup, FieldForm.GROUP, -1, 15) + @FormField(R.string.backup, FieldForm.GROUP, -1, 16) var backup = Backup(); @Serializable class Backup { @@ -696,7 +716,7 @@ class Settings : FragmentedStorageFileJson() { }*/ } - @FormField(R.string.payment, FieldForm.GROUP, -1, 16) + @FormField(R.string.payment, FieldForm.GROUP, -1, 17) var payment = Payment(); @Serializable class Payment { @@ -713,7 +733,7 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.other, FieldForm.GROUP, -1, 17) + @FormField(R.string.other, FieldForm.GROUP, -1, 18) var other = Other(); @Serializable class Other { @@ -722,7 +742,7 @@ class Settings : FragmentedStorageFileJson() { var bypassRotationPrevention: Boolean = false; } - @FormField(R.string.info, FieldForm.GROUP, -1, 18) + @FormField(R.string.info, FieldForm.GROUP, -1, 19) var info = Info(); @Serializable class Info { diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index a9839361..c1d849ef 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -893,6 +893,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } else { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { finish(); + } else { + UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { + finish(); + }) } } } 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 0d92eec8..9ac89b82 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 @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -151,7 +152,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { _recyclerResults = view.findViewById(R.id.recycler_videos); - _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply { + _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.timeBars.channel).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index 686c54e5..2986eb20 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -37,6 +37,7 @@ abstract class ContentFeedView : FeedView, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { @@ -57,7 +58,7 @@ abstract class ContentFeedView : FeedView>; + override val shouldShowTimeBar: Boolean get() = Settings.instance.timeBars.search constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index ebf5d56e..ee1944e9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -95,6 +95,7 @@ class HomeFragment : MainFragment() { private var _announcementsView: AnnouncementView; private val _taskGetPager: TaskHandler>; + override val shouldShowTimeBar: Boolean get() = Settings.instance.timeBars.home constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { _announcementsView = AnnouncementView(context, null).apply { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index adeb0390..7317cb95 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -93,6 +93,8 @@ class SubscriptionsFeedFragment : MainFragment() { @SuppressLint("ViewConstructor") class SubscriptionsFeedView : ContentFeedView { + override val shouldShowTimeBar: Boolean get() = Settings.instance.timeBars.subscriptions + constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { Logger.i(TAG, "SubscriptionsFeedFragment constructor()"); StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total -> diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt index 503a26c2..112fab64 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt @@ -29,6 +29,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onContentUrlClicked = Event2(); @@ -48,12 +49,13 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader, exoPlayer: PlayerManager? = null, initialPlay: Boolean = false, viewsToPrepend: ArrayList = arrayListOf(), - viewsToAppend: ArrayList = arrayListOf()) : super(context, viewsToPrepend, viewsToAppend) { + viewsToAppend: ArrayList = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) { this._feedStyle = feedStyle; this._dataSet = dataSet; this._initialPlay = initialPlay; this._exoPlayer = exoPlayer; + this._shouldShowTimeBar = shouldShowTimeBar } override fun getChildCount(): Int = _dataSet.size; @@ -97,7 +99,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onLongPress = Event1(); val onChannelClicked = Event1(); @@ -77,10 +81,12 @@ open class PreviewVideoView : LinearLayout { private set val content: IPlatformContent? get() = currentVideo; + val shouldShowTimeBar: Boolean - constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null) : super(context) { + constructor(context: Context, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null, shouldShowTimeBar: Boolean = true) : super(context) { inflate(feedStyle); _feedStyle = feedStyle; + this.shouldShowTimeBar = shouldShowTimeBar val playerContainer = findViewById(R.id.player_container); val displayMetrics = Resources.getSystem().displayMetrics; @@ -117,6 +123,7 @@ open class PreviewVideoView : LinearLayout { _button_add_to = findViewById(R.id.button_add_to); _imageNeopassChannel = findViewById(R.id.image_neopass_channel); _layoutDownloaded = findViewById(R.id.layout_downloaded); + _timeBar = findViewById(R.id.time_bar) this._exoPlayer = exoPlayer @@ -235,13 +242,23 @@ open class PreviewVideoView : LinearLayout { _containerLive.visibility = GONE; _containerDuration.visibility = VISIBLE; } + + if (shouldShowTimeBar) { + val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url) + _timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE + _timeBar.progress = historyPosition.toFloat() / video.duration.toFloat() + } else { + _timeBar.visibility = GONE + } } else { currentVideo = null; _imageVideo.setImageResource(0); _containerDuration.visibility = GONE; _containerLive.visibility = GONE; + _timeBar.visibility = GONE; } + _textVideoMetadata.text = metadata + timeMeta; } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt index e3793ff4..8f998365 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt @@ -27,8 +27,8 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder { private val view: PreviewVideoView get() = itemView as PreviewVideoView; - constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null): super( - PreviewVideoView(viewGroup.context, feedStyle, exoPlayer) + constructor(viewGroup: ViewGroup, feedStyle : FeedStyle, exoPlayer: PlayerManager? = null, shouldShowTimeBar: Boolean = true): super( + PreviewVideoView(viewGroup.context, feedStyle, exoPlayer, shouldShowTimeBar) ) { view.onVideoClicked.subscribe(onVideoClicked::emit); view.onChannelClicked.subscribe(onChannelClicked::emit); diff --git a/app/src/main/res/layout/list_video_preview.xml b/app/src/main/res/layout/list_video_preview.xml index 0f067668..ac93a071 100644 --- a/app/src/main/res/layout/list_video_preview.xml +++ b/app/src/main/res/layout/list_video_preview.xml @@ -32,6 +32,20 @@ android:scaleType="centerCrop" tools:srcCompat="@drawable/placeholder_video_thumbnail" /> + + diff --git a/app/src/main/res/layout/list_video_thumbnail.xml b/app/src/main/res/layout/list_video_thumbnail.xml index 33115dcb..bc69e9da 100644 --- a/app/src/main/res/layout/list_video_thumbnail.xml +++ b/app/src/main/res/layout/list_video_thumbnail.xml @@ -117,6 +117,20 @@ android:layout_gravity="end" android:layout_marginStart="4dp" android:layout_marginBottom="4dp" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6514a13..dda12fc9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Add to queue General + Channel Home Recommendations More @@ -299,6 +300,8 @@ Clears cookies when you log out Clears in-app browser cookies Configure browsing behavior + Time bar + Configure if historical time bars should be shown Configure casting Configure daily backup in case of catastrophic failure Configure downloading of videos From ef8ea9eecfb022a17ff51362e255e88a49477cb2 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 22 Nov 2023 16:05:27 +0100 Subject: [PATCH 10/23] Fix whitelist checking for dev-portal --- .../media/platforms/js/internal/JSHttpClient.kt | 12 ++++++++++-- .../platformplayer/developer/DeveloperEndpoints.kt | 14 ++++++++++++-- app/src/unstable/assets/sources/kick | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index 134fcb7e..af7291ae 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.matchesDomain class JSHttpClient : ManagedHttpClient { private val _jsClient: JSClient?; + private val _jsConfig: SourcePluginConfig?; private val _auth: SourceAuth?; private val _captcha: SourceCaptchaData?; @@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient { private var _currentCookieMap: HashMap>; - constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() { + constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() { _jsClient = jsClient; + _jsConfig = config; _auth = auth; _captcha = captcha; @@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient { } } - _jsClient?.validateUrlOrThrow(request.url.toString()); + if(_jsClient != null) + _jsClient?.validateUrlOrThrow(request.url.toString()); + else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString())) + throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config"); + return newBuilder?.let { it.build() } ?: request; } diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt index 95502b6d..92a48ebf 100644 --- a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt +++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt @@ -24,6 +24,7 @@ import com.google.gson.JsonArray import com.google.gson.JsonParser import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.lang.reflect.InvocationTargetException import java.util.UUID import kotlin.reflect.jvm.jvmErasure @@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) { val config = context.readContentJson() try { _testPluginVariables.clear(); - _testPlugin = V8Plugin(StateApp.instance.context, config); + + val client = JSHttpClient(null, null, null, config); + val clientAuth = JSHttpClient(null, null, null, config); + _testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth); + context.respondJson(200, testPluginOrThrow.getPackageVariables()); } catch(ex: Throwable) { @@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) { } LoginActivity.showLogin(StateApp.instance.context, config) { _testPluginVariables.clear(); - _testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it)); + _testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config)); }; context.respondCode(200, "Login started"); @@ -311,6 +316,11 @@ class DeveloperEndpoints(private val context: Context) { val json = wrapRemoteResult(callResult, false); context.respondCode(200, json, "application/json"); } + catch(invocation: InvocationTargetException) { + val innerException = invocation.targetException; + Logger.e("DeveloperEndpoints", innerException.message, innerException); + context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain") + } catch(ilEx: IllegalArgumentException) { if(ilEx.message?.contains("does not exist") ?: false) { context.respondCode(400, ilEx.message ?: "", "text/plain"); diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index d0b7a2c1..12b84d2f 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit d0b7a2c1b4939c27b4ec04ee52b5a16380c27afb +Subproject commit 12b84d2ff179f9f4940c4232859b59b57e37fdc6 From 5054b093a40e8687709ca3251d9c67dca1ea78b5 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 22 Nov 2023 16:15:05 +0100 Subject: [PATCH 11/23] Stable refs --- app/src/stable/assets/sources/kick | 2 +- app/src/stable/assets/sources/patreon | 2 +- app/src/unstable/assets/sources/patreon | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index d0b7a2c1..12b84d2f 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit d0b7a2c1b4939c27b4ec04ee52b5a16380c27afb +Subproject commit 12b84d2ff179f9f4940c4232859b59b57e37fdc6 diff --git a/app/src/stable/assets/sources/patreon b/app/src/stable/assets/sources/patreon index 9e26b703..55aef15f 160000 --- a/app/src/stable/assets/sources/patreon +++ b/app/src/stable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit 9e26b7032e64ed03315a8e75d2174cb4253030d1 +Subproject commit 55aef15f4b4ad1359266bb77908b48726d32594b diff --git a/app/src/unstable/assets/sources/patreon b/app/src/unstable/assets/sources/patreon index 339b44e9..55aef15f 160000 --- a/app/src/unstable/assets/sources/patreon +++ b/app/src/unstable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit 339b44e9f00521ab4cfe755a343fd9e6e5338d04 +Subproject commit 55aef15f4b4ad1359266bb77908b48726d32594b From 502602e27aac69602754809a71912e45fd316730 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 22 Nov 2023 16:50:54 +0100 Subject: [PATCH 12/23] Reordering progress bar settings --- .../java/com/futo/platformplayer/Settings.kt | 72 +++++++++---------- .../channel/tab/ChannelContentsFragment.kt | 2 +- .../main/ContentSearchResultsFragment.kt | 2 +- .../mainactivity/main/HomeFragment.kt | 2 +- .../main/SubscriptionsFeedFragment.kt | 2 +- app/src/main/res/values/strings.xml | 2 + 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 9ef64a86..56aff38d 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -158,7 +158,11 @@ class Settings : FragmentedStorageFileJson() { var previewFeedItems: Boolean = true; - @FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 7) + @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) + var progressBar: Boolean = false; + + + @FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8) @FormFieldButton(R.drawable.ic_visibility_off) fun clearHidden() { StateMeta.instance.removeAllHiddenCreators(); @@ -185,6 +189,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5) var previewFeedItems: Boolean = true; + @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) + var progressBar: Boolean = false; fun getSearchFeedStyle(): FeedStyle { @@ -195,7 +201,17 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3) + + @FormField(R.string.channel, "group", -1, 3) + var channel = ChannelSettings(); + @Serializable + class ChannelSettings { + + @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) + var progressBar: Boolean = false; + } + + @FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4) var subscriptions = SubscriptionsSettings(); @Serializable class SubscriptionsSettings { @@ -213,14 +229,17 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5) var previewFeedItems: Boolean = true; - @FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6) + @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) + var progressBar: Boolean = false; + + @FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7) @Serializable(with = FlexibleBooleanSerializer::class) var fetchOnAppBoot: Boolean = true; - @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 6) + @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8) var fetchOnTabOpen: Boolean = true; - @FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7) + @FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9) @DropdownFieldOptionsId(R.array.background_interval) var subscriptionsBackgroundUpdateInterval: Int = 0; @@ -236,7 +255,7 @@ class Settings : FragmentedStorageFileJson() { }; - @FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8) + @FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10) @DropdownFieldOptionsId(R.array.thread_count) var subscriptionConcurrency: Int = 3; @@ -244,17 +263,17 @@ class Settings : FragmentedStorageFileJson() { return threadIndexToCount(subscriptionConcurrency); } - @FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9) + @FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11) var showWatchMetrics: Boolean = false; - @FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10) + @FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12) var allowPlaytimeTracking: Boolean = true; - @FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 11) + @FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13) var alwaysReloadFromCache: Boolean = false; - @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 12) + @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14) fun clearChannelCache() { UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing.."); ChannelContentCache.instance.clear(); @@ -262,7 +281,7 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4) + @FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5) var playback = PlaybackSettings(); @Serializable class PlaybackSettings { @@ -360,7 +379,7 @@ class Settings : FragmentedStorageFileJson() { var backgroundSwitchToAudio: Boolean = true; } - @FormField(R.string.comments, "group", R.string.comments_description, 4) + @FormField(R.string.comments, "group", R.string.comments_description, 6) var comments = CommentSettings(); @Serializable class CommentSettings { @@ -369,7 +388,7 @@ class Settings : FragmentedStorageFileJson() { var defaultCommentSection: Int = 0; } - @FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5) + @FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7) var downloads = Downloads(); @Serializable class Downloads { @@ -409,7 +428,7 @@ class Settings : FragmentedStorageFileJson() { } } - @FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6) + @FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8) var browsing = Browsing(); @Serializable class Browsing { @@ -418,7 +437,7 @@ class Settings : FragmentedStorageFileJson() { var videoCache: Boolean = true; } - @FormField(R.string.casting, "group", R.string.configure_casting, 7) + @FormField(R.string.casting, "group", R.string.configure_casting, 9) var casting = Casting(); @Serializable class Casting { @@ -446,28 +465,7 @@ class Settings : FragmentedStorageFileJson() { }*/ } - @FormField(R.string.time_bar, "group", R.string.configure_if_historical_time_bar_should_be_shown, 8) - var timeBars = TimeBars(); - @Serializable - class TimeBars { - @FormField(R.string.home, FieldForm.TOGGLE, -1, 0) - @Serializable(with = FlexibleBooleanSerializer::class) - var home: Boolean = true; - - @FormField(R.string.subscriptions, FieldForm.TOGGLE, -1, 1) - @Serializable(with = FlexibleBooleanSerializer::class) - var subscriptions: Boolean = true; - - @FormField(R.string.search, FieldForm.TOGGLE, -1, 2) - @Serializable(with = FlexibleBooleanSerializer::class) - var search: Boolean = true; - - @FormField(R.string.channel, FieldForm.TOGGLE, -1, 3) - @Serializable(with = FlexibleBooleanSerializer::class) - var channel: Boolean = true; - } - - @FormField(R.string.logging, FieldForm.GROUP, -1, 9) + @FormField(R.string.logging, FieldForm.GROUP, -1, 10) var logging = Logging(); @Serializable class Logging { 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 9ac89b82..d5b4c0b4 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 @@ -152,7 +152,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { _recyclerResults = view.findViewById(R.id.recycler_videos); - _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.timeBars.channel).apply { + _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); 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 14eed33d..d5a2c987 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 @@ -84,7 +84,7 @@ class ContentSearchResultsFragment : MainFragment() { private var _channelUrl: String? = null; private val _taskSearch: TaskHandler>; - override val shouldShowTimeBar: Boolean get() = Settings.instance.timeBars.search + override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index ee1944e9..dd5f33c3 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -95,7 +95,7 @@ class HomeFragment : MainFragment() { private var _announcementsView: AnnouncementView; private val _taskGetPager: TaskHandler>; - override val shouldShowTimeBar: Boolean get() = Settings.instance.timeBars.home + override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { _announcementsView = AnnouncementView(context, null).apply { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index 7317cb95..21b75c83 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -93,7 +93,7 @@ class SubscriptionsFeedFragment : MainFragment() { @SuppressLint("ViewConstructor") class SubscriptionsFeedView : ContentFeedView { - override val shouldShowTimeBar: Boolean get() = Settings.instance.timeBars.subscriptions + override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, LinearLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { Logger.i(TAG, "SubscriptionsFeedFragment constructor()"); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dda12fc9..9198b49a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,8 @@ General Channel Home + Progress Bar + If a historical progress bar should be shown Recommendations More Playlists From f3fa2086806b1839cf2a7a97512f7074ef14aeec Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 22 Nov 2023 18:04:29 +0100 Subject: [PATCH 13/23] Kick subs fix, dedup fix --- .../platformplayer/api/media/structures/DedupContentPager.kt | 2 +- app/src/stable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/kick | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt index 9839141d..04f0786a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/DedupContentPager.kt @@ -52,7 +52,7 @@ class DedupContentPager : IPager, IAsyncPager items.first() } val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) } - val bestItem = platformItemMap[bestPlatform] ?: sameItems.first() + val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull(); resultsToRemove.addAll(sameItems.filter { it != bestItem }); } diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index 12b84d2f..396dd169 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 12b84d2ff179f9f4940c4232859b59b57e37fdc6 +Subproject commit 396dd16987fba87e6f455a900426a5d7f22cbde3 diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index 12b84d2f..396dd169 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 12b84d2ff179f9f4940c4232859b59b57e37fdc6 +Subproject commit 396dd16987fba87e6f455a900426a5d7f22cbde3 From 5cafbf243e92b87009623d1cf9593e08b8dcaad6 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 22 Nov 2023 22:32:44 +0100 Subject: [PATCH 14/23] Fixed channel contents long press and fixed a crash due to time bars. --- .../channel/tab/ChannelContentsFragment.kt | 2 ++ .../mainactivity/main/ChannelFragment.kt | 6 ++++++ .../views/adapters/ChannelViewPagerAdapter.kt | 2 ++ .../adapters/feedtypes/PreviewVideoView.kt | 19 +++++++++++-------- 4 files changed, 21 insertions(+), 8 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 9ac89b82..54a09c43 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 @@ -59,6 +59,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { val onChannelClicked = Event1(); val onAddToClicked = Event1(); val onAddToQueueClicked = Event1(); + val onLongPress = Event1(); private fun getContentPager(channel: IPlatformChannel): IPager { Logger.i(TAG, "getContentPager"); @@ -159,6 +160,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); + this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit); } _llmVideo = LinearLayoutManager(view.context); 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 f598dbc2..743648ef 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 @@ -223,6 +223,12 @@ class ChannelFragment : MainFragment() { else -> {}; } } + adapter.onLongPress.subscribe { content -> + _overlayContainer.let { + if(content is IPlatformVideo) + _slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it); + } + } viewPager.adapter = adapter; val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position -> diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index fcb3d123..4217ff35 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -20,6 +20,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec val onChannelClicked = Event1(); val onAddToClicked = Event1(); val onAddToQueueClicked = Event1(); + val onLongPress = Event1(); override fun getItemCount(): Int { return _cache.size; @@ -55,6 +56,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit); onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit); onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit); + onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit); }; 1 -> ChannelListFragment.newInstance().apply { onClickChannel.subscribe(onChannelClicked::emit) }; //2 -> ChannelStoreFragment.newInstance(); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt index 3a690918..8bc3770e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt @@ -69,7 +69,7 @@ open class PreviewVideoView : LinearLayout { Logger.w(TAG, "Failed to load profile.", it); }; - private val _timeBar: ProgressBar; + private val _timeBar: ProgressBar?; val onVideoClicked = Event2(); val onLongPress = Event1(); @@ -243,12 +243,15 @@ open class PreviewVideoView : LinearLayout { _containerDuration.visibility = VISIBLE; } - if (shouldShowTimeBar) { - val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url) - _timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE - _timeBar.progress = historyPosition.toFloat() / video.duration.toFloat() - } else { - _timeBar.visibility = GONE + val timeBar = _timeBar + if (timeBar != null) { + if (shouldShowTimeBar) { + val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url) + timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE + timeBar.progress = historyPosition.toFloat() / video.duration.toFloat() + } else { + timeBar.visibility = GONE + } } } else { @@ -256,7 +259,7 @@ open class PreviewVideoView : LinearLayout { _imageVideo.setImageResource(0); _containerDuration.visibility = GONE; _containerLive.visibility = GONE; - _timeBar.visibility = GONE; + _timeBar?.visibility = GONE; } _textVideoMetadata.text = metadata + timeMeta; From 9d5888ddf7430cc6415a559899b5554dd5dcf560 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 23 Nov 2023 11:48:50 +0100 Subject: [PATCH 15/23] Fixed VODs not working properly for YouTube and Twitch. --- .../futo/platformplayer/Extensions_Syntax.kt | 21 ------- .../api/http/server/HttpContext.kt | 9 ++- .../handlers/HttpOptionsAllowHandler.kt | 15 ++--- .../http/server/handlers/HttpProxyHandler.kt | 47 ++++++++++------ .../platformplayer/casting/StateCasting.kt | 56 +++++++++++++------ .../com/futo/platformplayer/parsers/HLS.kt | 16 ++++-- .../parsers/HttpResponseParser.kt | 2 + app/src/stable/assets/sources/twitch | 2 +- app/src/unstable/assets/sources/twitch | 2 +- 9 files changed, 95 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 6ce03b70..0fc8dcd8 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -21,25 +21,4 @@ inline fun Any.assume(cb: (T) -> R): R? { fun String?.yesNoToBoolean(): Boolean { return this?.uppercase() == "YES" -} - -fun String?.toURIRobust(): URI? { - if (this == null) { - return null - } - - try { - return URI(this) - } catch (e: URISyntaxException) { - val parts = this.split("\\?".toRegex(), 2) - if (parts.size < 2) { - return null - } - - val beforeQuery = parts[0] - val query = parts[1] - val encodedQuery = URLEncoder.encode(query, "UTF-8") - val rebuiltUrl = "$beforeQuery?$encodedQuery" - return URI(rebuiltUrl) - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt index f08610f5..ecb835c4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/HttpContext.kt @@ -197,8 +197,13 @@ class HttpContext : AutoCloseable { } fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) { val bytes = body?.toByteArray(Charsets.UTF_8); - if(body != null && headers.get("content-length").isNullOrEmpty()) - headers.put("content-length", bytes!!.size.toString()); + if(headers.get("content-length").isNullOrEmpty()) { + if (body != null) { + headers.put("content-length", bytes!!.size.toString()); + } else { + headers.put("content-length", "0") + } + } respond(status, headers) { responseStream -> if(body != null) { responseStream.write(bytes!!); diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt index af226aa6..3561e31e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt @@ -4,17 +4,10 @@ import com.futo.platformplayer.api.http.server.HttpContext class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) { override fun handle(httpContext: HttpContext) { - //Just allow whatever is requested - - val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", ""); - val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", ""); - val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", ""); - - val newHeaders = headers.clone(); - newHeaders.put("Allow", requestedMethods); - newHeaders.put("Access-Control-Allow-Methods", requestedMethods); - newHeaders.put("Access-Control-Allow-Headers", "*"); - + val newHeaders = headers.clone() + newHeaders.put("Access-Control-Allow-Origin", "*") + newHeaders.put("Access-Control-Allow-Methods", "*") + newHeaders.put("Access-Control-Allow-Headers", "*") httpContext.respondCode(200, newHeaders); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt index afc2589e..74dcbb52 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpProxyHandler.kt @@ -98,11 +98,15 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv proxyHeaders.put("Referer", targetUrl); val useMethod = if (method == "inherit") context.method else method; - Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${targetUrl}"); + Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}"); Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); + makeTcpRequest(proxyHeaders, useMethod, parsed, context) + } + + private fun makeTcpRequest(proxyHeaders: HashMap, useMethod: String, parsed: Uri, context: HttpContext) { val requestBuilder = StringBuilder() - requestBuilder.append("$useMethod $targetUrl HTTP/1.1\r\n") + requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n") proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") } requestBuilder.append("\r\n") @@ -128,23 +132,31 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv val inputStream = s.getInputStream() val resp = HttpResponseParser(inputStream) - val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true) - val contentLength = resp.contentLength.toInt() + if (resp.statusCode == 302) { + val location = resp.location!! + Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location"); + makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context) + } else { + val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true) + val contentLength = resp.contentLength.toInt() - val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) }); - for(newHeader in headers) - headersFiltered.put(newHeader.key, newHeader.value); + val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) }); + for (newHeader in headers) + headersFiltered.put(newHeader.key, newHeader.value); - context.respond(resp.statusCode, headersFiltered) { responseStream -> - if (isChunked) { - Logger.i(TAG, "handleWithTcp handleChunkedTransfer"); - handleChunkedTransfer(inputStream, responseStream) - } else if (contentLength != -1) { - Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength"); - transferFixedLengthContent(inputStream, responseStream, contentLength) - } else { - Logger.i(TAG, "handleWithTcp transferUntilEndOfStream"); - transferUntilEndOfStream(inputStream, responseStream) + context.respond(resp.statusCode, headersFiltered) { responseStream -> + if (isChunked) { + Logger.i(TAG, "handleWithTcp handleChunkedTransfer"); + handleChunkedTransfer(inputStream, responseStream) + } else if (contentLength > 0) { + Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength"); + transferFixedLengthContent(inputStream, responseStream, contentLength) + } else if (contentLength == -1) { + Logger.i(TAG, "handleWithTcp transferUntilEndOfStream"); + transferUntilEndOfStream(inputStream, responseStream) + } else { + Logger.i(TAG, "handleWithTcp no content"); + } } } } @@ -156,7 +168,6 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv while (inputStream.readLine().also { line = it } != null) { val size = line!!.trim().toInt(16) - Logger.i(TAG, "handleWithTcp handleChunkedTransfer chunk size $size") responseStream.write(line!!.encodeToByteArray()) responseStream.write("\r\n".encodeToByteArray()) 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 ad136bdb..9039e3ca 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -356,27 +356,35 @@ class StateCasting { } } } else { - if (videoSource is IVideoUrlSource) + if (videoSource is IVideoUrlSource) { + Logger.i(TAG, "Casting as singular video"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); - else if (audioSource is IAudioUrlSource) + } else if (audioSource is IAudioUrlSource) { + Logger.i(TAG, "Casting as singular audio"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); - else if(videoSource is IHLSManifestSource) { - if (ad is ChromecastCastingDevice && video.isLive) { + } else if(videoSource is IHLSManifestSource) { + if (ad is ChromecastCastingDevice) { + Logger.i(TAG, "Casting as proxied HLS"); castHlsIndirect(video, videoSource.url, resumePosition); } else { + Logger.i(TAG, "Casting as non-proxied HLS"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); } } else if(audioSource is IHLSManifestAudioSource) { - if (ad is ChromecastCastingDevice && video.isLive) { + if (ad is ChromecastCastingDevice) { + Logger.i(TAG, "Casting as proxied audio HLS"); castHlsIndirect(video, audioSource.url, resumePosition); } else { + Logger.i(TAG, "Casting as non-proxied audio HLS"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); } - } else if (videoSource is LocalVideoSource) + } else if (videoSource is LocalVideoSource) { + Logger.i(TAG, "Casting as local video"); castLocalVideo(video, videoSource, resumePosition); - else if (audioSource is LocalAudioSource) + } else if (audioSource is LocalAudioSource) { + Logger.i(TAG, "Casting as local audio"); castLocalAudio(video, audioSource, resumePosition); - else { + } else { var str = listOf( if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, @@ -413,6 +421,14 @@ class StateCasting { return true; } + private fun castVideoIndirect() { + + } + + private fun castAudioIndirect() { + + } + private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); @@ -634,7 +650,7 @@ class StateCasting { }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); - ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); return listOf(hlsUrl); } @@ -684,8 +700,6 @@ class StateCasting { val proxyStreams = ad !is FastCastCastingDevice; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; - Logger.i(TAG, "DASH url: $url"); - val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -694,6 +708,8 @@ class StateCasting { val subtitlePath = "/subtitle-${id}" val dashUrl = url + dashPath; + Logger.i(TAG, "DASH url: $dashUrl"); + val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); @@ -719,6 +735,10 @@ class StateCasting { HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(subtitlePath) + .withHeader("Access-Control-Allow-Origin", "*") + ).withTag("cast"); } subtitlesUrl = url + subtitlePath; @@ -732,28 +752,32 @@ class StateCasting { "application/dash+xml") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(dashPath) + .withHeader("Access-Control-Allow-Origin", "*") + ).withTag("cast"); + if (videoSource != null) { _castServer.addHandler( - HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl()) + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); _castServer.addHandler( HttpOptionsAllowHandler(videoPath) .withHeader("Access-Control-Allow-Origin", "*") - .withHeader("Connection", "keep-alive")) - .withTag("cast"); + ).withTag("cast"); } if (audioSource != null) { _castServer.addHandler( - HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl()) + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); _castServer.addHandler( HttpOptionsAllowHandler(audioPath) .withHeader("Access-Control-Allow-Origin", "*") - .withHeader("Connection", "keep-alivcontexte")) + ) .withTag("cast"); } 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 6f37e815..1deecc20 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,7 +1,6 @@ package com.futo.platformplayer.parsers import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.toURIRobust import com.futo.platformplayer.yesNoToBoolean import java.net.URI import java.time.ZonedDateTime @@ -15,7 +14,7 @@ class HLS { val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") - val baseUrl = sourceUrl.toURIRobust()!!.resolve("./").toString() + val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf() val mediaRenditions = mutableListOf() @@ -81,7 +80,7 @@ class HLS { } else -> { currentSegment?.let { - it.uri = line + it.uri = resolveUrl(sourceUrl, line) segments.add(it) } currentSegment = null @@ -93,9 +92,16 @@ class HLS { } private fun resolveUrl(baseUrl: String, url: String): String { - return if (url.toURIRobust()!!.isAbsolute) url else baseUrl + url - } + val baseUri = URI(baseUrl) + val urlUri = URI(url) + return if (urlUri.isAbsolute) { + url + } else { + val resolvedUri = baseUri.resolve(urlUri) + resolvedUri.toString() + } + } private fun parseStreamInfo(content: String): StreamInfo { val attributes = parseAttributes(content) diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt b/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt index 2209ba24..bc3be71c 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HttpResponseParser.kt @@ -14,6 +14,7 @@ class HttpResponseParser : AutoCloseable { var contentType: String? = null; var transferEncoding: String? = null; + var location: String? = null; var contentLength: Long = -1L; var statusCode: Int = -1; @@ -47,6 +48,7 @@ class HttpResponseParser : AutoCloseable { "content-length" -> contentLength = headerValue.toLong(); "content-type" -> contentType = headerValue; "transfer-encoding" -> transferEncoding = headerValue; + "location" -> location = headerValue; } if(line.isNullOrEmpty()) break; diff --git a/app/src/stable/assets/sources/twitch b/app/src/stable/assets/sources/twitch index 6732a56c..8d978dd7 160000 --- a/app/src/stable/assets/sources/twitch +++ b/app/src/stable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit 6732a56cd60522f995478399173dd020d8ffc828 +Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57 diff --git a/app/src/unstable/assets/sources/twitch b/app/src/unstable/assets/sources/twitch index 6732a56c..8d978dd7 160000 --- a/app/src/unstable/assets/sources/twitch +++ b/app/src/unstable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit 6732a56cd60522f995478399173dd020d8ffc828 +Subproject commit 8d978dd7bd749f837f13322742329c8f769a1a57 From e4c89e9aa90d9af3cc34868b122d00ed02b28027 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 23 Nov 2023 12:48:16 +0100 Subject: [PATCH 16/23] Extended HLS spec, fixes to YES NO booleans, started on implementing HLS stream combiner. --- .../futo/platformplayer/Extensions_Syntax.kt | 4 + .../platformplayer/casting/StateCasting.kt | 140 +++++++++++++++++- .../com/futo/platformplayer/parsers/HLS.kt | 10 +- 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 0fc8dcd8..cf2e3900 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -21,4 +21,8 @@ inline fun Any.assume(cb: (T) -> R): R? { fun String?.yesNoToBoolean(): Boolean { return this?.uppercase() == "YES" +} + +fun Boolean?.toYesNo(): String { + return if (this == true) "YES" else "NO" } \ No newline at end of file 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 9039e3ca..1428be63 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -365,7 +365,7 @@ class StateCasting { } else if(videoSource is IHLSManifestSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied HLS"); - castHlsIndirect(video, videoSource.url, resumePosition); + castProxiedHls(video, videoSource.url, resumePosition); } else { Logger.i(TAG, "Casting as non-proxied HLS"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); @@ -373,7 +373,7 @@ class StateCasting { } else if(audioSource is IHLSManifestAudioSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied audio HLS"); - castHlsIndirect(video, audioSource.url, resumePosition); + castProxiedHls(video, audioSource.url, resumePosition); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); @@ -574,7 +574,7 @@ class StateCasting { return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); } - private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List { + private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List { _castServer.removeAllHandlers("castHlsIndirectMaster") val ad = activeDevice ?: return listOf(); @@ -695,6 +695,138 @@ class StateCasting { ) } + private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { + val ad = activeDevice ?: return listOf(); + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; + val id = UUID.randomUUID(); + + val hlsPath = "/hls-${id}" + + val hlsUrl = url + hlsPath; + Logger.i(TAG, "HLS url: $hlsUrl"); + + val mediaRenditions = arrayListOf() + val variantPlaylistReferences = arrayListOf() + + if (audioSource != null) { + val audioPath = "/audio-${id}" + val audioUrl = url + audioPath + + val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val audioVariantPlaylistPath = "/audio-playlist-${id}" + val audioVariantPlaylistUrl = url + audioVariantPlaylistPath + val audioVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, audioVariantPlaylistSegments) + + _castServer.addHandler( + HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + + mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true)) + + _castServer.addHandler( + HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { + return@withContext subtitleSource.getSubtitlesURI(); + } else null; + + var subtitlesUrl: String? = null; + if (subtitlesUri != null) { + val subtitlePath = "/subtitles-${id}" + if(subtitlesUri.scheme == "file") { + var content: String? = null; + val inputStream = contentResolver.openInputStream(subtitlesUri); + inputStream?.use { stream -> + val reader = stream.bufferedReader(); + content = reader.use { it.readText() }; + } + + if (content != null) { + _castServer.addHandler( + HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + _castServer.addHandler( + HttpOptionsAllowHandler(subtitlePath) + .withHeader("Access-Control-Allow-Origin", "*") + ).withTag("cast"); + } + + subtitlesUrl = url + subtitlePath; + } else { + subtitlesUrl = subtitlesUri.toString(); + } + } + + if (subtitlesUrl != null) { + val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") + val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" + val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath + val subtitleVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), subtitlesUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, subtitleVariantPlaylistSegments) + + _castServer.addHandler( + HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + + mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true)) + } + + if (videoSource != null) { + val videoPath = "/video-${id}" + val videoUrl = url + videoPath + + val duration = videoSource.duration + val videoVariantPlaylistPath = "/video-playlist-${id}" + val videoVariantPlaylistUrl = url + videoVariantPlaylistPath + val videoVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, videoVariantPlaylistSegments) + + _castServer.addHandler( + HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectVariant"); + + variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate ?: 0, + "${videoSource.width}x${videoSource.height}", + videoSource.codec, + null, + null, + if (audioSource != null) "audio" else null, + if (subtitleSource != null) "subtitles" else null, + null))) + + _castServer.addHandler( + HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + _castServer.addHandler( + HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castHlsIndirectMaster") + + Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + + return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); + } + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); val proxyStreams = ad !is FastCastCastingDevice; @@ -782,7 +914,7 @@ class StateCasting { } Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } 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 1deecc20..c201dbd2 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.parsers import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean import java.net.URI import java.time.ZonedDateTime @@ -112,6 +113,7 @@ class HLS { frameRate = attributes["FRAME-RATE"], videoRange = attributes["VIDEO-RANGE"], audio = attributes["AUDIO"], + subtitles = attributes["SUBTITLES"], closedCaptions = attributes["CLOSED-CAPTIONS"] ) } @@ -198,6 +200,7 @@ class HLS { val frameRate: String?, val videoRange: String?, val audio: String?, + val subtitles: String?, val closedCaptions: String? ) @@ -219,9 +222,9 @@ class HLS { "GROUP-ID" to groupID, "LANGUAGE" to language, "NAME" to name, - "DEFAULT" to isDefault?.toString()?.uppercase(), - "AUTOSELECT" to isAutoSelect?.toString()?.uppercase(), - "FORCED" to isForced?.toString()?.uppercase() + "DEFAULT" to isDefault.toYesNo(), + "AUTOSELECT" to isAutoSelect.toYesNo(), + "FORCED" to isForced.toYesNo() ) append("\n") } @@ -267,6 +270,7 @@ class HLS { "FRAME-RATE" to streamInfo.frameRate, "VIDEO-RANGE" to streamInfo.videoRange, "AUDIO" to streamInfo.audio, + "SUBTITLES" to streamInfo.subtitles, "CLOSED-CAPTIONS" to streamInfo.closedCaptions ) append("\n$url\n") From ee3761c780eebc173ead58c880376ba6935efe42 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 23 Nov 2023 13:18:09 +0100 Subject: [PATCH 17/23] Added full support for HLS casting to Airplay. --- .../platformplayer/casting/StateCasting.kt | 146 +++++++++++++++--- 1 file changed, 122 insertions(+), 24 deletions(-) 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 1428be63..2afd3734 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -334,20 +334,23 @@ class StateCasting { } if (sourceCount > 1) { - if (ad is AirPlayCastingDevice) { - StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); }; - ad.stopCasting(); - return false; - } - if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); + if (ad is AirPlayCastingDevice) { + castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); + } else { + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); + } } else { StateApp.instance.scope.launch(Dispatchers.IO) { try { if (ad is FastCastCastingDevice) { + Logger.i(TAG, "Casting as dash direct"); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); + } else if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as HLS indirect"); + castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } else { + Logger.i(TAG, "Casting as dash indirect"); castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } } catch (e: Throwable) { @@ -467,6 +470,101 @@ class StateCasting { return listOf(audioUrl); } + private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List { + val ad = activeDevice ?: return listOf() + + val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}" + val id = UUID.randomUUID() + + val hlsPath = "/hls-${id}" + val videoPath = "/video-${id}" + val audioPath = "/audio-${id}" + val subtitlePath = "/subtitle-${id}" + + val hlsUrl = url + hlsPath + val videoUrl = url + videoPath + val audioUrl = url + audioPath + val subtitleUrl = url + subtitlePath + + val mediaRenditions = arrayListOf() + val variantPlaylistReferences = arrayListOf() + + if (videoSource != null) { + _castServer.addHandler( + HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + val duration = videoSource.duration + val videoVariantPlaylistPath = "/video-playlist-${id}" + val videoVariantPlaylistUrl = url + videoVariantPlaylistPath + val videoVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, videoVariantPlaylistSegments) + + _castServer.addHandler( + HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( + videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null))) + } + + if (audioSource != null) { + _castServer.addHandler( + HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") + val audioVariantPlaylistPath = "/audio-playlist-${id}" + val audioVariantPlaylistUrl = url + audioVariantPlaylistPath + val audioVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, audioVariantPlaylistSegments) + + _castServer.addHandler( + HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true)) + } + + if (subtitleSource != null) { + _castServer.addHandler( + HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") + val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" + val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath + val subtitleVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), subtitleUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, subtitleVariantPlaylistSegments) + + _castServer.addHandler( + HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true)) + } + + val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) + _castServer.addHandler( + HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), + "application/vnd.apple.mpegurl") + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castLocalHls") + + Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()) + + return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) + } private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List { val ad = activeDevice ?: return listOf(); @@ -575,7 +673,7 @@ class StateCasting { } private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List { - _castServer.removeAllHandlers("castHlsIndirectMaster") + _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; @@ -586,7 +684,7 @@ class StateCasting { Logger.i(TAG, "HLS url: $hlsUrl"); _castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext -> - _castServer.removeAllHandlers("castHlsIndirectVariant") + _castServer.removeAllHandlers("castProxiedHlsVariant") val headers = masterContext.headers.clone() headers["Content-Type"] = "application/vnd.apple.mpegurl"; @@ -609,7 +707,7 @@ class StateCasting { val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") newVariantPlaylistRefs.add(HLS.VariantPlaylistReference( newPlaylistUrl, @@ -631,7 +729,7 @@ class StateCasting { val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant") + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") } newMediaRenditions.add(HLS.MediaRendition( @@ -647,7 +745,7 @@ class StateCasting { } masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8()); - }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectMaster") + }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); @@ -686,7 +784,7 @@ class StateCasting { HttpProxyHandler("GET", newSegmentPath, segment.uri, true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castHlsIndirectVariant") + ).withTag("castProxiedHlsVariant") } return HLS.Segment( @@ -723,6 +821,7 @@ class StateCasting { "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); + _castServer.addHandler(HttpOptionsAllowHandler(audioVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true)) @@ -730,7 +829,8 @@ class StateCasting { HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("castHlsIndirectVariant"); + _castServer.addHandler(HttpOptionsAllowHandler(audioPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); } val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { @@ -752,11 +852,8 @@ class StateCasting { _castServer.addHandler( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(subtitlePath) - .withHeader("Access-Control-Allow-Origin", "*") - ).withTag("cast"); + ).withTag("castHlsIndirectVariant"); + _castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); } subtitlesUrl = url + subtitlePath; @@ -777,6 +874,7 @@ class StateCasting { "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); + _castServer.addHandler(HttpOptionsAllowHandler(subtitleVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true)) } @@ -796,6 +894,7 @@ class StateCasting { "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); + _castServer.addHandler(HttpOptionsAllowHandler(videoVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( videoSource.bitrate ?: 0, @@ -811,7 +910,8 @@ class StateCasting { HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("cast"); + ).withTag("castHlsIndirectVariant"); + _castServer.addHandler(HttpOptionsAllowHandler(videoPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); } val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) @@ -820,6 +920,7 @@ class StateCasting { "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectMaster") + _castServer.addHandler(HttpOptionsAllowHandler(hlsPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); @@ -867,10 +968,7 @@ class StateCasting { HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(subtitlePath) - .withHeader("Access-Control-Allow-Origin", "*") - ).withTag("cast"); + _castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("cast"); } subtitlesUrl = url + subtitlePath; From 2530c6eb5895055743783afcca11ade5aca98573 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 23 Nov 2023 16:35:13 +0100 Subject: [PATCH 18/23] Live chat improvements and fixes --- .../futo/platformplayer/views/overlays/LiveChatOverlay.kt | 8 ++++++++ app/src/main/res/drawable/background_donation.xml | 7 +++++++ app/src/main/res/layout/list_donation.xml | 6 +++--- app/src/main/res/layout/overlay_livechat.xml | 5 ++--- 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/drawable/background_donation.xml diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt index ae7561bc..385be76d 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt @@ -308,13 +308,21 @@ class LiveChatOverlay : LinearLayout { } }; } + private var _dedupHackfix = ""; fun addDonation(donation: LiveEventDonation) { + val uniqueIdentifier = "${donation.name}${donation.amount}${donation.message}"; if(donation.hasExpired()) { Logger.i(TAG, "Donation that is already expired: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}"); return; } + else if(_dedupHackfix == uniqueIdentifier) { + Logger.i(TAG, "Donation duplicate found, ignoring"); + return; + } else Logger.i(TAG, "Donation Added: [${donation.amount}]" + donation.name + ":" + donation.message + " EXPIRE: ${donation.expire}"); + _dedupHackfix = uniqueIdentifier; + val view = LiveChatDonationPill(context, donation); view.setOnClickListener { showDonation(donation); diff --git a/app/src/main/res/drawable/background_donation.xml b/app/src/main/res/drawable/background_donation.xml new file mode 100644 index 00000000..7112c768 --- /dev/null +++ b/app/src/main/res/drawable/background_donation.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_donation.xml b/app/src/main/res/layout/list_donation.xml index 0eddfc30..921cd87a 100644 --- a/app/src/main/res/layout/list_donation.xml +++ b/app/src/main/res/layout/list_donation.xml @@ -9,7 +9,7 @@ android:paddingStart="7dp" android:paddingEnd="12dp" android:layout_marginEnd="5dp" - android:background="@drawable/background_pill" + android:background="@drawable/background_donation" android:orientation="vertical" android:id="@+id/root"> @@ -24,7 +24,7 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:layout_marginRight="5dp" - android:layout_marginLeft="5dp" + android:layout_marginLeft="0dp" android:scaleType="fitCenter" app:srcCompat="@drawable/placeholder_profile" /> @@ -32,7 +32,7 @@ android:id="@+id/donation_amount" android:layout_width="wrap_content" android:layout_height="match_parent" - android:layout_marginLeft="5dp" + android:layout_marginLeft="3dp" app:layout_constraintLeft_toRightOf="@id/donation_author_image" app:layout_constraintTop_toTopOf="parent" android:textColor="@color/white" diff --git a/app/src/main/res/layout/overlay_livechat.xml b/app/src/main/res/layout/overlay_livechat.xml index 8f4ff524..9339c9a5 100644 --- a/app/src/main/res/layout/overlay_livechat.xml +++ b/app/src/main/res/layout/overlay_livechat.xml @@ -46,10 +46,9 @@ app:layout_constraintLeft_toRightOf="@id/ic_viewers" tools:text="1536 viewers"/> - @@ -61,7 +60,7 @@ android:layout_height="match_parent"> - + Date: Thu, 23 Nov 2023 16:44:58 +0100 Subject: [PATCH 19/23] Fixes to adhere closer to the HLS spec and Twitch VODs no longer start at end. --- .../casting/ChomecastCastingDevice.kt | 2 +- .../platformplayer/casting/StateCasting.kt | 84 +++++++++++-------- .../com/futo/platformplayer/parsers/HLS.kt | 53 +++++++++--- 3 files changed, 92 insertions(+), 47 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 69b447e1..a67558c4 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice { return; } - Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); + Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); time = resumePosition; _streamType = streamType; 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 2afd3734..d3150157 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -336,21 +336,23 @@ class StateCasting { if (sourceCount > 1) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as local HLS"); castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); } else { + Logger.i(TAG, "Casting as local DASH"); castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); } } else { StateApp.instance.scope.launch(Dispatchers.IO) { try { if (ad is FastCastCastingDevice) { - Logger.i(TAG, "Casting as dash direct"); + Logger.i(TAG, "Casting as DASH direct"); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } else if (ad is AirPlayCastingDevice) { Logger.i(TAG, "Casting as HLS indirect"); castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } else { - Logger.i(TAG, "Casting as dash indirect"); + Logger.i(TAG, "Casting as DASH indirect"); castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); } } catch (e: Throwable) { @@ -498,8 +500,8 @@ class StateCasting { val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, videoVariantPlaylistSegments) + val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), @@ -508,7 +510,7 @@ class StateCasting { ).withTag("castLocalHls") variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( - videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null))) + videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null))) } if (audioSource != null) { @@ -520,8 +522,8 @@ class StateCasting { val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, audioVariantPlaylistSegments) + val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), @@ -541,8 +543,8 @@ class StateCasting { val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), subtitleUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), @@ -704,7 +706,7 @@ class StateCasting { vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") @@ -716,17 +718,19 @@ class StateCasting { } for (mediaRendition in masterPlaylist.mediaRenditions) { - val playlistId = UUID.randomUUID(); - val newPlaylistPath = "/hls-playlist-${playlistId}" - val newPlaylistUrl = url + newPlaylistPath; + val playlistId = UUID.randomUUID() + var newPlaylistUrl: String? = null if (mediaRendition.uri != null) { + val newPlaylistPath = "/hls-playlist-${playlistId}" + newPlaylistUrl = url + newPlaylistPath + _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> val vpHeaders = vpContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) - val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant") @@ -748,12 +752,15 @@ class StateCasting { }.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster") Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); + + //ChromeCast is sometimes funky with resume position 0 + val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition; + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble()); return listOf(hlsUrl); } - private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, proxySegments: Boolean = true): HLS.VariantPlaylist { + private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist { val newSegments = arrayListOf() if (proxySegments) { @@ -771,26 +778,31 @@ class StateCasting { variantPlaylist.mediaSequence, variantPlaylist.discontinuitySequence, variantPlaylist.programDateTime, + variantPlaylist.playlistType, newSegments ) } private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment { - val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" - val newSegmentUrl = url + newSegmentPath; + if (segment is HLS.MediaSegment) { + val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}" + val newSegmentUrl = url + newSegmentPath; - if (_castServer.getHandler("GET", newSegmentPath) == null) { - _castServer.addHandler( - HttpProxyHandler("GET", newSegmentPath, segment.uri, true) - .withInjectedHost() - .withHeader("Access-Control-Allow-Origin", "*"), true - ).withTag("castProxiedHlsVariant") + if (_castServer.getHandler("GET", newSegmentPath) == null) { + _castServer.addHandler( + HttpProxyHandler("GET", newSegmentPath, segment.uri, true) + .withInjectedHost() + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("castProxiedHlsVariant") + } + + return HLS.MediaSegment( + segment.duration, + newSegmentUrl + ) + } else { + return segment } - - return HLS.Segment( - segment.duration, - newSegmentUrl - ) } private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { @@ -813,8 +825,8 @@ class StateCasting { val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown") val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath - val audioVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, audioVariantPlaylistSegments) + val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), @@ -866,8 +878,8 @@ class StateCasting { val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown") val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath - val subtitleVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), subtitlesUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), @@ -886,8 +898,8 @@ class StateCasting { val duration = videoSource.duration val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath - val videoVariantPlaylistSegments = listOf(HLS.Segment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, videoVariantPlaylistSegments) + val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments) _castServer.addHandler( HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), @@ -904,7 +916,7 @@ class StateCasting { null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, - null))) + null, null))) _castServer.addHandler( HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) 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 c201dbd2..d8b1fbaf 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -66,18 +66,22 @@ class HLS { val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) } + val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val segments = mutableListOf() - var currentSegment: Segment? = null + var currentSegment: MediaSegment? = null lines.forEach { line -> when { line.startsWith("#EXTINF:") -> { val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() ?: throw Exception("Invalid segment duration format") - currentSegment = Segment(duration = duration) + currentSegment = MediaSegment(duration = duration) } - line.startsWith("#") -> { - // Handle other tags if necessary + line == "#EXT-X-DISCONTINUITY" -> { + segments.add(DiscontinuitySegment()) + } + line =="#EXT-X-ENDLIST" -> { + segments.add(EndListSegment()) } else -> { currentSegment?.let { @@ -89,7 +93,7 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, segments) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, segments) } private fun resolveUrl(baseUrl: String, url: String): String { @@ -113,6 +117,7 @@ class HLS { frameRate = attributes["FRAME-RATE"], videoRange = attributes["VIDEO-RANGE"], audio = attributes["AUDIO"], + video = attributes["VIDEO"], subtitles = attributes["SUBTITLES"], closedCaptions = attributes["CLOSED-CAPTIONS"] ) @@ -159,7 +164,7 @@ class HLS { return attributes } - private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO") + private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO") private fun shouldQuote(key: String, value: String?): Boolean { if (value == null) return false; @@ -200,6 +205,7 @@ class HLS { val frameRate: String?, val videoRange: String?, val audio: String?, + val video: String?, val subtitles: String?, val closedCaptions: String? ) @@ -270,6 +276,7 @@ class HLS { "FRAME-RATE" to streamInfo.frameRate, "VIDEO-RANGE" to streamInfo.videoRange, "AUDIO" to streamInfo.audio, + "VIDEO" to streamInfo.video, "SUBTITLES" to streamInfo.subtitles, "CLOSED-CAPTIONS" to streamInfo.closedCaptions ) @@ -283,6 +290,7 @@ class HLS { val mediaSequence: Long, val discontinuitySequence: Int, val programDateTime: ZonedDateTime?, + val playlistType: String?, val segments: List ) { fun buildM3U8(): String = buildString { @@ -291,19 +299,44 @@ class HLS { append("#EXT-X-TARGETDURATION:$targetDuration\n") append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n") append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n") + + playlistType?.let { + append("#EXT-X-PLAYLIST-TYPE:$it\n") + } + programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") } segments.forEach { segment -> - append("#EXTINF:${segment.duration},\n") - append(segment.uri + "\n") + append(segment.toM3U8Line()) } } } - data class Segment( + abstract class Segment { + abstract fun toM3U8Line(): String + } + + data class MediaSegment ( val duration: Double, var uri: String = "" - ) + ) : Segment() { + override fun toM3U8Line(): String = buildString { + append("#EXTINF:${duration},\n") + append(uri + "\n") + } + } + + class DiscontinuitySegment : Segment() { + override fun toM3U8Line(): String = buildString { + append("#EXT-X-DISCONTINUITY\n") + } + } + + class EndListSegment : Segment() { + override fun toM3U8Line(): String = buildString { + append("#EXT-X-ENDLIST\n") + } + } } From c52944621918058d7d9a1d27855c5c7afe726c59 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 23 Nov 2023 17:28:14 +0100 Subject: [PATCH 20/23] Attempt to fetch live videos for offline videos --- .../mainactivity/main/VideoDetailView.kt | 88 +++++++++++++------ 1 file changed, 63 insertions(+), 25 deletions(-) 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 d529062d..6a84494a 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 @@ -124,6 +124,7 @@ class VideoDetailView : ConstraintLayout { private var _searchVideo: IPlatformVideo? = null; var video: IPlatformVideoDetails? = null private set; + var videoLocal: VideoLocal? = null; private var _playbackTracker: IPlaybackTracker? = null; val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url; @@ -1044,10 +1045,32 @@ class VideoDetailView : ConstraintLayout { _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); } - val video = if(videoDetail is VideoLocal) - videoDetail; - else //TODO: Update cached video if it exists with video - StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail; + var videoLocal: VideoLocal? = null; + var video: IPlatformVideoDetails? = null; + + if(videoDetail is VideoLocal) { + videoLocal = videoDetail; + video = videoDetail; + val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url); + videoTask.invokeOnCompletion { ex -> + if(ex != null) { + Logger.e(TAG, "Failed to fetch live video for offline video", ex); + return@invokeOnCompletion; + } + val result = videoTask.getCompleted(); + if(this.video == videoDetail && result != null && result is IPlatformVideoDetails) { + this.video = result; + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateQualitySourcesOverlay(result, videoLocal); + } + } + }; + } + else { //TODO: Update cached video if it exists with video + videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id); + video = videoDetail; + } + this.videoLocal = videoLocal; this.video = video; this._playbackTracker = null; @@ -1082,9 +1105,13 @@ class VideoDetailView : ConstraintLayout { me._playbackTracker = tracker; } catch(ex: Throwable) { - withContext(Dispatchers.Main) { - UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex); + Logger.e(TAG, "Playback tracker failed", ex); + if(me.video?.isLive == true) withContext(Dispatchers.Main) { + UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker)); }; + else withContext(Dispatchers.Main) { + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex); + } } }; } @@ -1235,7 +1262,7 @@ class VideoDetailView : ConstraintLayout { //Overlay - updateQualitySourcesOverlay(video); + updateQualitySourcesOverlay(video, videoLocal); setLoading(false); @@ -1514,9 +1541,9 @@ class VideoDetailView : ConstraintLayout { //Quality Selector data private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List?, liveStreamAudioFormats : List?) { val v = video ?: return; - updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats); + updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats); } - private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { + private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { Logger.i(TAG, "updateQualitySourcesOverlay"); val video: IPlatformVideoDetails?; @@ -1524,24 +1551,35 @@ class VideoDetailView : ConstraintLayout { val localAudioSource: List?; val localSubtitleSources: List?; + val videoSources: List?; + val audioSources: List?; + if(videoDetails is VideoLocal) { - video = videoDetails.videoSerialized; + video = videoLocal?.videoSerialized; localVideoSources = videoDetails.videoSource.toList(); localAudioSource = videoDetails.audioSource.toList(); localSubtitleSources = videoDetails.subtitlesSources.toList(); + videoSources = null + audioSources = null; } else { video = videoDetails; - localVideoSources = null; - localAudioSource = null; - localSubtitleSources = null; + videoSources = video?.video?.videoSources?.toList(); + audioSources = if(video?.video?.isUnMuxed == true) + (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() + else null + if(videoLocal != null) { + localVideoSources = videoLocal.videoSource.toList(); + localAudioSource = videoLocal.audioSource.toList(); + localSubtitleSources = videoLocal.subtitlesSources.toList(); + } + else { + localVideoSources = null; + localAudioSource = null; + localSubtitleSources = null; + } } - val videoSources = video?.video?.videoSources?.toList(); - val audioSources = if(video?.video?.isUnMuxed == true) - (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() - else null - val bestVideoSources = videoSources?.map { it.height * it.width } ?.distinct() ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } @@ -1569,7 +1607,7 @@ class VideoDetailView : ConstraintLayout { if(localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video", - *localVideoSources.stream() + *localVideoSources .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it, { handleSelectVideoTrack(it) }); @@ -1577,7 +1615,7 @@ class VideoDetailView : ConstraintLayout { else null, if(localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio", - *localAudioSource.stream() + *localAudioSource .map { SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, { handleSelectAudioTrack(it) }); @@ -1593,7 +1631,7 @@ class VideoDetailView : ConstraintLayout { else null, if(liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", - *liveStreamVideoFormats.stream() + *liveStreamVideoFormats .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, { _player.selectVideoTrack(it.height) }); @@ -1601,7 +1639,7 @@ class VideoDetailView : ConstraintLayout { else null, if(liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", - *liveStreamAudioFormats.stream() + *liveStreamAudioFormats .map { SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it, { _player.selectAudioTrack(it.bitrate) }); @@ -1610,7 +1648,7 @@ class VideoDetailView : ConstraintLayout { if(bestVideoSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", - *bestVideoSources.stream() + *bestVideoSources .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, { handleSelectVideoTrack(it) }); @@ -1618,7 +1656,7 @@ class VideoDetailView : ConstraintLayout { else null, if(bestAudioSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", - *bestAudioSources.stream() + *bestAudioSources .map { SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, { handleSelectAudioTrack(it) }); @@ -1841,7 +1879,7 @@ class VideoDetailView : ConstraintLayout { private fun setCastEnabled(isCasting: Boolean) { Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)") - video?.let { updateQualitySourcesOverlay(it); }; + video?.let { updateQualitySourcesOverlay(it, videoLocal); }; _isCasting = isCasting; From e47349d0106b07b472500e39eda3b65d2b64c613 Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 24 Nov 2023 10:37:18 +0100 Subject: [PATCH 21/23] Added OPTIONS headers where necessary and further HLS spec implementations. --- .../api/http/server/ManagedHttpServer.kt | 18 ++ .../handlers/HttpOptionsAllowHandler.kt | 10 +- .../platformplayer/casting/StateCasting.kt | 163 +++++++++--------- .../com/futo/platformplayer/parsers/HLS.kt | 92 +++++----- 4 files changed, 151 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt index fa5eafac..1625186f 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/ManagedHttpServer.kt @@ -5,6 +5,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler import com.futo.platformplayer.api.http.server.handlers.HttpHandler +import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler import java.io.BufferedInputStream import java.io.OutputStream import java.lang.reflect.Field @@ -141,6 +142,23 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) { } return handler; } + + fun addHandlerWithAllowAllOptions(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { + val allowedMethods = arrayListOf(handler.method, "OPTIONS") + if (withHEAD) { + allowedMethods.add("HEAD") + } + + val tag = handler.tag + if (tag != null) { + addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods).withTag(tag)) + } else { + addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods)) + } + + return addHandler(handler, withHEAD) + } + fun removeHandler(method: String, path: String) { synchronized(_handlers) { val handlerMap = _handlers[method] ?: return diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt index 3561e31e..abb5009e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpOptionsAllowHandler.kt @@ -2,11 +2,17 @@ package com.futo.platformplayer.api.http.server.handlers import com.futo.platformplayer.api.http.server.HttpContext -class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) { +class HttpOptionsAllowHandler(path: String, val allowedMethods: List = listOf()) : HttpHandler("OPTIONS", path) { override fun handle(httpContext: HttpContext) { val newHeaders = headers.clone() newHeaders.put("Access-Control-Allow-Origin", "*") - newHeaders.put("Access-Control-Allow-Methods", "*") + + if (allowedMethods.isNotEmpty()) { + newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", ")) + } else { + newHeaders.put("Access-Control-Allow-Methods", "*") + } + newHeaders.put("Access-Control-Allow-Headers", "*") httpContext.respondCode(200, newHeaders); } 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 d3150157..b71094b2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -370,7 +370,7 @@ class StateCasting { } else if(videoSource is IHLSManifestSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls(video, videoSource.url, resumePosition); + castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition); } else { Logger.i(TAG, "Casting as non-proxied HLS"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); @@ -378,7 +378,7 @@ class StateCasting { } else if(audioSource is IHLSManifestAudioSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls(video, audioSource.url, resumePosition); + castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); @@ -442,7 +442,7 @@ class StateCasting { val videoPath = "/video-${id}" val videoUrl = url + videoPath; - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); @@ -461,7 +461,7 @@ class StateCasting { val audioPath = "/audio-${id}" val audioUrl = url + audioPath; - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); @@ -492,7 +492,7 @@ class StateCasting { val variantPlaylistReferences = arrayListOf() if (videoSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") @@ -501,9 +501,9 @@ class StateCasting { val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true @@ -514,7 +514,7 @@ class StateCasting { } if (audioSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") @@ -523,9 +523,9 @@ class StateCasting { val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true @@ -535,7 +535,7 @@ class StateCasting { } if (subtitleSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castLocalHls") @@ -544,9 +544,9 @@ class StateCasting { val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true @@ -556,7 +556,7 @@ class StateCasting { } val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true @@ -584,43 +584,28 @@ class StateCasting { val audioUrl = url + audioPath; val subtitleUrl = url + subtitlePath; - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitleUrl), "application/dash+xml") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); if (videoSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(videoPath) - .withHeader("Access-Control-Allow-Origin", "*") - .withHeader("Connection", "keep-alive")) - .withTag("cast"); } if (audioSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(audioPath) - .withHeader("Access-Control-Allow-Origin", "*") - .withHeader("Connection", "keep-alive")) - .withTag("cast"); } if (subtitleSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath) .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(subtitlePath) - .withHeader("Access-Control-Allow-Origin", "*") - .withHeader("Connection", "keep-alive")) - .withTag("cast"); } Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); @@ -654,7 +639,7 @@ class StateCasting { } if (content != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); @@ -674,7 +659,7 @@ class StateCasting { return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); } - private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List { + private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); @@ -685,13 +670,41 @@ class StateCasting { val hlsUrl = url + hlsPath Logger.i(TAG, "HLS url: $hlsUrl"); - _castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext -> + _castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", hlsPath) { masterContext -> _castServer.removeAllHandlers("castProxiedHlsVariant") val headers = masterContext.headers.clone() headers["Content-Type"] = "application/vnd.apple.mpegurl"; - val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl) + val masterPlaylistResponse = _client.get(sourceUrl) + check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } + + val masterPlaylistContent = masterPlaylistResponse.body?.string() + ?: throw Exception("Master playlist content is empty") + + val masterPlaylist: HLS.MasterPlaylist + try { + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) + } catch (e: Throwable) { + if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { + //This is a variant playlist, not a master playlist + Logger.i(TAG, "HLS casting as variant playlist (codec: $codec): $hlsUrl"); + + val vpHeaders = masterContext.headers.clone() + vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + + val variantPlaylist = HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl) + val proxiedVariantPlaylist = proxyVariantPlaylist(url, id, variantPlaylist, video.isLive) + val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() + masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); + return@HttpFuntionHandler + } else { + throw e + } + } + + Logger.i(TAG, "HLS casting as master playlist: $hlsUrl"); + val newVariantPlaylistRefs = arrayListOf() val newMediaRenditions = arrayListOf() val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments) @@ -701,11 +714,17 @@ class StateCasting { val newPlaylistPath = "/hls-playlist-${playlistId}" val newPlaylistUrl = url + newPlaylistPath; - _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + _castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> val vpHeaders = vpContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url) + val response = _client.get(variantPlaylistRef.url) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url) val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); @@ -725,11 +744,17 @@ class StateCasting { val newPlaylistPath = "/hls-playlist-${playlistId}" newPlaylistUrl = url + newPlaylistPath - _castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> + _castServer.addHandlerWithAllowAllOptions(HttpFuntionHandler("GET", newPlaylistPath) { vpContext -> val vpHeaders = vpContext.headers.clone() vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; - val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri) + val response = _client.get(mediaRendition.uri) + check(response.isOk) { "Failed to get variant playlist: ${response.code}" } + + val vpContent = response.body?.string() + ?: throw Exception("Variant playlist content is empty") + + val variantPlaylist = HLS.parseVariantPlaylist(vpContent, mediaRendition.uri) val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive) val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8() vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8); @@ -754,7 +779,7 @@ class StateCasting { Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath)."); //ChromeCast is sometimes funky with resume position 0 - val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition; + val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble()); return listOf(hlsUrl); @@ -765,7 +790,7 @@ class StateCasting { if (proxySegments) { variantPlaylist.segments.forEachIndexed { index, segment -> - val sequenceNumber = variantPlaylist.mediaSequence + index.toLong() + val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong() newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber)) } } else { @@ -779,6 +804,7 @@ class StateCasting { variantPlaylist.discontinuitySequence, variantPlaylist.programDateTime, variantPlaylist.playlistType, + variantPlaylist.streamInfo, newSegments ) } @@ -789,7 +815,7 @@ class StateCasting { val newSegmentUrl = url + newSegmentPath; if (_castServer.getHandler("GET", newSegmentPath) == null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", newSegmentPath, segment.uri, true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true @@ -826,23 +852,21 @@ class StateCasting { val audioVariantPlaylistPath = "/audio-playlist-${id}" val audioVariantPlaylistUrl = url + audioVariantPlaylistPath val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl)) - val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments) + val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, audioVariantPlaylistSegments) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - _castServer.addHandler(HttpOptionsAllowHandler(audioVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true)) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - _castServer.addHandler(HttpOptionsAllowHandler(audioPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); } val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) { @@ -861,11 +885,10 @@ class StateCasting { } if (content != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - _castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); } subtitlesUrl = url + subtitlePath; @@ -879,14 +902,13 @@ class StateCasting { val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}" val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl)) - val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments) + val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, subtitleVariantPlaylistSegments) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - _castServer.addHandler(HttpOptionsAllowHandler(subtitleVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true)) } @@ -899,14 +921,13 @@ class StateCasting { val videoVariantPlaylistPath = "/video-playlist-${id}" val videoVariantPlaylistUrl = url + videoVariantPlaylistPath val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl)) - val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments) + val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, null, videoVariantPlaylistSegments) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - _castServer.addHandler(HttpOptionsAllowHandler(videoVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo( videoSource.bitrate ?: 0, @@ -918,21 +939,19 @@ class StateCasting { if (subtitleSource != null) "subtitles" else null, null, null))) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectVariant"); - _castServer.addHandler(HttpOptionsAllowHandler(videoPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); } val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true) - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(), "application/vnd.apple.mpegurl") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("castHlsIndirectMaster") - _castServer.addHandler(HttpOptionsAllowHandler(hlsPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant"); Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble()); @@ -976,11 +995,10 @@ class StateCasting { } if (content != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("cast"); } subtitlesUrl = url + subtitlePath; @@ -989,38 +1007,25 @@ class StateCasting { } } - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpConstantHandler("GET", dashPath, DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl), "application/dash+xml") .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(dashPath) - .withHeader("Access-Control-Allow-Origin", "*") - ).withTag("cast"); if (videoSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(videoPath) - .withHeader("Access-Control-Allow-Origin", "*") - ).withTag("cast"); } if (audioSource != null) { - _castServer.addHandler( + _castServer.addHandlerWithAllowAllOptions( HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) .withInjectedHost() .withHeader("Access-Control-Allow-Origin", "*"), true ).withTag("cast"); - _castServer.addHandler( - HttpOptionsAllowHandler(audioPath) - .withHeader("Access-Control-Allow-Origin", "*") - ) - .withTag("cast"); } Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); 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 d8b1fbaf..e07b8a17 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -9,12 +9,7 @@ import java.time.format.DateTimeFormatter class HLS { companion object { - fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, sourceUrl: String): MasterPlaylist { - val masterPlaylistResponse = client.get(sourceUrl) - check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } - - val masterPlaylistContent = masterPlaylistResponse.body?.string() - ?: throw Exception("Master playlist content is empty") + fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { val baseUrl = URI(sourceUrl).resolve("./").toString() val variantPlaylists = mutableListOf() @@ -33,7 +28,7 @@ class HLS { } line.startsWith("#EXT-X-MEDIA") -> { - mediaRenditions.add(parseMediaRendition(client, line, baseUrl)) + mediaRenditions.add(parseMediaRendition(line, baseUrl)) } line == "#EXT-X-INDEPENDENT-SEGMENTS" -> { @@ -50,27 +45,21 @@ class HLS { return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) } - fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist { - val response = client.get(sourceUrl) - check(response.isOk) { "Failed to get variant playlist: ${response.code}" } - - val content = response.body?.string() - ?: throw Exception("Variant playlist content is empty") - + fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { val lines = content.lines() - val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3 + val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull() - ?: throw Exception("Target duration not found in variant playlist") - val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() ?: 0 - val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() ?: 0 + val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() + val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let { ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) } 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 segments = mutableListOf() var currentSegment: MediaSegment? = null - lines.forEach { line -> + lines.forEachIndexed { index, line -> when { line.startsWith("#EXTINF:") -> { val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull() @@ -93,7 +82,7 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, segments) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) } private fun resolveUrl(baseUrl: String, url: String): String { @@ -123,7 +112,7 @@ class HLS { ) } - private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition { + private fun parseMediaRendition(line: String, baseUrl: String): MediaRendition { val attributes = parseAttributes(line) val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) } return MediaRendition( @@ -208,7 +197,23 @@ class HLS { val video: String?, val subtitles: String?, val closedCaptions: String? - ) + ) { + fun toM3U8Line(): String = buildString { + append("#EXT-X-STREAM-INF:") + appendAttributes(this, + "BANDWIDTH" to bandwidth?.toString(), + "RESOLUTION" to resolution, + "CODECS" to codecs, + "FRAME-RATE" to frameRate, + "VIDEO-RANGE" to videoRange, + "AUDIO" to audio, + "VIDEO" to video, + "SUBTITLES" to subtitles, + "CLOSED-CAPTIONS" to closedCaptions + ) + append("\n") + } + } data class MediaRendition( val type: String?, @@ -268,45 +273,30 @@ class HLS { data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) { fun toM3U8Line(): String = buildString { - append("#EXT-X-STREAM-INF:") - appendAttributes(this, - "BANDWIDTH" to streamInfo.bandwidth?.toString(), - "RESOLUTION" to streamInfo.resolution, - "CODECS" to streamInfo.codecs, - "FRAME-RATE" to streamInfo.frameRate, - "VIDEO-RANGE" to streamInfo.videoRange, - "AUDIO" to streamInfo.audio, - "VIDEO" to streamInfo.video, - "SUBTITLES" to streamInfo.subtitles, - "CLOSED-CAPTIONS" to streamInfo.closedCaptions - ) - append("\n$url\n") + append(streamInfo.toM3U8Line()) + append("$url\n") } } data class VariantPlaylist( - val version: Int, - val targetDuration: Int, - val mediaSequence: Long, - val discontinuitySequence: Int, + val version: Int?, + val targetDuration: Int?, + val mediaSequence: Long?, + val discontinuitySequence: Int?, val programDateTime: ZonedDateTime?, val playlistType: String?, + val streamInfo: StreamInfo?, val segments: List ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") - append("#EXT-X-VERSION:$version\n") - append("#EXT-X-TARGETDURATION:$targetDuration\n") - append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n") - append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n") - - playlistType?.let { - append("#EXT-X-PLAYLIST-TYPE:$it\n") - } - - programDateTime?.let { - append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") - } + version?.let { append("#EXT-X-VERSION:$it\n") } + targetDuration?.let { append("#EXT-X-TARGETDURATION:$it\n") } + mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") } + discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") } + playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") } + programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") } + streamInfo?.let { append(it.toM3U8Line()) } segments.forEach { segment -> append(segment.toM3U8Line()) From d245e20b14185f8ba1a278a1f2f1f8c040ddbc48 Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 24 Nov 2023 11:24:52 +0100 Subject: [PATCH 22/23] Chromecast socket crash fix. --- .../com/futo/platformplayer/casting/ChomecastCastingDevice.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a67558c4..39b8c640 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -314,6 +314,7 @@ class ChromecastCastingDevice : CastingDevice { connectionState = CastConnectionState.CONNECTING; try { + _socket?.close() _socket = factory.createSocket(usedRemoteAddress, port) as SSLSocket; _socket?.startHandshake(); Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port"); @@ -324,7 +325,7 @@ class ChromecastCastingDevice : CastingDevice { } catch (e: Throwable) { Logger.i(TAG, "Failed to authenticate to Chromecast.", e); } - } catch (e: IOException) { + } catch (e: Throwable) { _socket?.close(); Logger.i(TAG, "Failed to connect to Chromecast.", e); From 12b2552185552bc4fe91e0c34e12653d9f3c7d07 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 24 Nov 2023 15:22:03 +0100 Subject: [PATCH 23/23] Settings search, Fix nested video events, Adding setting descriptions for metered --- .../java/com/futo/platformplayer/Settings.kt | 12 ++-- .../activities/SettingsActivity.kt | 2 + .../feedtypes/PreviewNestedVideoView.kt | 44 ++++++++++++- .../platformplayer/views/buttons/BigButton.kt | 3 + .../views/fields/ButtonField.kt | 4 ++ .../views/fields/DropdownField.kt | 3 + .../futo/platformplayer/views/fields/Field.kt | 2 + .../platformplayer/views/fields/FieldForm.kt | 62 ++++++++++++++----- .../platformplayer/views/fields/GroupField.kt | 5 ++ .../views/fields/ReadOnlyTextField.kt | 3 + .../views/fields/ToggleField.kt | 3 + app/src/main/res/layout/field_form.xml | 38 +++++++++++- .../layout/list_video_thumbnail_nested.xml | 8 ++- app/src/main/res/values/strings.xml | 4 ++ 14 files changed, 170 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 56aff38d..8daa4736 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -307,29 +307,29 @@ class Settings : FragmentedStorageFileJson() { else -> 1.0f; }; - @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2) + @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2) @DropdownFieldOptionsId(R.array.preferred_quality_array) var preferredQuality: Int = 0; - @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, -1, 2) + @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) var preferredMeteredQuality: Int = 0; fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality); fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality); fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount(); - @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, -1, 3) + @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4) @DropdownFieldOptionsId(R.array.preferred_quality_array) var preferredPreviewQuality: Int = 5; fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); - @FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 4) + @FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5) @DropdownFieldOptionsId(R.array.system_enabled_disabled_array) var autoRotate: Int = 2; fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate()); - @FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 5) + @FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 6) @DropdownFieldOptionsId(R.array.auto_rotate_dead_zone) var autoRotateDeadZone: Int = 0; @@ -337,7 +337,7 @@ class Settings : FragmentedStorageFileJson() { return autoRotateDeadZone * 5; } - @FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6) + @FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7) @DropdownFieldOptionsId(R.array.player_background_behavior) var backgroundPlay: Int = 2; diff --git a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt index 1925a1c9..3e5259a9 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SettingsActivity.kt @@ -69,9 +69,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher { } fun reloadSettings() { + _form.setSearchVisible(false); _loader.start(); _form.fromObject(lifecycleScope, Settings.instance) { _loader.stop(); + _form.setSearchVisible(true); var devCounter = 0; _form.findField("code")?.assume()?.setOnClickListener { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt index 8d8845e0..6644d7eb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewNestedVideoView.kt @@ -5,10 +5,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.TextView import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails @@ -17,6 +19,7 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.Loader import com.futo.platformplayer.views.platform.PlatformIndicator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -25,6 +28,7 @@ class PreviewNestedVideoView : PreviewVideoView { protected val _platformIndicatorNested: PlatformIndicator; protected val _containerLoader: LinearLayout; + protected val _loader: Loader; protected val _containerUnavailable: LinearLayout; protected val _textNestedUrl: TextView; @@ -38,8 +42,39 @@ class PreviewNestedVideoView : PreviewVideoView { constructor(context: Context, feedStyle: FeedStyle, exoPlayer: PlayerManager? = null): super(context, feedStyle, exoPlayer) { _platformIndicatorNested = findViewById(R.id.thumbnail_platform_nested); _containerLoader = findViewById(R.id.container_loader); + _loader = findViewById(R.id.loader); _containerUnavailable = findViewById(R.id.container_unavailable); _textNestedUrl = findViewById(R.id.text_nested_url); + + _imageChannel?.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } }; + _textChannelName.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } }; + _textVideoMetadata.setOnClickListener { _contentNested?.let { onChannelClicked.emit(it.author) } }; + _button_add_to.setOnClickListener { + if(_contentNested is IPlatformVideo) + _contentNested?.let { onAddToClicked.emit(it as IPlatformVideo) } + else _content?.let { + if(it is IPlatformNestedContent) + loadNested(it) { + if(it is IPlatformVideo) + onAddToClicked.emit(it); + else + UIDialogs.toast(context, "Content is not a video"); + } + } + }; + _button_add_to_queue.setOnClickListener { + if(_contentNested is IPlatformVideo) + _contentNested?.let { onAddToQueueClicked.emit(it as IPlatformVideo) } + else _content?.let { + if(it is IPlatformNestedContent) + loadNested(it) { + if(it is IPlatformVideo) + onAddToQueueClicked.emit(it); + else + UIDialogs.toast(context, "Content is not a video"); + } + } + }; } override fun inflate(feedStyle: FeedStyle) { @@ -81,6 +116,7 @@ class PreviewNestedVideoView : PreviewVideoView { if(!_contentSupported) { _containerUnavailable.visibility = View.VISIBLE; _containerLoader.visibility = View.GONE; + _loader.stop(); } else { if(_feedStyle == FeedStyle.THUMBNAIL) @@ -96,12 +132,14 @@ class PreviewNestedVideoView : PreviewVideoView { _contentSupported = false; _containerUnavailable.visibility = View.VISIBLE; _containerLoader.visibility = View.GONE; + _loader.stop(); } } - private fun loadNested(content: IPlatformNestedContent) { + private fun loadNested(content: IPlatformNestedContent, onCompleted: ((IPlatformContentDetails)->Unit)? = null) { Logger.i(TAG, "Loading nested content [${content.contentUrl}]"); _containerLoader.visibility = View.VISIBLE; + _loader.start(); StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { val def = StatePlatform.instance.getContentDetails(content.contentUrl); def.invokeOnCompletion { @@ -112,11 +150,13 @@ class PreviewNestedVideoView : PreviewVideoView { if(_content == content) { _containerUnavailable.visibility = View.VISIBLE; _containerLoader.visibility = View.GONE; + _loader.stop(); } //TODO: Handle exception } else if(_content == content) { _containerLoader.visibility = View.GONE; + _loader.stop(); val nestedContent = def.getCompleted(); _contentNested = nestedContent; if(nestedContent is IPlatformVideoDetails) { @@ -131,6 +171,8 @@ class PreviewNestedVideoView : PreviewVideoView { else { _containerUnavailable.visibility = View.VISIBLE; } + if(onCompleted != null) + onCompleted(nestedContent); } } }; diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt index 66d0b3bb..6d5e30e8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt @@ -19,6 +19,9 @@ open class BigButton : LinearLayout { private val _textPrimary: TextView; private val _textSecondary: TextView; + val title: String get() = _textPrimary.text.toString(); + val description: String get() = _textSecondary.text.toString(); + val onClick = Event0(); constructor(context : Context, text: String, subText: String, icon: Int, action: ()->Unit) : super(context) { diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt index e3342948..6996005c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt @@ -28,6 +28,10 @@ class ButtonField : BigButton, IField { override val value: Any? = null; + override val searchContent: String? + get() = "$title $description"; + + override val obj : Any? get() { if(this._obj == null) throw java.lang.IllegalStateException("Can only be called if fromField is used"); diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt index 5fc34884..f2f95d1a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt @@ -41,6 +41,9 @@ class DropdownField : TableRow, IField { override val value: Any? get() = _selected; + override val searchContent: String? + get() = "${_title.text} ${_description.text}"; + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs){ inflate(context, R.layout.field_dropdown, this); _spinner = findViewById(R.id.field_spinner); diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt index 0f950bea..1b5a9282 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt @@ -23,6 +23,8 @@ interface IField { var reference: Any?; + val searchContent: String?; + fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField; fun setField(); diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt b/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt index 5a112c37..24ebf38e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt @@ -3,12 +3,14 @@ package com.futo.platformplayer.views.fields import android.content.Context import android.util.AttributeSet import android.view.View +import android.widget.EditText +import android.widget.FrameLayout import android.widget.LinearLayout +import androidx.core.widget.addTextChangedListener import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,11 +26,12 @@ import kotlin.reflect.full.hasAnnotation import kotlin.reflect.jvm.javaField import kotlin.reflect.jvm.javaMethod import kotlin.streams.asStream -import kotlin.streams.toList class FieldForm : LinearLayout { - private val _root : LinearLayout; + private val _containerSearch: FrameLayout; + private val _editSearch: EditText; + private val _fieldsContainer : LinearLayout; val onChanged = Event2(); @@ -36,11 +39,45 @@ class FieldForm : LinearLayout { constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.field_form, this); - _root = findViewById(R.id.field_form_root); + _containerSearch = findViewById(R.id.container_search); + _editSearch = findViewById(R.id.edit_search); + _fieldsContainer = findViewById(R.id.field_form_container); + + _editSearch.addTextChangedListener { + updateSettingsVisibility(); + } + } + + fun updateSettingsVisibility(group: GroupField? = null) { + val settings = group?.getFields() ?: _fields; + + val query = _editSearch.text.toString().lowercase(); + + var groupVisible = false; + val isGroupMatch = query.isEmpty() || group?.searchContent?.lowercase()?.contains(query) == true; + for(field in settings) { + if(field is GroupField) + updateSettingsVisibility(field); + else if(field is View && field.descriptor != null) { + val txt = field.searchContent?.lowercase(); + if(txt != null) { + val visible = isGroupMatch || txt.contains(query); + field.visibility = if (visible) View.VISIBLE else View.GONE; + groupVisible = groupVisible || visible; + } + } + } + if(group != null) + group.visibility = if(groupVisible) View.VISIBLE else View.GONE; + } + + fun setSearchVisible(visible: Boolean) { + _containerSearch.visibility = if(visible) View.VISIBLE else View.GONE; + _editSearch.setText(""); } fun fromObject(scope: CoroutineScope, obj : Any, onLoaded: (()->Unit)? = null) { - _root.removeAllViews(); + _fieldsContainer.removeAllViews(); scope.launch(Dispatchers.Default) { val newFields = getFieldsFromObject(context, obj); @@ -50,7 +87,7 @@ class FieldForm : LinearLayout { if (field !is View) throw java.lang.IllegalStateException("Only views can be IFields"); - _root.addView(field as View); + _fieldsContainer.addView(field as View); field.onChanged.subscribe { a1, a2, oldValue -> onChanged.emit(a1, a2); }; @@ -62,13 +99,13 @@ class FieldForm : LinearLayout { } } fun fromObject(obj : Any) { - _root.removeAllViews(); + _fieldsContainer.removeAllViews(); val newFields = getFieldsFromObject(context, obj); for(field in newFields) { if(field !is View) throw java.lang.IllegalStateException("Only views can be IFields"); - _root.addView(field as View); + _fieldsContainer.addView(field as View); field.onChanged.subscribe { a1, a2, oldValue -> onChanged.emit(a1, a2); }; @@ -76,7 +113,7 @@ class FieldForm : LinearLayout { _fields = newFields; } fun fromPluginSettings(settings: List, values: HashMap, groupTitle: String? = null, groupDescription: String? = null) { - _root.removeAllViews(); + _fieldsContainer.removeAllViews(); val newFields = getFieldsFromPluginSettings(context, settings, values); if (newFields.isEmpty()) { return; @@ -87,7 +124,7 @@ class FieldForm : LinearLayout { if(field.second !is View) throw java.lang.IllegalStateException("Only views can be IFields"); finalizePluginSettingField(field.first, field.second, newFields); - _root.addView(field as View); + _fieldsContainer.addView(field as View); } _fields = newFields.map { it.second }; } else { @@ -96,7 +133,7 @@ class FieldForm : LinearLayout { } val group = GroupField(context, groupTitle, groupDescription) .withFields(newFields.map { it.second }); - _root.addView(group as View); + _fieldsContainer.addView(group as View); } } private fun finalizePluginSettingField(setting: SourcePluginConfig.Setting, field: IField, others: List>) { @@ -210,7 +247,6 @@ class FieldForm : LinearLayout { .asStream() .filter { it.hasAnnotation() && it.javaField != null } .map { Pair, FormField>(it, it.findAnnotation()!!) } - .toList() //TODO: Rewrite fields to properties so no map is required val propertyMap = mutableMapOf>(); @@ -252,7 +288,6 @@ class FieldForm : LinearLayout { .asStream() .filter { it.hasAnnotation() && it.javaField == null && it.getter.javaMethod != null} .map { Pair(it.getter.javaMethod!!, it.findAnnotation()!!) } - .toList(); for(prop in objProps) { prop.first.isAccessible = true; @@ -270,7 +305,6 @@ class FieldForm : LinearLayout { .asStream() .filter { it.getAnnotation(FormField::class.java) != null && !it.name.startsWith("get") && !it.name.startsWith("set") } .map { Pair(it, it.getAnnotation(FormField::class.java)) } - .toList(); for(meth in objMethods) { meth.first.isAccessible = true; diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt index 5f657d88..6ea48ee7 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt @@ -39,6 +39,8 @@ class GroupField : LinearLayout, IField { override val value: Any? = null; + override val searchContent: String? get() = "${_title.text} ${_subtitle.text}"; + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.field_group, this); _title = findViewById(R.id.field_group_title); @@ -142,6 +144,9 @@ class GroupField : LinearLayout, IField { field.setField(); } } + fun getFields(): List { + return _fields; + } override fun setValue(value: Any) {} } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt index c54239ae..7b0244dc 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt @@ -34,6 +34,9 @@ class ReadOnlyTextField : TableRow, IField { override val value: Any? = null; + override val searchContent: String? + get() = "${_title.text}"; + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){ inflate(context, R.layout.field_readonly_text, this); _title = findViewById(R.id.field_title); diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt index 01ff8f1c..2bbbbb21 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt @@ -38,6 +38,9 @@ class ToggleField : TableRow, IField { override val value: Any get() = _lastValue; + override val searchContent: String? + get() = "${_title.text} ${_description.text}"; + constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs){ inflate(context, R.layout.field_toggle, this); _toggle = findViewById(R.id.field_toggle); diff --git a/app/src/main/res/layout/field_form.xml b/app/src/main/res/layout/field_form.xml index 74aee5fa..1b1d9f94 100644 --- a/app/src/main/res/layout/field_form.xml +++ b/app/src/main/res/layout/field_form.xml @@ -2,8 +2,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_video_thumbnail_nested.xml b/app/src/main/res/layout/list_video_thumbnail_nested.xml index 6b699eb4..a54e6276 100644 --- a/app/src/main/res/layout/list_video_thumbnail_nested.xml +++ b/app/src/main/res/layout/list_video_thumbnail_nested.xml @@ -125,7 +125,13 @@ android:layout_height="match_parent" android:background="#BB000000" android:visibility="gone" - android:orientation="vertical" /> + android:gravity="center" + android:orientation="vertical"> + + Defaults Home Screen Preferred Quality + Default quality for watching a video Update Close Never @@ -358,8 +359,11 @@ Player Plugins Preferred Casting Quality + Default quality while casting to an external device Preferred Metered Quality + Default quality while on metered connections such as cellular Preferred Preview Quality + Default quality while previewing a video in a feed Primary Language Default Comment Section Reinstall Embedded Plugins