mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay
This commit is contained in:
@@ -370,17 +370,19 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||
setOnDismissListener { dismissAction?.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.io.File
|
||||
|
||||
@@ -21,6 +22,8 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
if (version == 0) {
|
||||
return
|
||||
@@ -49,10 +52,12 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
private fun handleUpdateNo(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
}
|
||||
|
||||
private fun handleUpdateNever(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
Settings.instance.autoUpdate.check = 1
|
||||
Settings.instance.save()
|
||||
|
||||
@@ -86,5 +91,6 @@ class UpdateActionReceiver : BroadcastReceiver() {
|
||||
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
UpdateInstaller.startInstall(context, apkFile)
|
||||
UpdateDownloadService.updateDownloadedDialog?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Dialog
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
@@ -21,6 +22,8 @@ class UpdateDownloadService : Service() {
|
||||
private const val MAX_RETRIES = 5
|
||||
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||
private const val BUFFER_SIZE = 8 * 1024
|
||||
|
||||
var updateDownloadedDialog: Dialog? = null
|
||||
}
|
||||
|
||||
private val job = SupervisorJob()
|
||||
@@ -216,12 +219,13 @@ class UpdateDownloadService : Service() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
StateApp.withContext { ctx ->
|
||||
try {
|
||||
UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
|
||||
updateDownloadedDialog = UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, apkFile)
|
||||
}, {})
|
||||
}, dismissAction = { updateDownloadedDialog = null })
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
updateDownloadedDialog = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,15 +444,9 @@ fun addressScore(addr: InetAddress): Int {
|
||||
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||
|
||||
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder<T> {
|
||||
var builder = this
|
||||
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||
return this
|
||||
.downsample(DownsampleStrategy.AT_MOST)
|
||||
.override(maxSizePx, maxSizePx)
|
||||
builder = if (useCenterCrop) {
|
||||
builder.centerCrop()
|
||||
} else {
|
||||
builder.fitCenter()
|
||||
}
|
||||
|
||||
return builder
|
||||
.centerInside()
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
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
|
||||
|
||||
resolver.query(uri, null, 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 (displayName == null) {
|
||||
displayName = uri.lastPathSegment
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -235,9 +239,9 @@ abstract class StateCasting {
|
||||
Logger.i(TAG, "Connect to device ${device.name}")
|
||||
}
|
||||
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||
return Metadata(
|
||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
||||
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,65 @@ 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;
|
||||
val thumbnailPath = "/thumbnail-${id}"
|
||||
val thumbnailUrl = url + thumbnailPath;
|
||||
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||
|
||||
if (thumbnailContentUrl != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
_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, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||
|
||||
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;
|
||||
val thumbnailPath = "/thumbnail-${id}"
|
||||
val thumbnailUrl = url + thumbnailPath;
|
||||
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||
|
||||
if (thumbnailContentUrl != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
_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, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||
|
||||
return listOf(audioUrl);
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import java.io.InputStream
|
||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
companion object {
|
||||
private val TAG = "AutoUpdateDialog";
|
||||
|
||||
var currentDialog: AutoUpdateDialog? = null
|
||||
}
|
||||
|
||||
private lateinit var _buttonNever: Button;
|
||||
@@ -94,11 +96,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
};
|
||||
|
||||
currentDialog = this
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
InstallReceiver.onReceiveResult.clear();
|
||||
currentDialog = null
|
||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -409,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||
UIDialogs.showConfirmationDialog(context,
|
||||
val dialog = UIDialogs.showConfirmationDialog(context,
|
||||
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||
.replace("{channelName}", channel.name),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -487,9 +488,10 @@ class StateLibrary {
|
||||
"";
|
||||
|
||||
|
||||
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||
val albumContentUrl = if (albumId > 0)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
|
||||
else null;
|
||||
ContentUris.withAppendedId(albumArtBase, albumId).toString()
|
||||
else null
|
||||
|
||||
val dateObj = if(date > 0)
|
||||
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
|
||||
@@ -515,6 +517,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 +538,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;
|
||||
@@ -622,11 +626,12 @@ class Artist {
|
||||
val numTracks = cursor.getInt(2);
|
||||
val numAlbums = cursor.getInt(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
val idLong = id.toLongOrNull()
|
||||
val uri = if (idLong != null)
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong)
|
||||
else null
|
||||
|
||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
|
||||
}
|
||||
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) }
|
||||
|
||||
fun getArtist(id: Long): Artist? {
|
||||
val resolver = StateApp.instance.contextOrNull?.contentResolver;
|
||||
@@ -730,9 +735,10 @@ class Album {
|
||||
val numTracks = cursor.getInt(2);
|
||||
val artist = cursor.getString(3);
|
||||
|
||||
val idLong = id.toLongOrNull();
|
||||
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
|
||||
return Album(album, numTracks, artist, id, uri?.toString());
|
||||
val idLong = id.toLongOrNull()
|
||||
val albumArtBase = Uri.parse("content://media/external/audio/albumart")
|
||||
val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null
|
||||
return Album(album, numTracks, artist, id, uri?.toString())
|
||||
}
|
||||
|
||||
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
|
||||
@@ -22,18 +23,16 @@ class CastButton : androidx.appcompat.widget.AppCompatImageButton {
|
||||
visibility = View.GONE;
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
|
||||
updateCastState();
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { d, _ ->
|
||||
updateCastState(d);
|
||||
};
|
||||
|
||||
updateCastState();
|
||||
updateCastState(StateCasting.instance.activeDevice);
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCastState() {
|
||||
private fun updateCastState(d: CastingDevice?) {
|
||||
val c = context ?: return;
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
|
||||
val activeColor = ContextCompat.getColor(c, R.color.colorPrimary);
|
||||
val connectingColor = ContextCompat.getColor(c, R.color.gray_c3);
|
||||
val inactiveColor = ContextCompat.getColor(c, R.color.white);
|
||||
|
||||
@@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout {
|
||||
executeDelete()
|
||||
}, cancelAction = {
|
||||
|
||||
}, doNotAskAgainAction = {
|
||||
}, dismissAction = {}, doNotAskAgainAction = {
|
||||
Settings.instance.other.playlistDeleteConfirmation = false
|
||||
Settings.instance.save()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user