mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-17 13:32:38 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb3dd854d4 | |||
| c529446219 | |||
| fa2f8c3447 | |||
| 840d1ae534 | |||
| 2530c6eb58 | |||
| ee3761c780 | |||
| e4c89e9aa9 | |||
| 9d5888ddf7 | |||
| ecc94920d7 | |||
| 5cafbf243e | |||
| f3fa208680 |
@@ -23,23 +23,6 @@ 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)
|
||||
}
|
||||
fun Boolean?.toYesNo(): String {
|
||||
return if (this == true) "YES" else "NO"
|
||||
}
|
||||
@@ -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!!);
|
||||
|
||||
+4
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
-18
@@ -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<String, String>, 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())
|
||||
|
||||
@@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
||||
val sameItems = results.filter { isSameItem(result, it) };
|
||||
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> 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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -334,20 +334,25 @@ 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) {
|
||||
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");
|
||||
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) {
|
||||
@@ -356,27 +361,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) {
|
||||
castHlsIndirect(video, videoSource.url, resumePosition);
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
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());
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (ad is ChromecastCastingDevice && video.isLive) {
|
||||
castHlsIndirect(video, audioSource.url, resumePosition);
|
||||
if (ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
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());
|
||||
}
|
||||
} 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 +426,14 @@ class StateCasting {
|
||||
return true;
|
||||
}
|
||||
|
||||
private fun castVideoIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castAudioIndirect() {
|
||||
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
@@ -451,6 +472,101 @@ class StateCasting {
|
||||
return listOf(audioUrl);
|
||||
}
|
||||
|
||||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> {
|
||||
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<HLS.MediaRendition>()
|
||||
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
|
||||
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.MediaSegment(duration.toDouble(), videoUrl))
|
||||
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, 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, 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.MediaSegment(duration.toDouble(), audioUrl))
|
||||
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, 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.MediaSegment(duration.toDouble(), subtitleUrl))
|
||||
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, 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<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
@@ -558,8 +674,8 @@ class StateCasting {
|
||||
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
|
||||
}
|
||||
|
||||
private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
|
||||
_castServer.removeAllHandlers("castHlsIndirectMaster")
|
||||
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
|
||||
@@ -570,7 +686,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";
|
||||
@@ -590,10 +706,10 @@ 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("castHlsIndirectVariant")
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||
|
||||
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
|
||||
newPlaylistUrl,
|
||||
@@ -602,20 +718,22 @@ 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("castHlsIndirectVariant")
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
|
||||
}
|
||||
|
||||
newMediaRenditions.add(HLS.MediaRendition(
|
||||
@@ -631,15 +749,18 @@ 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("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<HLS.Segment>()
|
||||
|
||||
if (proxySegments) {
|
||||
@@ -657,26 +778,166 @@ 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")
|
||||
}
|
||||
|
||||
return HLS.MediaSegment(
|
||||
segment.duration,
|
||||
newSegmentUrl
|
||||
)
|
||||
} else {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
|
||||
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<HLS.MediaRendition>()
|
||||
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
|
||||
|
||||
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.MediaSegment(duration.toDouble(), audioUrl))
|
||||
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
|
||||
|
||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||
_castServer.addHandler(
|
||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||
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(
|
||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castHlsIndirectVariant")
|
||||
).withTag("castHlsIndirectVariant");
|
||||
_castServer.addHandler(HttpOptionsAllowHandler(audioPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
|
||||
}
|
||||
|
||||
return HLS.Segment(
|
||||
segment.duration,
|
||||
newSegmentUrl
|
||||
)
|
||||
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("castHlsIndirectVariant");
|
||||
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
|
||||
}
|
||||
|
||||
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.MediaSegment(duration.toDouble(), subtitlesUrl))
|
||||
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
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))
|
||||
}
|
||||
|
||||
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.MediaSegment(duration.toDouble(), videoUrl))
|
||||
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
|
||||
|
||||
_castServer.addHandler(
|
||||
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,
|
||||
"${videoSource.width}x${videoSource.height}",
|
||||
videoSource.codec,
|
||||
null,
|
||||
null,
|
||||
if (audioSource != null) "audio" else null,
|
||||
if (subtitleSource != null) "subtitles" else null,
|
||||
null, null)))
|
||||
|
||||
_castServer.addHandler(
|
||||
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(
|
||||
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());
|
||||
|
||||
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<String> {
|
||||
@@ -684,8 +945,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 +953,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 +980,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");
|
||||
}
|
||||
|
||||
subtitlesUrl = url + subtitlePath;
|
||||
@@ -732,33 +994,37 @@ 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");
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
+2
@@ -59,6 +59,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
val onAddToClicked = Event1<IPlatformContent>();
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
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);
|
||||
|
||||
+6
@@ -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 ->
|
||||
|
||||
+63
-25
@@ -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<Format>?, liveStreamAudioFormats : List<Format>?) {
|
||||
val v = video ?: return;
|
||||
updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats);
|
||||
updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats);
|
||||
}
|
||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
|
||||
Logger.i(TAG, "updateQualitySourcesOverlay");
|
||||
|
||||
val video: IPlatformVideoDetails?;
|
||||
@@ -1524,24 +1551,35 @@ class VideoDetailView : ConstraintLayout {
|
||||
val localAudioSource: List<LocalAudioSource>?;
|
||||
val localSubtitleSources: List<LocalSubtitleSource>?;
|
||||
|
||||
val videoSources: List<IVideoSource>?;
|
||||
val audioSources: List<IAudioSource>?;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.futo.platformplayer.parsers
|
||||
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.toURIRobust
|
||||
import com.futo.platformplayer.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.net.URI
|
||||
import java.time.ZonedDateTime
|
||||
@@ -15,7 +15,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<VariantPlaylistReference>()
|
||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||
@@ -66,22 +66,26 @@ 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<Segment>()
|
||||
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 {
|
||||
it.uri = line
|
||||
it.uri = resolveUrl(sourceUrl, line)
|
||||
segments.add(it)
|
||||
}
|
||||
currentSegment = null
|
||||
@@ -89,13 +93,20 @@ 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 {
|
||||
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)
|
||||
@@ -106,6 +117,8 @@ class HLS {
|
||||
frameRate = attributes["FRAME-RATE"],
|
||||
videoRange = attributes["VIDEO-RANGE"],
|
||||
audio = attributes["AUDIO"],
|
||||
video = attributes["VIDEO"],
|
||||
subtitles = attributes["SUBTITLES"],
|
||||
closedCaptions = attributes["CLOSED-CAPTIONS"]
|
||||
)
|
||||
}
|
||||
@@ -151,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;
|
||||
@@ -192,6 +205,8 @@ class HLS {
|
||||
val frameRate: String?,
|
||||
val videoRange: String?,
|
||||
val audio: String?,
|
||||
val video: String?,
|
||||
val subtitles: String?,
|
||||
val closedCaptions: String?
|
||||
)
|
||||
|
||||
@@ -213,9 +228,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")
|
||||
}
|
||||
@@ -261,6 +276,8 @@ 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
|
||||
)
|
||||
append("\n$url\n")
|
||||
@@ -273,6 +290,7 @@ class HLS {
|
||||
val mediaSequence: Long,
|
||||
val discontinuitySequence: Int,
|
||||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val segments: List<Segment>
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
@@ -281,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
|
||||
val onChannelClicked = Event1<PlatformAuthorLink>();
|
||||
val onAddToClicked = Event1<IPlatformContent>();
|
||||
val onAddToQueueClicked = Event1<IPlatformContent>();
|
||||
val onLongPress = Event1<IPlatformContent>();
|
||||
|
||||
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();
|
||||
|
||||
+11
-8
@@ -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<IPlatformVideo, Long>();
|
||||
val onLongPress = Event1<IPlatformVideo>();
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#2A2A2A" />
|
||||
<corners android:radius="500dp" />
|
||||
<size android:height="20dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -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"
|
||||
|
||||
@@ -46,10 +46,9 @@
|
||||
app:layout_constraintLeft_toRightOf="@id/ic_viewers"
|
||||
tools:text="1536 viewers"/>
|
||||
|
||||
<ScrollView
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="35dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent">
|
||||
@@ -61,7 +60,7 @@
|
||||
android:layout_height="match_parent">
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</HorizontalScrollView>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button_close"
|
||||
|
||||
Submodule app/src/stable/assets/sources/kick updated: 12b84d2ff1...396dd16987
Submodule app/src/stable/assets/sources/twitch updated: 6732a56cd6...8d978dd7bd
Submodule app/src/unstable/assets/sources/kick updated: 12b84d2ff1...396dd16987
Submodule app/src/unstable/assets/sources/twitch updated: 6732a56cd6...8d978dd7bd
Reference in New Issue
Block a user