diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 6090aa56..6c1e134d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -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) { diff --git a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt index 42f452f0..1789cc91 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt @@ -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() } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index fe01051a..bc860479 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -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 } } } diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index b154cb67..5713fac7 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -444,15 +444,9 @@ fun addressScore(addr: InetAddress): Int { fun Enumeration.toList(): List = Collections.list(this) -fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder { - var builder = this +fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder { + return this .downsample(DownsampleStrategy.AT_MOST) .override(maxSizePx, maxSizePx) - builder = if (useCenterCrop) { - builder.centerCrop() - } else { - builder.fitCenter() - } - - return builder + .centerInside() } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt new file mode 100644 index 00000000..63745991 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt index 52659b46..98dc3524 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt @@ -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; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt index 06f1c50c..23f268f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt @@ -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; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt index d8507fab..e8b37364 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt @@ -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; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt index 1560eff1..84d96e02 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt @@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { } DeviceConnectionState.Disconnected -> { - connectionState = CastConnectionState.CONNECTING + connectionState = CastConnectionState.DISCONNECTED onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) } } @@ -268,4 +268,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { companion object { private val TAG = "CastingDeviceExp" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 0404dbeb..5c38f12f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -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 { + 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 { + 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 { val ad = activeDevice ?: return listOf(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index fbca0f6b..9cfb840d 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -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.") } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 541d7c7c..c95238aa 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -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), { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 9176b8ae..b2d4149b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -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 albumContentUrl = if(albumId > 0) - ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() - else null; + val albumArtBase = Uri.parse("content://media/external/audio/albumart") + val albumContentUrl = if (albumId > 0) + 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 { diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt index acffc619..2718f482 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastButton.kt @@ -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); diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 67be4058..7bc7dffe 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout { executeDelete() }, cancelAction = { - }, doNotAskAgainAction = { + }, dismissAction = {}, doNotAskAgainAction = { Settings.instance.other.playlistDeleteConfirmation = false Settings.instance.save() })