Added support for casting local media content.

This commit is contained in:
Koen J
2025-12-03 14:09:33 +01:00
parent a67244e79a
commit eba995f87d
6 changed files with 377 additions and 8 deletions
@@ -0,0 +1,317 @@
package com.futo.platformplayer.api.http.server.handlers
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.*
class HttpContentUriHandler(
method: String,
path: String,
private val contentResolver: ContentResolver,
private val uri: Uri,
private val explicitContentType: String? = null
) : HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val resolver = contentResolver
val requestHeaders = httpContext.headers
val responseHeaders = this.headers.clone()
val meta = try {
queryMetadata(resolver, uri)
} catch (e: Exception) {
Logger.e(TAG, "Failed to query metadata for $uri", e)
httpContext.respondCode(404, responseHeaders)
return
}
val contentType = explicitContentType
?: resolver.getType(uri)
?: "application/octet-stream"
responseHeaders["Content-Type"] = contentType
meta.lastModifiedMillis?.let { lastModified ->
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
if (ifModifiedSinceHeader != null) {
val ifModifiedSince = try {
httpDateFormat.parse(ifModifiedSinceHeader)
} catch (_: Exception) {
null
}
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
httpContext.respondCode(304, responseHeaders)
return
}
}
}
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
val length = meta.size
if (length == null) {
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
responseHeaders.remove("Content-Length")
responseHeaders.remove("Content-Range")
responseHeaders.remove("Accept-Ranges")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = null,
length = null
)
return
}
responseHeaders["Accept-Ranges"] = "bytes"
val rangeHeader = requestHeaders["Range"]
if (rangeHeader.isNullOrBlank()) {
responseHeaders["Content-Length"] = length.toString()
Logger.i(TAG, "Sending full content for $uri, length=$length")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = 0L,
length = length
)
return
}
val range = parseRange(rangeHeader, length)
if (range == null) {
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
responseHeaders["Content-Range"] = "bytes */$length"
httpContext.respondCode(416, responseHeaders)
return
}
val start = range.first
val endInclusive = range.last
val bytesToSend = endInclusive - start + 1
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
responseHeaders["Content-Length"] = bytesToSend.toString()
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 206,
headers = responseHeaders,
start = start,
length = bytesToSend
)
}
data class ContentMeta(
val displayName: String?,
val size: Long?,
val lastModifiedMillis: Long?
)
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
var displayName: String? = null
var size: Long? = null
var lastModifiedMillis: Long? = null
val projection = arrayOf(
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DATE_ADDED
)
resolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
displayName = cursor.getString(nameIndex)
}
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
val s = cursor.getLong(sizeIndex)
if (s >= 0) size = s // -1 means unknown
}
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
val seconds = cursor.getLong(dateModifiedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
if (lastModifiedMillis == null) {
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
val seconds = cursor.getLong(dateAddedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
}
}
}
if (size == null) {
try {
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
val assetLen = afd.length
if (assetLen >= 0) {
size = assetLen
}
}
} catch (_: Exception) { }
}
return ContentMeta(displayName = displayName, size = size, lastModifiedMillis = lastModifiedMillis)
}
private fun parseRange(header: String, totalLength: Long): LongRange? {
if (totalLength <= 0L) return null
val prefix = "bytes="
if (!header.startsWith(prefix, ignoreCase = true)) return null
val spec = header.substring(prefix.length).trim()
if (spec.isEmpty()) return null
if (spec.contains(",")) return null
val dashIndex = spec.indexOf('-')
if (dashIndex < 0) return null
val startPart = spec.substring(0, dashIndex).trim()
val endPart = spec.substring(dashIndex + 1).trim()
return when {
startPart.isNotEmpty() -> {
val start = startPart.toLongOrNull() ?: return null
if (start < 0 || start >= totalLength) return null
val end = if (endPart.isNotEmpty()) {
val rawEnd = endPart.toLongOrNull() ?: return null
if (rawEnd < start) return null
rawEnd.coerceAtMost(totalLength - 1)
} else {
totalLength - 1
}
start..end
}
endPart.isNotEmpty() -> {
val suffixLen = endPart.toLongOrNull() ?: return null
if (suffixLen <= 0L) return null
if (suffixLen >= totalLength) {
0L..(totalLength - 1)
} else {
val start = totalLength - suffixLen
val end = totalLength - 1
start..end
}
}
else -> null
}
}
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
try {
val input = resolver.openInputStream(uri)
if (input == null) {
Logger.w(TAG, "Content not found: $uri")
httpContext.respondCode(404, headers)
return
}
input.use { inputStream ->
httpContext.respond(statusCode, headers) { outputStream ->
try {
val offset = start ?: 0L
if (offset > 0L) {
skipFully(inputStream, offset)
}
copyStream(inputStream, outputStream, length)
outputStream.flush()
} catch (e: Exception) {
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
}
}
}
} catch (e: FileNotFoundException) {
Logger.w(TAG, "Content not found: $uri", e)
httpContext.respondCode(404, headers)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open stream for $uri", e)
httpContext.respondCode(500, headers)
}
}
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
val buffer = ByteArray(8192)
if (limit == null) {
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
} else {
var remaining = limit
while (remaining > 0L) {
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
val read = input.read(buffer, 0, toRead)
if (read < 0) break
output.write(buffer, 0, read)
remaining -= read.toLong()
}
}
}
private fun skipFully(input: InputStream, bytesToSkip: Long) {
var remaining = bytesToSkip
while (remaining > 0L) {
val skipped = input.skip(remaining)
if (skipped <= 0L) {
val b = input.read()
if (b == -1) break
remaining -= 1L
} else {
remaining -= skipped
}
}
}
companion object {
private const val TAG = "HttpContentUriHandler"
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("GMT")
}
}
}
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor(
arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
))
else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name)
LocalVideoContentSource(url, mimeType ?: "", name, duration)
))
);
override val preview: ISerializedVideoSourceDescriptor? = null;
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File";
container = mime;
duration = 0;
this.duration = duration;
this.contentUrl = contentUrl;
}
@@ -22,12 +22,12 @@ class LocalVideoContentSource: IVideoSource {
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File";
width = 0;
height = 0;
container = mime;
duration = 0;
this.duration = duration;
this.contentUrl = contentUrl;
}
}
@@ -6,6 +6,7 @@ import android.content.Context
import android.os.Looper
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
@@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
@@ -34,6 +36,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.models.CastingDeviceInfo
@@ -371,6 +375,12 @@ abstract class StateCasting {
} else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition, speed);
} else if (videoSource is LocalVideoContentSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioContentSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
} else if (videoSource is JSDashManifestRawSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
@@ -461,6 +471,45 @@ abstract class StateCasting {
}
return true;
}
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(videoUrl);
}
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath;
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
return listOf(audioUrl);
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
@@ -344,7 +344,8 @@ class StateLibrary {
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
MediaStore.Video.Media.DURATION
);
val PROJECTION_MEDIA = arrayOf(
MediaStore.Audio.Media._ID, //0
@@ -515,6 +516,8 @@ class StateLibrary {
val date = cursor.getLong(2);
val contentType = cursor.getString(3);
val category = cursor.getString(4);
val durationMs = cursor.getLong(5)
val duration = if (durationMs > 0) durationMs / 1000 else -1
val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null )
@@ -534,7 +537,7 @@ class StateLibrary {
PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf(
Thumbnail(contentUrl, 0)
)), authorObj, contentUrl, -1, contentType, dateObj);
)), authorObj, contentUrl, duration, contentType, dateObj);
}
private var _instance : StateLibrary? = null;