Compare commits

...

24 Commits

Author SHA1 Message Date
Kelvin K 09fd4c0881 Fix it asking for background updating when not required 2025-12-03 18:37:06 -06:00
Kelvin K 1667866a35 Hotfix invalid closed state 2025-12-03 18:08:36 -06:00
Kelvin K 035125d0f8 Hotfix invalid closed state 2025-12-03 18:06:38 -06:00
Kelvin K 1bb0cdc405 Add exception handling for background updater 2025-12-03 12:49:08 -06:00
Kelvin K 86019c80a1 Fix in-video login flow 2025-12-03 11:58:00 -06:00
Kelvin K 8c640d3def Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:44:58 -06:00
Kelvin K 7ed1e8a28b NEw install dialog, incognito dont show fix, crash fix old android search library 2025-12-03 11:44:27 -06:00
Koen J 3dcfe8c340 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 18:19:26 +01:00
Koen J 042ced81ef Fix for update when app is fully killed. 2025-12-03 18:18:43 +01:00
Kelvin K b37f48380b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:10:35 -06:00
Kelvin K 0a02169782 Fix PiP for back button 2025-12-03 11:10:22 -06:00
Koen J f12e4390f3 Changed the order of buttons. 2025-12-03 17:48:47 +01:00
Koen J 82ab45d04e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 17:35:06 +01:00
Koen J 7f77c39296 Made notifications for update silent. 2025-12-03 17:33:41 +01:00
Kelvin K 99eee4f6ee Disable misbehaving thumbnail rendering 2025-12-03 10:27:40 -06:00
Kelvin K 68886502d1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 09:58:16 -06:00
Kelvin K 26461c21c4 move to background on back with video 2025-12-03 09:58:08 -06:00
Koen J 300466f722 Update dialogs should nicely be hidden when interacting with notifications. 2025-12-03 16:33:51 +01:00
Koen J 961710cc8b Fixed thumbnails acting up and added support for library content thumbnail when casting. 2025-12-03 15:37:53 +01:00
Koen J eba995f87d Added support for casting local media content. 2025-12-03 14:09:33 +01:00
Koen a67244e79a Merge branch 'marcus/cast-dev-connection-state-fix' into 'master'
casting: set connectionState to correct value when disconnected

See merge request videostreaming/grayjay!160
2025-12-03 11:16:08 +00:00
Marcus Hanestad 70502a7651 casting: set connectionState to correct value when disconnected 2025-12-03 12:12:15 +01:00
Koen J 36b4f5b41d Potential fix for issue where cast icon doesn't properly turn blue at the right moment. 2025-12-03 12:04:54 +01:00
Kelvin K def39ba397 Diff loop icon, allow loop1 in playlist, fix queue clear on opening video on a channel page 2025-12-02 17:11:12 -06:00
33 changed files with 639 additions and 130 deletions
+7
View File
@@ -261,5 +261,12 @@
android:name=".UpdateActionReceiver"
android:exported="false" />
<activity
android:name=".activities.InstallUpdateActivity"
android:exported="false"
android:theme="@style/Theme.App.TransparentNoUi"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
</application>
</manifest>
@@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0;
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0;
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
//@DropdownFieldOptionsId(R.array.background_download)
var shouldBackgroundDownload: Boolean = false;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
@@ -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
@@ -16,11 +17,12 @@ class UpdateActionReceiver : BroadcastReceiver() {
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
@@ -28,31 +30,19 @@ class UpdateActionReceiver : BroadcastReceiver() {
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
} else {
if (StateApp.instance.isMainActive) {
StateApp.withContext { ctx ->
UIDialogs.showUpdateAvailableDialog(ctx, version, false)
}
} else {
val startIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra("SHOW_UPDATE_DIALOG_VERSION", version)
}
context.startActivity(startIntent)
}
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
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()
@@ -70,21 +60,4 @@ class UpdateActionReceiver : BroadcastReceiver() {
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
}
private fun handleInstallNow(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
return
}
UpdateNotificationManager.cancelAll(context)
UpdateInstaller.startInstall(context, apkFile)
}
}
@@ -1,8 +1,10 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
@@ -21,6 +23,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 +220,19 @@ class UpdateDownloadService : Service() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, apkFile)
}, {})
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Cancel", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, apkFile)
}, ActionStyle.PRIMARY, true));
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
}
@@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.provider.Settings
import android.view.View
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.InstallReceiver
import kotlinx.coroutines.Dispatchers
@@ -17,6 +19,8 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@@ -53,8 +57,8 @@ object UpdateInstaller {
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
@@ -72,6 +76,10 @@ object UpdateInstaller {
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender
InstallReceiver.onReceiveResult.subscribe(this) { message ->
InstallReceiver.onReceiveResult.clear();
onReceiveResult(context, message);
};
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver)
} catch (e: Throwable) {
@@ -86,4 +94,11 @@ object UpdateInstaller {
}
}
}
private fun onReceiveResult(context: Context, result: String?) {
InstallReceiver.onReceiveResult.remove(this);
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n" + result);
}
}
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
@@ -13,6 +14,7 @@ import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.InstallUpdateActivity
import java.io.File
object UpdateNotificationManager {
@@ -25,6 +27,7 @@ object UpdateNotificationManager {
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
@@ -36,12 +39,17 @@ object UpdateNotificationManager {
fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
channel.description = CHANNEL_DESCRIPTION
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
description = CHANNEL_DESCRIPTION
enableVibration(false)
enableLights(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
@@ -70,9 +78,10 @@ object UpdateNotificationManager {
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.addAction(0, "Download", yesPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
@@ -97,6 +106,7 @@ object UpdateNotificationManager {
.setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent)
if (indeterminate) {
@@ -123,17 +133,8 @@ object UpdateNotificationManager {
}
ensureChannel(context)
val installIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_INSTALL_NOW
putExtra(EXTRA_VERSION, version)
putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
}
val installPendingIntent = getBroadcast(
context,
4,
installIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
@@ -141,6 +142,7 @@ object UpdateNotificationManager {
.setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Install", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
@@ -159,6 +161,7 @@ object UpdateNotificationManager {
.setContentText(error?.message ?: "Unknown error")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
@@ -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
.downsample(DownsampleStrategy.AT_MOST)
.override(maxSizePx, maxSizePx)
builder = if (useCenterCrop) {
builder.centerCrop()
} else {
builder.fitCenter()
}
return builder
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
return this;
//.downsample(DownsampleStrategy.AT_MOST)
//.override(maxSizePx, maxSizePx)
//.centerInside()
}
@@ -0,0 +1,47 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import java.io.File
class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
finish()
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
UIDialogs.Companion.toast(this, "Update file missing")
finish()
return
}
UpdateInstaller.startInstall(this, apkFile)
finish()
}
companion object {
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
Intent(context, InstallUpdateActivity::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
@@ -618,8 +618,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
@@ -1299,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(last.first, last.second, false, true);
} else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
finish();
} else {
//UIDialogs.toast("Grayjay continues in background because of an open video.")
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
try {
_fragVideoDetail._viewDetail?.startPictureInPicture();
_fragVideoDetail?.forcePictureInPicture();
} catch (ex: Throwable) {
} //Fail silently
}
else
moveTaskToBack(false);
/*
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish();
})
*/
}
}
}
@@ -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")
}
}
}
@@ -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;
}
}
@@ -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"
}
}
}
@@ -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;
@@ -81,7 +83,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
return@setOnClickListener;
}
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
@@ -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.")
}
@@ -155,15 +155,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
StateApp.instance.setPrivacyMode(true);
UIDialogs.appToast("Privacy mode enabled");
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
UIDialogs.Action("Don't show again", {
Settings.instance.other.showPrivacyModeDialog = false;
Settings.instance.save();
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Understood", {
if(Settings.instance.other.showPrivacyModeDialog)
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
UIDialogs.Action("Don't show again", {
Settings.instance.other.showPrivacyModeDialog = false;
Settings.instance.save();
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}, UIDialogs.ActionStyle.PRIMARY));
}
}
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
@@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.withTimestamp
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
adapter.onContentClicked.subscribe { v, _ ->
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
//StatePlayer.instance.clearQueue()
if (StatePlayer.instance.hasQueue) {
StatePlayer.instance.insertToQueue(v, true);
} else {
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
}
}
is IPlatformPlaylist -> {
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
adapter.onContentUrlClicked.subscribe { url, contentType ->
when (contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue()
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
@@ -403,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),
{
@@ -55,7 +55,7 @@ class LoginFragment : MainFragment() {
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
if(_callback != null) _callback?.invoke(null);
_callback = callback;
StateApp.instance.activity?.navigate<LoginFragment>(config, false);
StateApp.instance.activity?.navigate<LoginFragment>(config, true);
}
}
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
private var _isActive: Boolean = false;
private var _viewDetail : VideoDetailView? = null;
var _viewDetail : VideoDetailView? = null;
private var _view : SingleViewTouchableMotionLayout? = null;
var isFullscreen : Boolean = false;
@@ -372,14 +372,18 @@ class VideoDetailFragment() : MainFragment() {
onMinimize.emit();
}
else if (state != State.MAXIMIZED && progress > 0.9) {
state = State.MAXIMIZED;
onMaximized.emit();
/*
if (_isInitialMaximize) {
state = State.CLOSED;
//state = State.CLOSED; Causes issues? might no longer be needed
_isInitialMaximize = false;
}
else {
state = State.MAXIMIZED;
onMaximized.emit();
}
*/
}
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
@@ -450,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false
}
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode")
@@ -1,5 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.topbar
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
} else if (currentMain is LibraryFragment) {
navigate<LibrarySearchFragment>();
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
}
else
navigate<LibrarySearchFragment>();
} else {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
}
@@ -573,7 +573,7 @@ class StateApp {
}
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.backgroundDownload == 1) {
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
@@ -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<IPlatformVideo> {
@@ -169,6 +169,9 @@ class StatePlugins {
return false;
LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) {
if(it == null)
return@showLogin;
try {
StatePlugins.instance.setPluginAuth(config.id, it);
} catch (e: Throwable) {
@@ -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()
})
@@ -489,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
StatePlayer.instance.onQueueChanged.subscribe(this) {
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
setLoopVisible(!StatePlayer.instance.hasQueue)
//setLoopVisible(!StatePlayer.instance.hasQueue)
updateNextPrevious();
}
}
@@ -886,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
fun updateLoopVideoUI() {
if(StatePlayer.instance.loopVideo) {
_control_loop.setImageResource(R.drawable.ic_loop_active);
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active);
_control_loop.setImageResource(R.drawable.ic_repeat_one_active);
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active);
}
else {
_control_loop.setImageResource(R.drawable.ic_loop);
_control_loop_fullscreen.setImageResource(R.drawable.ic_loop);
_control_loop.setImageResource(R.drawable.ic_repeat_one);
_control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one);
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M472.31,587.69L472.31,407.69L424.62,407.69L424.62,372.31L507.69,372.31L507.69,587.69L472.31,587.69ZM292.31,840L160,707.69L292.31,575.38L320.62,604.15L237.08,687.69L692.31,687.69L692.31,527.69L732.31,527.69L732.31,727.69L237.08,727.69L320.62,811.23L292.31,840ZM227.69,432.31L227.69,232.31L722.92,232.31L639.38,148.77L667.69,120L800,252.31L667.69,384.62L639.38,355.85L722.92,272.31L267.69,272.31L267.69,432.31L227.69,432.31Z"/>
</vector>
+1 -1
View File
@@ -65,7 +65,7 @@
android:scaleType="fitCenter"
android:clickable="true"
android:padding="12dp"
app:srcCompat="@drawable/ic_loop" />
app:srcCompat="@drawable/ic_repeat_one" />
<ImageButton
android:id="@+id/button_settings"
android:layout_width="50dp"
@@ -93,7 +93,7 @@
android:scaleType="fitCenter"
android:clickable="true"
android:padding="12dp"
app:srcCompat="@drawable/ic_loop" />
app:srcCompat="@drawable/ic_repeat_one" />
<ImageButton
android:id="@+id/button_settings"
android:layout_width="50dp"
+10
View File
@@ -116,4 +116,14 @@
<item name="android:fontFamily">@font/inter_regular</item>
</style>
<style name="Theme.App.TransparentNoUi" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
</style>
</resources>