Compare commits

...

30 Commits

Author SHA1 Message Date
Koen J 60cd5976cc Updated ExoPlayer. 2025-12-31 13:11:16 +01:00
Koen 3ca6a1fd70 Merge branch 'marcus/remove-legacy-casting' into 'master'
casting: remove legacy backend

See merge request videostreaming/grayjay!162
2025-12-26 08:52:13 +00:00
Marcus Hanestad 0d8c8de450 casting: remove legacy backend 2025-12-25 23:04:10 +01:00
Koen J 8ba2fe9972 getOrNull should be used for original everywhere. 2025-12-23 15:52:15 +01:00
koen-futo 7a7ef533cc Merge pull request #2336 from realchrisolin/master
update configChanges so bluetooth keyboards don't recreate activity
2025-12-22 14:28:18 +01:00
Koen 5385549a43 Merge branch 'b23tv-intent-filter' into 'master'
Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml

See merge request videostreaming/grayjay!161
2025-12-20 14:06:36 +00:00
Stefan 04deffc66e Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml
related with https://github.com/futo-org/grayjay-android/issues/2537
2025-12-20 12:08:52 +00:00
Koen J 852f563c9a Renamed subtitles-1 2025-12-18 15:23:16 +01:00
Koen J c84cea9ea1 Remove animation for quality selector. 2025-12-18 14:37:44 +01:00
Koen J 5c162083d5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-18 08:23:26 +01:00
Koen J 3230e7c0b4 Draft fix for cast subtitles UMP. 2025-12-18 08:23:13 +01:00
Kelvin 8437825dd1 apply language filters to downloads 2025-12-17 20:29:45 +01:00
Kelvin 0fbe0bb438 Add filters for video languages to resolve excessive sources 2025-12-17 19:43:56 +01:00
Kelvin 34d2e62314 sub mods 2025-12-17 16:27:12 +01:00
Kelvin 1075ded170 Language for video support, original for video support, deduplication fix for languages on videos, submods 2025-12-17 15:32:37 +01:00
Koen J 80bb15f3fb Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-15 10:03:34 +01:00
Koen J 27a86a67f0 Updated submodules and fixed casting for combined request executor. 2025-12-15 10:03:18 +01:00
Koen 284b2a24f8 Merge branch 'marcus/casting-sdk-updates' into 'master'
casting: subscribe to and handle MediaItemEnd events

See merge request videostreaming/grayjay!158
2025-12-15 09:01:31 +00:00
Kelvin K 854d1506a6 Compile fix 2025-12-11 17:17:42 -06:00
Kelvin K 811fd4e73e Improved dl 2025-12-11 17:16:31 -06:00
Kelvin K 335988aa67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-11 14:16:07 -06:00
Kelvin K 29a54fbed4 Download support combined 2025-12-11 14:15:55 -06:00
Koen J 3a11d0d9d1 Fixed HLS downloading for Twitch, DialyMotion, Nebula. 2025-12-05 15:31:31 +01:00
Koen J bda534e485 Various updates to bg update flow:
- Throttled progress updates in notifications resolving the notifications not showing under some conditions.
- Properly cancel notifications when interacting with in-app dialogs.
- Added install failed notification.
- Added install success notification.
- Added default behavior for tapping on notifications.
- Fixed crash in install receiver.
2025-12-04 11:18:00 +01:00
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
Marcus Hanestad 894e400819 casting: subscribe to and handle MediaItemEnd events 2025-11-27 16:56:43 +01:00
Chris Olin 09bc180d4f update configChanges so bluetooth keyboards don't recreate activity 2025-06-10 13:25:45 -04:00
73 changed files with 1297 additions and 3133 deletions
+8 -8
View File
@@ -184,13 +184,13 @@ dependencies {
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.8.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
implementation 'androidx.media3:media3-ui:1.8.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
implementation 'androidx.media3:media3-transformer:1.8.0'
implementation 'androidx.media3:media3-exoplayer:1.9.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
implementation 'androidx.media3:media3-ui:1.9.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
implementation 'androidx.media3:media3-transformer:1.9.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.1'
@@ -232,7 +232,7 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
+1 -1
View File
@@ -60,7 +60,7 @@
<activity
android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
+7
View File
@@ -415,6 +415,8 @@ class VideoUrlSource {
this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class VideoUrlWidevineSource extends VideoUrlSource {
@@ -512,6 +514,8 @@ class HLSSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashSource {
@@ -525,6 +529,8 @@ class DashSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashWidevineSource extends DashSource {
@@ -550,6 +556,7 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.original = obj?.original;
}
}
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context): String? {
fun getPrimaryLanguage(context: Context? = null): String? {
return when(primaryLanguage) {
0 -> "en";
1 -> "es";
@@ -725,11 +725,6 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false;
@AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = true
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -875,9 +870,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)
@@ -5,6 +5,7 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import kotlin.collections.toList
class UISlideOverlays {
companion object {
@@ -573,6 +576,51 @@ class UISlideOverlays {
return null;
}
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
if(languageFilters != null) items.add(languageFilters)
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context,
@@ -609,7 +657,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
}
is JSDashManifestRawSource -> {
@@ -629,7 +683,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
}
is IHLSManifestSource -> {
@@ -643,7 +703,13 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container)
},
invokeParent = false
)
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
}
else -> {
@@ -4,6 +4,7 @@ import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
@@ -23,6 +24,7 @@ class UpdateDownloadService : Service() {
private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
@@ -36,6 +38,8 @@ class UpdateDownloadService : Service() {
@Volatile
private var cancelRequested: Boolean = false
private var lastProgressUpdateElapsedMs: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -81,6 +85,16 @@ class UpdateDownloadService : Service() {
job.cancel()
}
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
}
}
private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version)
@@ -190,12 +204,18 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100
else -> progress
}
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false)
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
}
} else {
UpdateNotificationManager.updateDownloadProgress(this, version, 0, true)
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
}
}
if (!cancelRequested && totalBytes > 0L) {
val finalProgress = 100
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
}
output.flush()
}
}
@@ -223,12 +243,12 @@ class UpdateDownloadService : Service() {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Cancel", {
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, apkFile)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
@@ -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,20 +19,24 @@ 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"
@SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, apkFile: File) {
fun startInstall(context: Context, version: Int, apkFile: File) {
if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
return
}
if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
return
}
@@ -38,6 +44,7 @@ object UpdateInstaller {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
@@ -53,8 +60,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)
@@ -68,10 +75,17 @@ object UpdateInstaller {
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java)
val intent = Intent(context, InstallReceiver::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
}
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, version, apkFile, message);
};
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver)
} catch (e: Throwable) {
@@ -80,10 +94,29 @@ object UpdateInstaller {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally {
session?.close()
inputStream?.close()
}
}
}
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
try {
InstallReceiver.onReceiveResult.remove(this)
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
}
}
}
@@ -35,6 +35,8 @@ object UpdateNotificationManager {
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -49,6 +51,38 @@ object UpdateNotificationManager {
}
}
fun showInstallSucceededNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val launchIntent = context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
val launchPendingIntent = launchIntent?.let {
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update installed")
.setContentText("Version $version installed. Tap to open.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
if (launchPendingIntent != null) {
builder.setContentIntent(launchPendingIntent)
builder.addAction(0, "Open app", launchPendingIntent)
}
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
@@ -78,6 +112,7 @@ object UpdateNotificationManager {
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
@@ -104,7 +139,7 @@ object UpdateNotificationManager {
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update")
.setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent)
@@ -141,6 +176,7 @@ object UpdateNotificationManager {
.setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(installPendingIntent)
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Install", installPendingIntent)
@@ -166,9 +202,32 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
return
ensureChannel(context)
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)
.setContentTitle("Failed to install update")
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
.setAutoCancel(true)
.setSilent(true)
.setContentIntent(installPendingIntent)
.addAction(0, "Install again", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -15,6 +15,8 @@ class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UpdateNotificationManager.cancelAll(this)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
@@ -32,7 +34,7 @@ class InstallUpdateActivity : AppCompatActivity() {
return
}
UpdateInstaller.startInstall(this, apkFile)
UpdateInstaller.startInstall(this, version, apkFile)
finish()
}
@@ -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,6 +1299,7 @@ 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.")
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl(): String {
return url
}
@@ -9,4 +9,6 @@ interface IVideoSource {
val bitrate : Int?;
val duration: Long;
val priority: Boolean;
val language: String?;
val original: Boolean?;
}
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
val filePath : String;
val fileSize : Long;
@@ -19,6 +19,9 @@ open class VideoUrlSource(
) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null;
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl() : String {
return url;
}
@@ -54,7 +54,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
original = obj.getOrNull(config, "original", contextName) ?: false;
hasGenerate = _obj.has("generate");
}
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
private val ctx = "DashRawSource"
private val cfg = plugin.config
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override val container: String =
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
@@ -185,6 +189,9 @@ class JSDashManifestMergingRawSource(
override val priority: Boolean
get() = video.priority;
override val language: String? get() = audio.language
override val original: Boolean? get() = audio.original;
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
val videoDashDef = video.generateAsync(scope);
val audioDashDef = audio.generateAsync(scope);
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashSource";
val config = plugin.config;
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
duration = _obj.getOrThrow(config, "duration", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = obj.getOrNull(config, "language", contextName);
original = obj.getOrNull(config, "original", contextName);
}
override fun getVideoUrl(): String {
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
override val language: String?;
override val original: Boolean?;
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource"
@@ -40,6 +43,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
original = obj.getOrNull(config, "original", contextName) ?: false;
}
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSSource";
val config = plugin.config;
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
}
@@ -44,6 +44,9 @@ open class JSVideoUrlSource(
override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override fun getVideoUrl(): String = url
override fun toString(): String =
@@ -20,6 +20,9 @@ class LocalVideoContentSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = null;
var file: File;
constructor(file: File) {
@@ -1,330 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.UUID
class AirPlayCastingDevice : CastingDeviceLegacy {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private val _client = ManagedHttpClient();
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
if (resumePosition > 0.0) {
val pos = resumePosition / duration;
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
} else {
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
}
if (speed != null) {
changeSpeed(speed)
}
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
throw NotImplementedError();
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
post("scrub?position=${timeSeconds}");
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
isPlaying = true;
post("rate?value=1.000000");
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
isPlaying = false;
post("rate?value=0.000000");
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
post("stop");
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
post("stop");
stop();
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
_started = true;
_scopeIO?.cancel();
_scopeIO = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting...");
_scopeIO?.launch {
try {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(1000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
_sessionId = UUID.randomUUID().toString();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
}
}
while (_scopeIO?.isActive == true) {
try {
val progressInfo = getProgress();
if (progressInfo == null) {
connectionState = CastConnectionState.CONNECTING;
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
delay(1000);
continue;
}
connectionState = CastConnectionState.CONNECTED;
val progressIndex = progressInfo.lowercase().indexOf("position: ");
if (progressIndex == -1) {
delay(1000);
continue;
}
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
setTime(progress);
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
if (durationIndex == -1) {
delay(1000);
continue;
}
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
setDuration(duration);
delay(1000);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
}
};
Logger.i(TAG, "Started.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
connectionState = CastConnectionState.DISCONNECTED;
usedRemoteAddress = null;
localAddress = null;
_started = false;
_scopeIO?.cancel();
_scopeIO = null;
}
override fun changeSpeed(speed: Double) {
setSpeed(speed)
post("rate?value=$speed")
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
private fun getProgress(): String? {
val info = get("scrub");
Logger.i(TAG, "Progress: ${info ?: "null"}");
return info;
}
private fun getPlaybackInfo(): String? {
val playbackInfo = get("playback-info");
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
return playbackInfo;
}
private fun getServerInfo(): String? {
val serverInfo = get("server-info");
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
return serverInfo;
}
private fun post(path: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"Content-Length" to "0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url");
val response = _client.post(url, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path");
return false;
}
}
private fun post(path: String, contentType: String, body: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId,
"Content-Type" to contentType
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url:\n$body");
val response = _client.post(url, body, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path $body");
return false;
}
}
private fun get(path: String): String? {
val sessionId = _sessionId ?: return null;
try {
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"Content-Length" to "0",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "GET $url");
val response = _client.get(url, headers);
if (!response.isOk) {
return null;
}
if (response.body == null) {
return null;
}
return response.body.string();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to GET $path");
return null;
}
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
companion object {
val TAG = "AirPlayCastingDevice";
}
}
@@ -1,60 +1,289 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import java.net.InetAddress
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
abstract class CastingDevice {
abstract val isReady: Boolean
abstract val usedRemoteAddress: InetAddress?
abstract val localAddress: InetAddress?
abstract val name: String?
abstract val onConnectionStateChanged: Event1<CastConnectionState>
abstract val onPlayChanged: Event1<Boolean>
abstract val onTimeChanged: Event1<Double>
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
abstract val expectedCurrentTime: Double
abstract var speed: Double
abstract var time: Double
abstract var duration: Double
abstract var volume: Double
abstract fun canSetVolume(): Boolean
abstract fun canSetSpeed(): Boolean
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Throws
abstract fun resumePlayback()
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
@Throws
abstract fun pausePlayback()
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
@Throws
abstract fun stopPlayback()
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
@Throws
abstract fun seekTo(timeSeconds: Double)
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
@Throws
abstract fun changeVolume(timeSeconds: Double)
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
@Throws
abstract fun changeSpeed(speed: Double)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
@Throws
abstract fun connect()
// abstract class CastingDevice {
class CastingDevice(val device: RsCastingDevice) {
// abstract val isReady: Boolean
// abstract val usedRemoteAddress: InetAddress?
// abstract val localAddress: InetAddress?
// abstract val name: String?
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
// abstract val onPlayChanged: Event1<Boolean>
// abstract val onTimeChanged: Event1<Double>
// abstract val onDurationChanged: Event1<Double>
// abstract val onVolumeChanged: Event1<Double>
// abstract val onSpeedChanged: Event1<Double>
// abstract val onMediaItemEnd: Event0
// abstract var connectionState: CastConnectionState
// abstract val protocolType: CastProtocolType
// abstract var isPlaying: Boolean
// abstract val expectedCurrentTime: Double
// abstract var speed: Double
// abstract var time: Double
// abstract var duration: Double
// abstract var volume: Double
// abstract fun canSetVolume(): Boolean
// abstract fun canSetSpeed(): Boolean
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
// @Throws
// abstract fun resumePlayback()
@Throws
abstract fun loadVideo(
// @Throws
// abstract fun pausePlayback()
// @Throws
// abstract fun stopPlayback()
// @Throws
// abstract fun seekTo(timeSeconds: Double)
// @Throws
// abstract fun changeVolume(timeSeconds: Double)
// @Throws
// abstract fun changeSpeed(speed: Double)
// @Throws
// abstract fun connect()
// @Throws
// abstract fun disconnect()
// abstract fun getDeviceInfo(): CastingDeviceInfo
// abstract fun getAddresses(): List<InetAddress>
// @Throws
// abstract fun loadVideo(
// streamType: String,
// contentType: String,
// contentId: String,
// resumePosition: Double,
// duration: Double,
// speed: Double?,
// metadata: Metadata?
// )
// @Throws
// fun loadContent(
// contentType: String,
// content: String,
// resumePosition: Double,
// duration: Double,
// speed: Double?,
// metadata: Metadata?
// )
// fun ensureThreadStarted()
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
val isReady: Boolean
get() = device.isReady()
val name: String
get() = device.name()
var usedRemoteAddress: InetAddress? = null
var localAddress: InetAddress? = null
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
val onConnectionStateChanged =
Event1<CastConnectionState>()
val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
fun resumePlayback() = device.resumePlayback()
fun pausePlayback() = device.pausePlayback()
fun stopPlayback() = device.stopPlayback()
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
fun disconnect() = device.disconnect()
fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
@@ -62,18 +291,107 @@ abstract class CastingDevice {
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
@Throws
abstract fun loadContent(
fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
abstract fun ensureThreadStarted()
}
var connectionState = CastConnectionState.DISCONNECTED
val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
var volume: Double = 1.0
var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
var speed: Double = 0.0
var isPlaying: Boolean = false
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -1,271 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: GenericKeyEvent) {
// Unreachable
}
override fun mediaEvent(event: GenericMediaEvent) {
// Unreachable
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -1,242 +0,0 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDeviceLegacy {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}
@@ -1,736 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDeviceLegacy {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _streamType: String? = null;
private var _contentType: String? = null;
private var _contentId: String? = null;
private var _socket: SSLSocket? = null;
private var _outputStream: DataOutputStream? = null;
private var _outputStreamLock = Object();
private var _inputStream: DataInputStream? = null;
private var _inputStreamLock = Object();
private var _scopeIO: CoroutineScope? = null;
private var _requestId = 1;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private var _transportId: String? = null;
private var _launching = false;
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
private var _autoLaunchEnabled = true
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
_streamType = streamType;
_contentType = contentType;
_contentId = contentId;
playVideo();
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
//TODO: Can maybe be implemented by sending data:contentType,base64...
throw NotImplementedError();
}
private fun connectMediaChannel(transportId: String) {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
}
private fun requestMediaStatus() {
val transportId = _transportId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "GET_STATUS");
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun playVideo() {
val transportId = _transportId ?: return;
val contentId = _contentId ?: return;
val streamType = _streamType ?: return;
val contentType = _contentType ?: return;
val loadObject = JSONObject();
loadObject.put("type", "LOAD");
val mediaObject = JSONObject();
mediaObject.put("contentId", contentId);
mediaObject.put("streamType", streamType);
mediaObject.put("contentType", contentType);
if (time > 0.0) {
val seekTime = time;
loadObject.put("currentTime", seekTime);
}
loadObject.put("media", mediaObject);
loadObject.put("requestId", _requestId++);
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
val json = loadObject.toString().replace("\\/","/");
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume)
val setVolumeObject = JSONObject();
setVolumeObject.put("type", "SET_VOLUME");
val volumeObject = JSONObject();
volumeObject.put("level", volume)
setVolumeObject.put("volume", volumeObject);
setVolumeObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "SEEK");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
loadObject.put("currentTime", timeSeconds);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PLAY");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PAUSE");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
_contentId = null;
_contentType = null;
_streamType = null;
val loadObject = JSONObject();
loadObject.put("type", "STOP");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun launchPlayer() {
if (invokeInIOScopeIfRequired(::launchPlayer)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "LAUNCH");
launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
}
private fun getStatus() {
if (invokeInIOScopeIfRequired(::getStatus)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "GET_STATUS");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
val sessionId = _sessionId;
if (sessionId != null) {
val launchObject = JSONObject();
launchObject.put("type", "STOP");
launchObject.put("sessionId", sessionId);
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_contentId = null;
_contentType = null;
_streamType = null;
_sessionId = null;
_launchRetries = 0
_transportId = null;
}
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_autoLaunchEnabled = true
_started = true;
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Starting...");
_launching = true;
ensureThreadsStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadsStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
Log.i(TAG, "Restarting threads because one of the threads has died")
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
}
}
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.")
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.")
val s = Socket().apply { this.connect(address, 2000) }
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
}
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
}
} catch (e: Throwable) {
_socket?.close();
Logger.i(TAG, "Failed to connect to Chromecast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress;
try {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
_socket?.close();
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
getStatus();
val buffer = ByteArray(409600);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
val message = synchronized(_inputStreamLock)
{
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size =
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
return@synchronized null
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $msg");
}
return@synchronized msg
}
if (message != null) {
try {
handleMessage(message);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
break
}
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break;
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
//Start ping loop
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
val pingObject = JSONObject();
pingObject.put("type", "PING");
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.");
}
Thread.sleep(5000);
}
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() };
} else {
Log.i(TAG, "Threads still alive, not restarted")
}
}
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
sendMessage(castMessage.toByteArray());
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
//Log.d(TAG, "Sent channel message: $castMessage");
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
val status = jsonObject.getJSONObject("status");
var sessionIsRunning = false;
if (status.has("applications")) {
val applications = status.getJSONArray("applications");
for (i in 0 until applications.length()) {
val applicationUpdate = applications.getJSONObject(i);
val appId = applicationUpdate.getString("appId");
Logger.i(TAG, "Status update received appId (appId: $appId)");
if (appId == "CC1AD845") {
sessionIsRunning = true;
_autoLaunchEnabled = false
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId);
Logger.i(TAG, "Connected to media channel $transportId");
_transportId = transportId;
requestMediaStatus();
}
}
}
}
if (!sessionIsRunning) {
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
_transportId = null
if (_autoLaunchEnabled) {
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
}
} else {
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else {
if (_retryJob == null) {
Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
}
} else {
_launching = false
_launchRetries = 0
_autoLaunchEnabled = false
}
val volume = status.getJSONObject("volume");
//val volumeControlType = volume.getString("controlType");
val volumeLevel = volume.getString("level").toDouble();
val volumeMuted = volume.getBoolean("muted");
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
setVolume(if (volumeMuted) 0.0 else volumeLevel);
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
} else if (type == "MEDIA_STATUS") {
val statuses = jsonObject.getJSONArray("status");
for (i in 0 until statuses.length()) {
val status = statuses.getJSONObject(i);
_mediaSessionId = status.getInt("mediaSessionId");
val playerState = status.getString("playerState");
val currentTime = status.getDouble("currentTime");
if (status.has("media")) {
val media = status.getJSONObject("media")
if (media.has("duration")) {
setDuration(media.getDouble("duration"))
}
}
isPlaying = playerState == "PLAYING";
if (isPlaying || playerState == "PAUSED") {
setTime(currentTime);
}
val playbackRate = status.getInt("playbackRate");
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
if (_contentType == null) {
stopVideo();
}
}
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
if (needsLoad && _contentId != null && _mediaSessionId == null) {
Logger.i(TAG, "Receiver idle, sending initial LOAD")
playVideo()
}
} else if (type == "CLOSE") {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stopCasting();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
}
} else {
throw Exception("Payload type ${message.payloadType} is not implemented.");
}
}
private fun sendMessage(data: ByteArray) {
val outputStream = _outputStream;
if (outputStream == null) {
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
return;
}
synchronized(_outputStreamLock)
{
val serializedSizeBE = ByteArray(4);
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
serializedSizeBE[3] = (data.size and 0xff).toByte();
outputStream.write(serializedSizeBE);
outputStream.write(data);
}
//Log.d(TAG, "Sent ${data.size} bytes.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
_contentId = null
_contentType = null
_streamType = null
_retryJob?.cancel()
_retryJob = null
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_pingThread = null;
_thread = null;
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
_mediaSessionId = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "ChromecastCastingDevice";
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
});
}
}
@@ -1,636 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
import com.futo.platformplayer.casting.models.FCastPlayMessage
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
import com.futo.platformplayer.casting.models.FCastSeekMessage
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
import com.futo.platformplayer.casting.models.FCastVersionMessage
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.spec.DHParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
enum class Opcode(val value: Byte) {
None(0),
Play(1),
Pause(2),
Resume(3),
Stop(4),
Seek(5),
PlaybackUpdate(6),
VolumeUpdate(7),
SetVolume(8),
PlaybackError(9),
SetSpeed(10),
Version(11),
Ping(12),
Pong(13);
companion object {
private val _map = entries.associateBy { it.value }
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
}
}
class FCastCastingDevice : CastingDeviceLegacy {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _socket: Socket? = null;
private var _outputStream: OutputStream? = null;
private var _inputStream: InputStream? = null;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume);
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
return;
}
setSpeed(speed);
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds
));
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
send(Opcode.Resume);
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
send(Opcode.Pause);
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
send(Opcode.Stop);
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
stopVideo();
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_started = true;
Logger.i(TAG, "Starting...");
ensureThreadStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
Log.i(TAG, "(Re)starting thread because the thread has died")
_scopeIO?.let {
it.cancel()
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
}
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
Log.i(TAG, "Connection thread started.")
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Log.i(TAG, "Connection failed, waiting 1 seconds.")
Thread.sleep(1000);
continue;
}
Log.i(TAG, "Connection succeeded.")
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress
localAddress = connectedSocket.localAddress
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
}
}
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.");
_socket = connectedSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.");
_socket = Socket().apply { this.connect(address, 2000) };
}
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
_outputStream = _socket?.outputStream;
_inputStream = _socket?.inputStream;
} catch (e: IOException) {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress
_lastPongTime = System.currentTimeMillis()
connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
var headerBytesRead = 0
while (headerBytesRead < 4) {
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
if (read == -1)
throw Exception("Stream closed")
headerBytesRead += read
}
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
break
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
var bytesRead = 0
while (bytesRead < size) {
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
if (read == -1)
throw Exception("Stream closed")
bytesRead += read
}
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val opcode = messageBytes[0];
var json: String? = null;
if (size > 1) {
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
}
try {
handleMessage(Opcode.find(opcode), json);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e)
break
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break
}
}
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() }
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
if (connectionState == CastConnectionState.CONNECTED) {
try {
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
}
Thread.sleep(5000)
}
Logger.i(TAG, "Stopped ping loop.")
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
}
private fun handleMessage(opcode: Opcode, json: String? = null) {
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
when (opcode) {
Opcode.PlaybackUpdate -> {
if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring.");
return;
}
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
setTime(playbackUpdate.time, playbackUpdate.generationTime);
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
isPlaying = when (playbackUpdate.state) {
1 -> true
else -> false
}
}
Opcode.VolumeUpdate -> {
if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring.");
return;
}
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
}
Opcode.PlaybackError -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
}
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.Version -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
}
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { }
}
}
private fun send(opcode: Opcode, message: String? = null) {
ensureNotMainThread()
synchronized (_outputStreamLock) {
try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return
}
val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
}
private inline fun <reified T> send(opcode: Opcode, message: T) {
try {
send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) {
Log.i(TAG, "Failed to encode message to string.", e)
throw e
}
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
_inputStream?.close()
_outputStream?.close()
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "FCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true }
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
}
fun generateKeyPair(): KeyPair {
//modp14
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
val g = BigInteger("2", 16)
val dhSpec = DHParameterSpec(p, g)
val keyGen = KeyPairGenerator.getInstance("DH")
keyGen.initialize(dhSpec)
return keyGen.generateKeyPair()
}
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
val keyFactory = KeyFactory.getInstance("DH")
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
val keyAgreement = KeyAgreement.getInstance("DH")
keyAgreement.init(privateKey)
keyAgreement.doPhase(receivedPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
val sha256 = MessageDigest.getInstance("SHA-256")
val hashedSecret = sha256.digest(sharedSecret)
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
return SecretKeySpec(hashedSecret, "AES")
}
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
val iv = cipher.iv
val json = Json.encodeToString(decryptedMessage)
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
return FCastEncryptedMessage(
version = 1,
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
)
}
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedJson = cipher.doFinal(encrypted)
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
}
}
}
@@ -8,6 +8,7 @@ import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -40,6 +41,7 @@ import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAud
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.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -56,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.DeviceInfo
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.NsdDeviceDiscoverer
import org.fcast.sender_sdk.ProtocolType
import java.net.Inet6Address
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
abstract class StateCasting {
class StateCasting {
val _scopeIO = CoroutineScope(Dispatchers.IO);
val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
@@ -82,6 +90,7 @@ abstract class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null
@@ -90,15 +99,163 @@ abstract class StateCasting {
val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0)
abstract fun handleUrl(url: String)
abstract fun onStop()
abstract fun start(context: Context)
abstract fun stop()
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
abstract fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
): Job?
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDevice(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDevice(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDevice) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
fun onResume() {
val ad = activeDevice
@@ -145,6 +302,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect()
}
@@ -159,6 +317,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null;
}
@@ -222,6 +381,9 @@ abstract class StateCasting {
device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
};
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try {
device.connect();
@@ -232,6 +394,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return;
}
@@ -1233,6 +1396,47 @@ abstract class StateCasting {
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
private fun escapeXml(s: String): String =
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun injectSubtitleAdaptationSet(
mpd: String,
subtitleUrl: String,
mimeType: String,
lang: String = "und",
label: String = "Subtitles"
): String {
val mt = mimeType.lowercase()
val codecs = when (mt) {
"text/vtt", "text/webvtt" -> "wvtt"
"application/ttml+xml", "application/ttml" -> "stpp"
else -> null
}
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
val adaptation = """
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
<Label>${escapeXml(label)}</Label>
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
</Representation>
</AdaptationSet>
""".trimIndent()
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
return if (periodClose.containsMatchIn(mpd)) {
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
} else {
mpd
}
}
@OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
val ad = activeDevice ?: return listOf();
@@ -1254,30 +1458,42 @@ abstract class StateCasting {
val videoUrl = url + videoPath
val audioUrl = url + audioPath
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
return@withContext subtitleSource.getSubtitlesURI();
} else null;
subtitleSource.getSubtitlesURI()
} else null
var subtitlesUrl: String? = null;
var subtitlesUrl: String? = null
if (subtitlesUri != null) {
if(subtitlesUri.scheme == "file") {
var content: String? = null;
val inputStream = contentResolver.openInputStream(subtitlesUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
content = reader.use { it.readText() };
when (subtitlesUri.scheme) {
"file", "content" -> {
val content = withContext(Dispatchers.IO) {
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
stream.bufferedReader().use { it.readText() }
}
}
if (!content.isNullOrEmpty()) {
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castDashRaw")
subtitlesUrl = url + subtitlePath
}
}
if (content != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
"http", "https" -> {
// Receiver will fetch directly (works only if it doesnt need auth/headers)
subtitlesUrl = subtitlesUri.toString()
}
subtitlesUrl = url + subtitlePath;
} else {
subtitlesUrl = subtitlesUri.toString();
else -> {
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
}
}
}
@@ -1323,8 +1539,22 @@ abstract class StateCasting {
return emptyList()
}
if (subtitlesUrl != null) {
dashContent = injectSubtitleAdaptationSet(
dashContent,
subtitlesUrl!!,
subtitleMimeTypeForMpd
)
}
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value
@@ -1348,16 +1578,20 @@ abstract class StateCasting {
throw Exception("Audio source without request executor not supported")
}
if (audioSource != null && audioSource.hasRequestExecutor) {
val oldExecutor = _audioExecutor;
oldExecutor?.closeAsync();
_audioExecutor = audioSource.getRequestExecutor()
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldVideoExecutor = _videoExecutor
oldVideoExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor()
}
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldExecutor = _videoExecutor;
oldExecutor?.closeAsync();
_videoExecutor = videoSource.getRequestExecutor()
if (audioSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = audioSource.getRequestExecutor()
} else if (hasAudioInDash && videoSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = _videoExecutor
}
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
@@ -1388,7 +1622,7 @@ abstract class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw");
}
if (audioSource != null) {
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext ->
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
@@ -1453,11 +1687,7 @@ abstract class StateCasting {
}
companion object {
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
StateCastingExp()
} else {
StateCastingLegacy()
}
var instance = StateCasting()
private val representationRegex = Regex(
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
RegexOption.DOT_MATCHES_ALL
@@ -1,178 +0,0 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.util.Log
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.NsdDeviceDiscoverer
class StateCastingExp : StateCasting() {
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
override fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDeviceExp(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
override fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
override fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDeviceExp(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDeviceExp) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
override fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
companion object {
private val TAG = "StateCastingExp"
}
}
@@ -1,399 +0,0 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import kotlinx.coroutines.delay
class StateCastingLegacy : StateCasting() {
private var _nsdManager: NsdManager? = null
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
override fun handleUrl(url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json =
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
.toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
val foundInfo = addRememberedDevice(
CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
)
)
if (foundInfo != null) {
connectDevice(deviceFromInfo(foundInfo))
}
}
override fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.disconnect();
}
@Synchronized
override fun start(context: Context) {
if (_started)
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
override fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.disconnect();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(
service,
{ it.run() },
object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
serviceInfo.hostAddresses.toTypedArray(),
serviceInfo.port
)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
arrayOf(serviceInfo.host),
serviceInfo.port
)
}
})
}
}
}
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? {
val d = activeDevice;
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
return _scopeMain.launch {
while (true) {
val device = instance.activeDevice
if (device == null || !device.isPlaying) {
break
}
delay(1000)
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms)
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
return null
}
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return CastingDeviceLegacyWrapper(
when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
)
}
private fun addOrUpdateChromeCastDevice(
name: String,
addresses: Array<InetAddress>,
port: Int
) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
ChromecastCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.addresses = addresses;
d.inner.port = port;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
AirPlayCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun addOrUpdateCastDevice(
name: String,
deviceFactory: () -> CastingDevice,
deviceUpdater: (device: CastingDevice) -> Boolean
) {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
val changed = deviceUpdater(device);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
private val TAG = "StateCastingLegacy"
}
}
@@ -1,72 +0,0 @@
package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@Serializable
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Double? = null,
val speed: Double? = null
) { }
@Serializable
data class FCastSeekMessage(
val time: Double
) { }
@Serializable
data class FCastPlaybackUpdateMessage(
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int,
val speed: Double
) { }
@Serializable
data class FCastVolumeUpdateMessage(
val generationTime: Long,
val volume: Double
)
@Serializable
data class FCastSetVolumeMessage(
val volume: Double
)
@Serializable
data class FCastSetSpeedMessage(
val speed: Double
)
@Serializable
data class FCastPlaybackErrorMessage(
val message: String
)
@Serializable
data class FCastVersionMessage(
val version: Long
)
@Serializable
data class FCastKeyExchangeMessage(
val version: Long,
val publicKey: String
)
@Serializable
data class FCastDecryptedMessage(
val opcode: Long,
val message: String?
)
@Serializable
data class FCastEncryptedMessage(
val version: Long,
val iv: String?,
val blob: String
)
@@ -21,6 +21,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
@@ -64,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
_buttonNever.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
Settings.instance.autoUpdate.check = 1;
Settings.instance.save();
dismiss();
};
_buttonClose.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
dismiss();
};
@@ -79,11 +82,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
};
_buttonUpdate.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
if (_updating) {
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);
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter;
};
@@ -12,7 +12,6 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_textType.text = "AirPlay";
}
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_imageDevice.setImageResource(R.drawable.ic_fc)
_textType.text = "FCast";
}
}
@@ -9,7 +9,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
@@ -40,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
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.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads
@@ -86,6 +91,9 @@ import kotlin.time.times
class VideoDownload {
var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -101,6 +109,7 @@ class VideoDownload {
var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual
@Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@@ -270,7 +279,7 @@ class VideoDownload {
//Fetch full video object and determine source
if(video != null && videoDetails == null) {
val original = StatePlatform.instance.getContentDetails(video!!.url).await();
val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?");
@@ -437,6 +446,11 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
}
if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
@@ -490,7 +504,11 @@ class VideoDownload {
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
if(actualAudioSource == null)
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
File(downloadDir, audioFileName!!));
else
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
}
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
});
@@ -530,7 +548,7 @@ class VideoDownload {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
}
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
});
@@ -589,38 +607,54 @@ class VideoDownload {
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
suspendCancellableCoroutine { continuation ->
val concatInput = buildString {
append("concat:")
append(
segmentFiles.joinToString("|") { file ->
file.absolutePath
}
)
}
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
//No callback
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
val session = FFmpegKit.executeAsync(
cmd,
{ completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
"Command failed with state '${completedSession.state}' " +
"and return code ${completedSession.returnCode}, " +
"stack trace ${completedSession.failStackTrace}"
}
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ Logger.v(TAG, it.message) },
{ log ->
Logger.v(TAG, log.message)
},
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdownNow()
}
}
}
@@ -856,14 +890,19 @@ class VideoDownload {
return downloadedTotalLength
}
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
if(targetFile.exists())
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null;
try{
@@ -874,14 +913,27 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
val foundTemplate = when(downloadType) {
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
}
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
val foundTemplateUrl = foundTemplate.groupValues[1];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
if(foundCues.count() <= 0)
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
val foundCues2Downloaded = hashSetOf<MatchResult>();
if(foundTemplate2 != null)
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
executor = if(source is JSSource && source.hasRequestExecutor)
source.getRequestExecutor();
else
@@ -896,13 +948,17 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf());
@@ -918,17 +974,60 @@ class VideoDownload {
speedTracker.addWork(data.size.toLong());
written += data.size;
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
indexCounter++;
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
val toDownload = if(lastCue != null && cue == lastCue)
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
for(cue2 in toDownload) {
val index2 = foundCues2.indexOf(cue2);
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
indexCounter2++;
foundCues2Downloaded.add(cue2);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
}
}
}
sourceLength = written;
sourceLengthAudio = written2;
Logger.i(TAG, "$name downloadSource Finished");
}
catch(scriptEx: ScriptReloadRequiredException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
createNewPluginClient();
throw scriptEx;
}
catch(ioex: IOException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
@@ -937,14 +1036,37 @@ class VideoDownload {
catch(ex: Throwable) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex;
}
finally {
fileStream.close();
fileStream2?.close();
executor?.closeAsync()
}
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
return sourceLength!!;
}
fun createNewPluginClient() {
UIDialogs.appToast("Download creating new client at request of plugin");
cleanupPluginClient();
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
plugin?.initialize();
}
fun cleanupPluginClient() {
val oldPlugin = plugin;
plugin = null;
try {
oldPlugin?.disable();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -1304,7 +1426,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSourceToUse != null) {
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!);
@@ -1327,7 +1449,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1369,6 +1491,10 @@ class VideoDownload {
}
}
fun cleanup(){
cleanupPluginClient()
}
enum class State {
QUEUED,
PREPARING,
@@ -1392,6 +1518,8 @@ class VideoDownload {
const val GROUP_WATCHLATER= "WatchLater";
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? {
@@ -1411,6 +1539,16 @@ class VideoDownload {
return "video";//throw IllegalStateException("Unknown container: " + container)
}
//TODO: Change usages of this to an accurate container instead of infering it.
fun videoAudioContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4a";
else if (container.contains("video/webm"))
return "webm";
else
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4"))
return "mp4a";
@@ -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)) {
@@ -33,6 +33,7 @@ import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.compose.ui.text.toLowerCase
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
@@ -723,15 +724,17 @@ class VideoDetailView : ConstraintLayout {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
};
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
Log.i(TAG, "Next video (loop?)")
nextVideo();
}
}
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
if (_isCasting) {
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
@@ -1273,6 +1276,7 @@ class VideoDetailView : ConstraintLayout {
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
StateApp.instance.preventPictureInPicture.remove(this);
StatePlayer.instance.onQueueChanged.remove(this);
StatePlayer.instance.onVideoChanging.remove(this);
@@ -2420,9 +2424,54 @@ class VideoDetailView : ConstraintLayout {
val doDedup = Settings.instance.playback.simplifySources;
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
?.distinct()
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
Log.i(TAG, "Language count: ${allLanguages}");
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
?.distinct()
?.filterNotNull()
@@ -2438,7 +2487,7 @@ class VideoDetailView : ConstraintLayout {
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true,
R.string.quality), null, false,
qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@@ -2528,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
call = { _player.selectAudioTrack(it.bitrate) });
}.toList().toTypedArray())
else null,
if(languageFilters != null) languageFilters else null,
if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources
.map {
(bestVideoSources.map {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context,
@@ -2541,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
(prefix + it.codec.trim()).trim(),
tag = it,
call = { handleSelectVideoTrack(it) });
}.toList().toTypedArray())
call = { handleSelectVideoTrack(it) }).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
};
}).toList())
else null,
if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
@@ -52,8 +52,8 @@ class VideoHelper {
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
val targetVideo = if(desiredPixelCount > 0) {
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
} else {
@@ -63,12 +63,34 @@ class VideoHelper {
val hasPriority = sources.any { it.priority };
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
val altSources = if(hasPriority) {
//Filter priority
var altSources = if(hasPriority) {
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
} else {
sources.filter { it.height == (targetVideo?.height ?: 0) };
}
//Filter Original
val hasOriginal = altSources.any { it.original == true };
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
altSources = altSources.filter { it.original == true };
//Filter Language
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
preferredLanguage
} else {
if(altSources.any { it.language == Language.ENGLISH })
Language.ENGLISH;
else
Language.UNKNOWN;
}
if(altSources.any { it.language == languageToFilter }) {
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
} else {
altSources.sortedBy { it.bitrate }
}
var bestSource = altSources.firstOrNull();
for (prefContainer in prefContainers) {
val betterSource = altSources.firstOrNull { it.container == prefContainer };
@@ -15,6 +15,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.getNowDiffMinutes
import com.futo.platformplayer.logging.Logger
@@ -169,6 +170,7 @@ class DownloadService : Service() {
Thread.sleep(500);
}
catch(ex: Throwable) {
//if(ex is ScriptReloadRequiredException)
Logger.e(TAG, "Download failed", ex);
if(currentVideo.video == null && currentVideo.videoDetails == null) {
//Corrupt?
@@ -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)
@@ -179,6 +179,7 @@ class StateDownloads {
fun removeDownload(download: VideoDownload) {
download.isCancelled = true;
download.cleanup();
_downloading.delete(download);
onDownloadsChanged.emit();
}
@@ -9,7 +9,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView.ViewHolder
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.CastProtocolType
@@ -91,13 +90,7 @@ class DeviceViewHolder : ViewHolder {
_textType.text = "AirPlay";
}
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_imageDevice.setImageResource(R.drawable.ic_fc)
_textType.text = "FCast";
}
}
@@ -11,6 +11,7 @@ import com.futo.platformplayer.*
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.CoroutineScope
@@ -32,6 +33,7 @@ class ActiveDownloadItem: LinearLayout {
private val _videoState: TextView;
private val _videoCancel: TextView;
private val _videoRetry: TextView;
private val _scope: CoroutineScope;
@@ -51,13 +53,14 @@ class ActiveDownloadItem: LinearLayout {
_videoSpeed = findViewById(R.id.download_video_speed);
_videoCancel = findViewById(R.id.download_cancel);
_videoRetry = findViewById(R.id.download_retry);
_videoName.text = download.name;
_videoDuration.text = download.videoEither.duration.toHumanTime(false);
_videoAuthor.text = download.videoEither.author.name;
_videoState.setOnClickListener {
UIDialogs.toast(context, _videoState.text.toString(), false);
UIDialogs.appToast(_videoState.text.toString(), false);
}
Glide.with(_videoImage)
@@ -72,6 +75,12 @@ class ActiveDownloadItem: LinearLayout {
StateDownloads.instance.removeDownload(_download);
StateDownloads.instance.preventPlaylistDownload(_download);
};
_videoRetry.setOnClickListener {
download.changeState(VideoDownload.State.QUEUED);
DownloadService.getOrCreateService(context) {
}
}
_download.onProgressChanged.subscribe(this) {
_scope.launch(Dispatchers.Main) {
@@ -122,16 +131,19 @@ class ActiveDownloadItem: LinearLayout {
VideoDownload.State.DOWNLOADING -> {
_videoBar.visibility = VISIBLE;
_videoSpeed.visibility = VISIBLE;
_videoRetry.visibility = GONE;
};
VideoDownload.State.ERROR -> {
_videoState.setTextColor(Color.RED);
_videoState.text = _download.error ?: context.getString(R.string.error);
_videoBar.visibility = GONE;
_videoSpeed.visibility = GONE;
_videoRetry.visibility = VISIBLE;
}
else -> {
_videoBar.visibility = GONE;
_videoSpeed.visibility = GONE;
_videoRetry.visibility = GONE;
}
}
}
@@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
class SlideUpMenuButtonList : LinearLayout {
private val _root: LinearLayout;
@@ -20,10 +21,16 @@ class SlideUpMenuButtonList : LinearLayout {
var _activeText: String? = null;
val id: String?
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) {
this.id = id
val scrollable: Boolean;
LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true);
constructor(context: Context, attrs: AttributeSet? = null, id: String? = null, scrollable: Boolean = false): super(context, attrs) {
this.id = id
this.scrollable = scrollable ?: false;
LayoutInflater.from(context).inflate(
if(!scrollable)
R.layout.overlay_slide_up_menu_button_list
else R.layout.overlay_slide_up_menu_button_list_scrollable, this, true);
_root = findViewById(R.id.root);
}
@@ -37,8 +44,9 @@ class SlideUpMenuButtonList : LinearLayout {
buttons.clear();
for (t in texts) {
val button = LinearLayout(context);
button.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT).apply {
weight = 1.0f;
button.layoutParams = LinearLayout.LayoutParams(if(!scrollable) 0 else LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT).apply {
if(!scrollable)
weight = 1.0f;
marginStart = marginLeft;
marginEnd = marginRight;
};
@@ -49,7 +57,11 @@ class SlideUpMenuButtonList : LinearLayout {
onClick.emit(t);
};
button.setPadding(0, 0, 0, 0);
val dp8 = 8.dp(resources)
if(!scrollable)
button.setPadding(0, 0, 0, 0);
else
button.setPadding(dp8, 0, dp8, 0);
val text = TextView(context);
text.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
@@ -69,6 +81,18 @@ class SlideUpMenuButtonList : LinearLayout {
fun setSelected(text: String) {
buttons[_activeText]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option);
buttons[text]?.background = ContextCompat.getDrawable(context, R.drawable.background_slide_up_option_selected);
val dp8 = 8.dp(resources)
if(!scrollable) {
buttons[text]?.setPadding(0, 0, 0, 0);
buttons[_activeText]?.setPadding(0, 0, 0, 0);
}
else {
buttons[text]?.setPadding(dp8, 0, dp8, 0);
buttons[_activeText]?.setPadding(dp8, 0, dp8, 0);
}
_activeText = text;
}
}
@@ -15,9 +15,9 @@ class PluginMediaDrmCallback(
) : MediaDrmCallback by delegate {
@ExperimentalEncodingApi
override fun executeKeyRequest(uuid: UUID, request: ExoMediaDrm.KeyRequest): ByteArray {
override fun executeKeyRequest(uuid: UUID, request: ExoMediaDrm.KeyRequest): MediaDrmCallback.Response {
val pluginResponse = requestExecutor.executeRequest("POST", licenseUrl, request.data, mapOf())
return pluginResponse
return MediaDrmCallback.Response(pluginResponse)
}
}
@@ -1,18 +0,0 @@
syntax = "proto2";
option optimize_for = LITE_RUNTIME;
package com.futo.platformplayer.protos;
message CastMessage {
enum ProtocolVersion { CASTV2_1_0 = 0; }
required ProtocolVersion protocol_version = 1;
required string source_id = 2;
required string destination_id = 3;
required string namespace = 4;
enum PayloadType {
STRING = 0;
BINARY = 1;
}
required PayloadType payload_type = 5;
optional string payload_utf8 = 6;
optional bytes payload_binary = 7;
}
-14
View File
@@ -1,14 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="111.96dp"
android:height="114.46dp"
android:viewportWidth="111.96"
android:viewportHeight="114.46">
<path
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
android:strokeWidth="0"
android:fillColor="#ffffff"/>
</vector>
+10 -5
View File
@@ -1,9 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="17dp"
android:height="12dp"
android:viewportWidth="17"
android:viewportHeight="12">
android:width="111.96dp"
android:height="114.46dp"
android:viewportWidth="111.96"
android:viewportHeight="114.46">
<path
android:pathData="M0.672,11V0.818H6.563V1.653H1.601V5.487H6.101V6.322H1.601V11H0.672ZM16.849,4H15.915C15.845,3.652 15.719,3.33 15.537,3.036C15.358,2.737 15.132,2.477 14.861,2.255C14.589,2.033 14.281,1.861 13.936,1.738C13.591,1.615 13.218,1.554 12.817,1.554C12.174,1.554 11.588,1.721 11.057,2.056C10.53,2.391 10.108,2.883 9.79,3.533C9.475,4.179 9.317,4.971 9.317,5.909C9.317,6.854 9.475,7.649 9.79,8.295C10.108,8.942 10.53,9.432 11.057,9.767C11.588,10.099 12.174,10.264 12.817,10.264C13.218,10.264 13.591,10.203 13.936,10.08C14.281,9.958 14.589,9.787 14.861,9.568C15.132,9.346 15.358,9.086 15.537,8.788C15.719,8.489 15.845,8.166 15.915,7.818H16.849C16.766,8.286 16.611,8.721 16.382,9.126C16.156,9.527 15.868,9.878 15.517,10.18C15.169,10.481 14.768,10.717 14.314,10.886C13.86,11.055 13.361,11.139 12.817,11.139C11.962,11.139 11.203,10.925 10.54,10.498C9.877,10.067 9.357,9.46 8.979,8.678C8.605,7.896 8.417,6.973 8.417,5.909C8.417,4.845 8.605,3.922 8.979,3.14C9.357,2.358 9.877,1.753 10.54,1.325C11.203,0.894 11.962,0.679 12.817,0.679C13.361,0.679 13.86,0.763 14.314,0.933C14.768,1.098 15.169,1.334 15.517,1.638C15.868,1.94 16.156,2.291 16.382,2.692C16.611,3.094 16.766,3.529 16.849,4Z"
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
android:strokeWidth="0"
android:fillColor="#ffffff"/>
</vector>
+28 -13
View File
@@ -118,6 +118,21 @@
android:ellipsize="end"
android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/downloaded_author"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:gravity="center_vertical"
android:textSize="9dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
app:layout_constraintTop_toBottomOf="@id/downloaded_video_name"
app:layout_constraintLeft_toLeftOf="parent"
tools:text="ShortCircuit"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginStart="10dp" />
<TextView
android:id="@+id/download_cancel"
android:layout_width="60dp"
@@ -130,20 +145,20 @@
android:background="@drawable/background_small_button"
android:textAlignment="center"
android:text="@string/cancel" />
<TextView
android:id="@+id/download_retry"
android:layout_width="60dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:padding="2dp"
android:visibility="gone"
app:layout_constraintRight_toRightOf="@id/download_cancel"
app:layout_constraintTop_toBottomOf="@id/download_cancel"
android:textSize="10dp"
android:background="@drawable/background_small_button"
android:textAlignment="center"
android:text="@string/retry" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/downloaded_author"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:gravity="center_vertical"
android:textSize="9dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="ShortCircuit"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginStart="10dp" />
</LinearLayout>
<LinearLayout
android:layout_height="wrap_content"
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="35dp"
android:layout_marginTop="10dp"
android:id="@+id/root"
android:orientation="horizontal"
android:paddingStart="0dp"
android:paddingEnd="0dp">
</LinearLayout>
</HorizontalScrollView>
-5
View File
@@ -1112,11 +1112,6 @@
<string-array name="casting_device_type_array" translatable="false">
<item>FCast</item>
<item>ChromeCast</item>
<item>AirPlay</item>
</string-array>
<string-array name="exp_casting_device_type_array" translatable="false">
<item>FCast</item>
<item>ChromeCast</item>
</string-array>
<string-array name="log_levels">
<item>None</item>
+2
View File
@@ -40,6 +40,7 @@
<data android:host="old.bitchute.com" />
<data android:host="open.spotify.com" />
<data android:host="music.youtube.com" />
<data android:host="b23.tv" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:autoVerify="true">
@@ -71,6 +72,7 @@
<data android:host="old.bitchute.com" />
<data android:host="open.spotify.com" />
<data android:host="music.youtube.com" />
<data android:host="b23.tv" />
</intent-filter>
</activity>
</application>
+2
View File
@@ -50,6 +50,7 @@
<data android:host="old.bitchute.com" />
<data android:host="open.spotify.com" />
<data android:host="music.youtube.com" />
<data android:host="b23.tv" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:autoVerify="true">
@@ -81,6 +82,7 @@
<data android:host="old.bitchute.com" />
<data android:host="open.spotify.com" />
<data android:host="music.youtube.com" />
<data android:host="b23.tv" />
</intent-filter>
</activity>
</application>