mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8536861e09 | |||
| 71262da3c2 | |||
| 60cd5976cc | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 09bc180d4f |
+7
-7
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,10 @@ import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.SessionAnnouncement
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.*
|
||||
@@ -14,6 +18,7 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class UpdateDownloadService : Service() {
|
||||
|
||||
@@ -85,13 +90,16 @@ class UpdateDownloadService : Service() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||
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)
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||
|
||||
if(onProgress != null)
|
||||
onProgress.invoke(progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +107,7 @@ class UpdateDownloadService : Service() {
|
||||
val apkFile = StateUpdate.getApkFile(this, version)
|
||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||
|
||||
var announcement: SessionAnnouncement? = null;
|
||||
try {
|
||||
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||
@@ -106,6 +115,14 @@ class UpdateDownloadService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||
ImageVariable.fromResource(R.drawable.foreground));
|
||||
}
|
||||
catch(ex: Exception){
|
||||
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||
}
|
||||
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
|
||||
for (attempt in 0 until MAX_RETRIES) {
|
||||
@@ -115,7 +132,13 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
|
||||
try {
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version)
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
||||
try {
|
||||
if (announcement != null)
|
||||
announcement?.setProgress(it);
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
})
|
||||
|
||||
if (!cancelRequested) {
|
||||
if (apkFile.exists()) {
|
||||
@@ -145,6 +168,12 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (announcement != null) {
|
||||
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){}
|
||||
isDownloading = false
|
||||
cancelRequested = false
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
@@ -152,7 +181,7 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDownload(url: String, partialFile: File, version: Int) {
|
||||
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||
|
||||
@@ -204,7 +233,7 @@ class UpdateDownloadService : Service() {
|
||||
progress > 100 -> 100
|
||||
else -> progress
|
||||
}
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||
}
|
||||
} else {
|
||||
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||
@@ -250,6 +279,18 @@ class UpdateDownloadService : Service() {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
}, ActionStyle.PRIMARY, true));
|
||||
|
||||
try {
|
||||
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
|
||||
AnnouncementType.SESSION,
|
||||
OffsetDateTime.now(), "update", "Install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
});
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
updateDownloadedDialog = null
|
||||
|
||||
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||
lateinit var _fragNotifications: NotificationOverlayView.Frag;
|
||||
lateinit var _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||
_fragNotifications = NotificationOverlayView.Frag();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||
_fragSettings.topBar = _fragTopBarNavigation;
|
||||
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||
_fragNotifications.topBar = _fragTopBarGeneral;
|
||||
|
||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||
|
||||
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||
NotificationOverlayView.Frag::class -> _fragNotifications as T;
|
||||
SettingsFragment:: class -> _fragSettings as T;
|
||||
DeveloperFragment::class -> _fragDeveloper as T;
|
||||
LoginFragment::class -> _fragLogin as T;
|
||||
|
||||
+11
@@ -61,6 +61,11 @@ class SourcePluginConfig(
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
||||
|
||||
fun isOfficialAuthor(): Boolean {
|
||||
return scriptSignature != null &&
|
||||
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
|
||||
}
|
||||
|
||||
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
||||
if(url == null)
|
||||
return null;
|
||||
@@ -165,6 +170,12 @@ class SourcePluginConfig(
|
||||
"Unrestricted Http Header access",
|
||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||
))
|
||||
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
|
||||
list.add(Pair(
|
||||
"Browser Interop",
|
||||
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
|
||||
))
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,62 +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 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
|
||||
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,
|
||||
@@ -64,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,289 +0,0 @@
|
||||
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 com.futo.polycentric.core.Event
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.KeyEvent
|
||||
import org.fcast.sender_sdk.MediaEvent
|
||||
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.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
|
||||
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>()
|
||||
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()
|
||||
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 val onMediaItemEnd: Event0
|
||||
get() = eventHandler.onMediaItemEnd
|
||||
|
||||
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,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
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 -> {
|
||||
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 }
|
||||
}
|
||||
|
||||
override fun ensureThreadStarted() {}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
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 val onMediaItemEnd: Event0 = Event0()
|
||||
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
|
||||
@@ -57,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();
|
||||
@@ -92,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
|
||||
@@ -1264,10 +1419,10 @@ abstract class StateCasting {
|
||||
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
|
||||
|
||||
val adaptation = """
|
||||
<AdaptationSet id="subtitles-1" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
|
||||
<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="subtitles-1"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
@@ -1532,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
|
||||
)
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
||||
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.engine.internal.V8Converter
|
||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||
import com.futo.platformplayer.engine.packages.PackageBrowser
|
||||
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
||||
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.toList
|
||||
import com.futo.platformplayer.toV8ValueBlocking
|
||||
import com.futo.platformplayer.toV8ValueAsync
|
||||
@@ -218,6 +222,9 @@ class V8Plugin {
|
||||
if(pack is PackageHttp) {
|
||||
pack.cleanup();
|
||||
}
|
||||
else if(pack is PackageBrowser) {
|
||||
pack.deinitialize();
|
||||
}
|
||||
}
|
||||
|
||||
_runtime?.let {
|
||||
@@ -387,6 +394,18 @@ class V8Plugin {
|
||||
"HttpImp" -> PackageHttpImp(this, config)
|
||||
"Utilities" -> PackageUtilities(this, config)
|
||||
"JSDOM" -> PackageJSDOM(this, config)
|
||||
"Browser" -> {
|
||||
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
|
||||
|
||||
if(BuildConfig.DEBUG)
|
||||
PackageBrowser(this)
|
||||
else if(isOfficial)
|
||||
PackageBrowser(this)
|
||||
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
|
||||
PackageBrowser(this)
|
||||
else
|
||||
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
|
||||
};
|
||||
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ class PackageBridge : V8Package {
|
||||
)
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun hasPackage(str: String): Boolean {
|
||||
return _plugin.getPackages().any { it.name == str };
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun dispose(value: V8Value) {
|
||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.futo.platformplayer.engine.packages
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.collection.emptyLongSet
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.utils.JavetResourceUtils
|
||||
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
class PackageBrowser: V8Package {
|
||||
override val name: String get() = "Browser";
|
||||
override val variableName: String = "browser";
|
||||
|
||||
@Transient
|
||||
private var _readySemaphore: Semaphore? = null;
|
||||
@Transient
|
||||
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
|
||||
@Transient
|
||||
private val _interop = JSInterop(this);
|
||||
@Transient
|
||||
private var _browser: WebView? = null;
|
||||
private val browser: WebView get() {
|
||||
if(_browser == null)
|
||||
throw IllegalStateException("Browser not initialized");
|
||||
return _browser!!;
|
||||
}
|
||||
|
||||
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
||||
|
||||
}
|
||||
@V8Function
|
||||
fun initialize() {
|
||||
if(_browser == null){
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
|
||||
_browser?.settings?.javaScriptEnabled = true;
|
||||
_browser?.settings?.blockNetworkImage = false;
|
||||
_browser?.settings?.blockNetworkLoads = false;
|
||||
_browser?.settings?.allowContentAccess = false;
|
||||
_browser?.settings?.allowFileAccess = false;
|
||||
//_browser?.settings?.useWideViewPort = true;
|
||||
//_browser?.settings?.loadWithOverviewMode = true;
|
||||
_browser?.webViewClient = object : WebViewClient() {
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
_readySemaphore?.release();
|
||||
_readySemaphore = null;
|
||||
Logger.i("PackageBrowser", "Browser loaded");
|
||||
}
|
||||
}
|
||||
_browser?.webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||
val msg = "Browser Error:${consoleMessage?.message()} [${consoleMessage?.lineNumber()}]" ?: ""
|
||||
Logger.e("PackageBrowser", msg);
|
||||
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
|
||||
}
|
||||
else {
|
||||
val msg = "Browser Log:" + consoleMessage?.message() ?: "";
|
||||
Logger.e("PackageBrowser", msg);
|
||||
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
|
||||
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
|
||||
}
|
||||
return super.onConsoleMessage(consoleMessage)
|
||||
}
|
||||
}
|
||||
_browser?.addJavascriptInterface(_interop, "__GJ");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@V8Function
|
||||
fun deinitialize() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
_browser?.destroy();
|
||||
}
|
||||
_browser = null;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun getCurrentUrl(): String? {
|
||||
return browser.url;
|
||||
}
|
||||
@V8Function
|
||||
fun waitTillLoaded(timeout: Int = 1000): Boolean {
|
||||
val acquired = _readySemaphore?.let {
|
||||
if(!it.tryAcquire()) {
|
||||
Logger.i("PackageBrowser", "Waiting for browser to be ready");
|
||||
if(!runBlocking {
|
||||
try {
|
||||
return@runBlocking withTimeout(timeout.toLong(), {
|
||||
it.acquire()
|
||||
return@withTimeout true;
|
||||
});
|
||||
}
|
||||
catch(ex: TimeoutCancellationException) {
|
||||
return@runBlocking false;
|
||||
}
|
||||
}) return@let false;
|
||||
}
|
||||
it.release();
|
||||
return@let true;
|
||||
} ?: true;
|
||||
if(acquired)
|
||||
Logger.i("PackageBrowser", "Browser is ready");
|
||||
else
|
||||
Logger.i("PackageBrowser", "Browser failed wait ready");
|
||||
return acquired;
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun load(url: String) {
|
||||
Logger.i("PackageBrowser", "Browser loading url [${url}]");
|
||||
_readySemaphore = Semaphore(1, 1);
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
browser.loadUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
|
||||
waitTillLoaded();
|
||||
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||
if(callbackId != null && callback != null) {
|
||||
synchronized(_callbacks) {
|
||||
_callbacks.put(callbackId, {
|
||||
_plugin.busy {
|
||||
funcClone?.callVoid(null, arrayOf(it));
|
||||
}
|
||||
if (!_plugin.isStopped)
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
});
|
||||
}
|
||||
}
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||
override fun onReceiveValue(value: String?) {
|
||||
Logger.i("PackageBrowser", "Browser run finished");
|
||||
}
|
||||
})
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@V8Function
|
||||
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
|
||||
waitTillLoaded();
|
||||
val funcClone = callback?.toClone<V8ValueFunction>()
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
|
||||
browser.evaluateJavascript(js, object : ValueCallback<String> {
|
||||
override fun onReceiveValue(value: String?) {
|
||||
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
|
||||
_plugin.busy {
|
||||
funcClone?.callVoid(null, arrayOf(value));
|
||||
}
|
||||
if (!_plugin.isStopped)
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class JSInterop(private val pack: PackageBrowser) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun callback(id: String, result: String) {
|
||||
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
|
||||
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
|
||||
if(callback != null) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
callback.invoke(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun log(msg: String) {
|
||||
Logger.i("PackageBrowser", "Log: " + msg);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Spinner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
|
||||
override val isTab: Boolean = false;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _root: LinearLayout? = null;
|
||||
private var _webview: WebView? = null;
|
||||
private val _webviewWithoutHandling = object: WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.fragment_browser, container, false);
|
||||
_root = view.findViewById<LinearLayout>(R.id.root);
|
||||
_webview = view.findViewById<WebView?>(R.id.webview).apply {
|
||||
this.webViewClient = _webviewWithoutHandling;
|
||||
this.settings.javaScriptEnabled = true;
|
||||
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack)
|
||||
|
||||
if(parameter is String) {
|
||||
if(parameter is WebView) {
|
||||
_root?.removeView(_webview);
|
||||
_root?.addView(parameter);
|
||||
_webview = parameter;
|
||||
}
|
||||
else if(parameter is String) {
|
||||
_webview?.webViewClient = _webviewWithoutHandling;
|
||||
_webview?.loadUrl(parameter);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _progressBar: ProgressBar;
|
||||
private val _spinnerSortBy: Spinner;
|
||||
private val _containerSortBy: LinearLayout;
|
||||
private val _announcementView: AnnouncementView;
|
||||
//private val _announcementView: AnnouncementView;
|
||||
private val _tagsView: TagsView;
|
||||
private val _textCentered: TextView;
|
||||
private val _emptyPagerContainer: FrameLayout;
|
||||
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
_textCentered = findViewById(R.id.text_centered);
|
||||
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
||||
_progressBar = findViewById(R.id.progress_bar);
|
||||
_announcementView = findViewById(R.id.announcement_view)
|
||||
//_announcementView = findViewById(R.id.announcement_view)
|
||||
_progressBar.inactiveColor = Color.TRANSPARENT;
|
||||
|
||||
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
||||
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
}
|
||||
|
||||
protected fun showAnnouncementView() {
|
||||
_announcementView.visibility = View.VISIBLE
|
||||
//_announcementView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
|
||||
+2
-2
@@ -266,7 +266,7 @@ class LibraryFragment : MainFragment() {
|
||||
});
|
||||
|
||||
if(this.allowMusic) {
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount)
|
||||
adapterArtists.setData(artists);
|
||||
if (artists.size == 0)
|
||||
sectionArtists.setEmpty(
|
||||
@@ -289,7 +289,7 @@ class LibraryFragment : MainFragment() {
|
||||
}
|
||||
|
||||
if(this.allowMusic) {
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
val albums = StateLibrary.instance.getAlbums()
|
||||
adapterAlbums.setData(albums);
|
||||
if (albums.size == 0)
|
||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||
|
||||
+1
-1
@@ -2487,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();
|
||||
|
||||
+49
@@ -7,6 +7,9 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||
@@ -17,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.views.casting.CastButton
|
||||
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GeneralTopBarFragment : TopFragment() {
|
||||
private var _buttonSearch: ImageButton? = null;
|
||||
private var _buttonCast: CastButton? = null;
|
||||
|
||||
private var _buttonNotifs: ConstraintLayout? = null;
|
||||
private var _buttonNotifIcon: ImageView? = null;
|
||||
private var _buttonNotifCount: TextView? = null;
|
||||
|
||||
init {
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
|
||||
lifecycleScope?.launch(Dispatchers.Main) {
|
||||
updateNotifCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotifCount() {
|
||||
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
if(currentAnnouncements.any())
|
||||
_buttonNotifCount?.let {
|
||||
it.text = currentAnnouncements.size.toString();
|
||||
it.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_buttonNotifCount?.let {
|
||||
it.text = currentAnnouncements.size.toString();
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?) {
|
||||
if(currentMain is CreatorsFragment) {
|
||||
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
||||
} else {
|
||||
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
||||
}
|
||||
if(currentMain is NotificationOverlayView.Frag) {
|
||||
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
|
||||
}
|
||||
else {
|
||||
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
|
||||
}
|
||||
}
|
||||
override fun onHide() {
|
||||
|
||||
@@ -44,6 +83,16 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
||||
_buttonCast = view.findViewById(R.id.button_cast);
|
||||
|
||||
_buttonNotifs = view.findViewById(R.id.button_notifs);
|
||||
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
|
||||
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
|
||||
|
||||
updateNotifCount();
|
||||
|
||||
_buttonNotifs?.setOnClickListener {
|
||||
navigate<NotificationOverlayView.Frag>();
|
||||
}
|
||||
|
||||
buttonSearch.setOnClickListener {
|
||||
if(currentMain is CreatorsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.dialogs.PluginUpdateDialog
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringHashSetStorage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -110,6 +119,48 @@ class StateAnnouncement {
|
||||
onAnnouncementChanged.emit();
|
||||
}
|
||||
|
||||
//Special Announcements
|
||||
fun registerPluginUpdate(oldConfig: SourcePluginConfig, newConfig: SourcePluginConfig): SessionAnnouncement {
|
||||
val announcement = SessionAnnouncement(
|
||||
"update-plugin-" + UUID.randomUUID().toString(),
|
||||
"${newConfig.name} update v${newConfig.version} available!",
|
||||
"An update is available to upgrade from ${oldConfig.version} to ${newConfig.version}.",
|
||||
AnnouncementType.SESSION,
|
||||
null, "updates", "Update", StateAnnouncement.ACTION_UPDATE_PLUGIN,
|
||||
null, null,oldConfig.id,
|
||||
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, oldConfig.id);
|
||||
registerAnnouncementSession(announcement);
|
||||
return announcement;
|
||||
}
|
||||
fun registerPluginUpdated(newConfig: SourcePluginConfig) {
|
||||
registerAnnouncementSession(SessionAnnouncement(
|
||||
"updated-plugin-" + UUID.randomUUID().toString(),
|
||||
"${newConfig.name} updated to v${newConfig.version}!",
|
||||
"You have succesfully been updater to v${newConfig.version}.",
|
||||
AnnouncementType.SESSION,
|
||||
null, "updates", null, null,
|
||||
null, null,null,
|
||||
newConfig?.absoluteIconUrl?.let { ImageVariable.fromUrl(it) }
|
||||
).withExtraAction("Changelog", StateAnnouncement.ACTION_CHANGELOG, newConfig.id));
|
||||
}
|
||||
|
||||
fun registerLoading(title: String, description: String, icon: ImageVariable? = null, customId: String? = null): SessionAnnouncement {
|
||||
val id = "loading-" + UUID.randomUUID().toString();
|
||||
val announcement = SessionAnnouncement(
|
||||
customId ?: id,
|
||||
title,
|
||||
description,
|
||||
AnnouncementType.ONGOING,
|
||||
null, "loading", null, null,
|
||||
null, null,null, icon
|
||||
);
|
||||
registerAnnouncementSession(announcement);
|
||||
return announcement;
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun getVisibleAnnouncements(category: String? = null): List<Announcement> {
|
||||
synchronized(_lock) {
|
||||
if (category != null) {
|
||||
@@ -122,7 +173,9 @@ class StateAnnouncement {
|
||||
}
|
||||
}
|
||||
|
||||
fun closeAnnouncement(id: String) {
|
||||
fun closeAnnouncement(id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
val item: Announcement?;
|
||||
synchronized(_lock) {
|
||||
item = _announcementsStore.findItem { it.id == id };
|
||||
@@ -164,6 +217,7 @@ class StateAnnouncement {
|
||||
cancelAction?.invoke(item);
|
||||
}
|
||||
}
|
||||
onAnnouncementChanged?.emit();
|
||||
}
|
||||
|
||||
fun deleteAllAnnouncements() {
|
||||
@@ -194,7 +248,9 @@ class StateAnnouncement {
|
||||
|
||||
onAnnouncementChanged.emit();
|
||||
}
|
||||
fun neverAnnouncement(id: String) {
|
||||
fun neverAnnouncement(id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
synchronized(_lock) {
|
||||
val item = _announcementsStore.findItem { it.id == id };
|
||||
if (item != null && !_announcementsNever.contains(id))
|
||||
@@ -208,19 +264,26 @@ class StateAnnouncement {
|
||||
_announcementsNever.save();
|
||||
onAnnouncementChanged.emit();
|
||||
}
|
||||
fun actionAnnouncement(id: String) {
|
||||
fun actionAnnouncement(id: String?, extra: Boolean = false) {
|
||||
if(id == null)
|
||||
return;
|
||||
val item = _announcementsStore.findItem { it.id == id } ?: _sessionAnnouncements[id];
|
||||
if(item != null)
|
||||
actionAnnouncement(item);
|
||||
actionAnnouncement(item, extra);
|
||||
}
|
||||
fun actionAnnouncement(item: Announcement) {
|
||||
fun actionAnnouncement(item: Announcement, extra: Boolean = false) {
|
||||
val actionId = if(!extra) item.actionId else if(item is SessionAnnouncement) item.extraActionId else null;
|
||||
val actionData = if(!extra) item.actionData else if(item is SessionAnnouncement) item.extraActionData else null;
|
||||
|
||||
val action = _sessionActions[item.id];
|
||||
if (action != null) {
|
||||
action(item);
|
||||
} else {
|
||||
when (item.actionId) {
|
||||
when (actionId) {
|
||||
ACTION_NEVER -> neverAnnouncement(item.id);
|
||||
ACTION_SOMETHING -> actionSomething();
|
||||
ACTION_CHANGELOG -> actionChangelog(actionData);
|
||||
ACTION_UPDATE_PLUGIN -> actionUpdatePlugin(item.id, actionData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,6 +314,84 @@ class StateAnnouncement {
|
||||
|
||||
}
|
||||
|
||||
private fun actionChangelog(id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
|
||||
StateApp.instance.contextOrNull?.let { context ->
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlugins.instance.getPlugin(id);
|
||||
if (plugin == null)
|
||||
return@launch
|
||||
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||
if(update == null)
|
||||
return@launch;
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.showChangelogDialog(context, update.version, update.changelog!!.filterKeys { it.toIntOrNull() != null }
|
||||
.mapKeys { it.key.toInt() }
|
||||
.mapValues { update.getChangelogString(it.key.toString()) ?: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun actionUpdatePlugin(notifId: String?, id: String?) {
|
||||
if(id == null)
|
||||
return;
|
||||
val plugin = StatePlugins.instance.getPlugin(id);
|
||||
if (plugin == null)
|
||||
return
|
||||
|
||||
closeAnnouncement(notifId);
|
||||
val loadingAnnouncement = registerLoading("Updating ${plugin.config.name}..", "An update is in progress for ${plugin.config.name}.",
|
||||
if(plugin.config.absoluteIconUrl != null) ImageVariable.fromUrl(plugin.config.absoluteIconUrl!!) else null);
|
||||
|
||||
val loadingId = loadingAnnouncement.id;
|
||||
|
||||
StateApp.instance.contextOrNull?.let { context ->
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val update = StatePlugins.instance.checkForUpdates(plugin.config);
|
||||
if (update == null)
|
||||
return@launch;
|
||||
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
val script = StatePlugins.instance.getScript(plugin.config.id) ?: "";
|
||||
val newScript = client.get(update.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
if(true || plugin.config.isLowRiskUpdate(script, update, newScript)) {
|
||||
StatePlugins.instance.installPluginBackground(context, StateApp.instance.scope, update, newScript,
|
||||
{ text: String, progress: Double -> },
|
||||
{ ex ->
|
||||
if(ex == null) {
|
||||
registerPluginUpdated(update);
|
||||
}
|
||||
else {
|
||||
UIDialogs.appToast("Update for ${update.name} failed\n" + ex.message);
|
||||
}
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
closeAnnouncement(loadingId);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
closeAnnouncement(loadingId);
|
||||
UIDialogs.showPluginUpdateDialog(context, plugin.config, update);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to trigger update from announcement", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun registerDefaultHandlerAnnouncement() {
|
||||
registerAnnouncement(
|
||||
"default-url-handler",
|
||||
@@ -279,6 +420,8 @@ class StateAnnouncement {
|
||||
|
||||
|
||||
const val ACTION_SOMETHING = "SOMETHING";
|
||||
const val ACTION_CHANGELOG = "CHANGELOG";
|
||||
const val ACTION_UPDATE_PLUGIN = "UPDATE_PLUGIN";
|
||||
const val ACTION_NEVER = "NEVER";
|
||||
private const val TAG = "StateAnnouncement";
|
||||
}
|
||||
@@ -294,7 +437,8 @@ open class Announcement(
|
||||
val time: OffsetDateTime? = null,
|
||||
val category: String? = null,
|
||||
val actionName: String? = null,
|
||||
val actionId: String? = null
|
||||
val actionId: String? = null,
|
||||
val actionData: String? = null
|
||||
);
|
||||
class SessionAnnouncement(
|
||||
id: String,
|
||||
@@ -306,7 +450,9 @@ class SessionAnnouncement(
|
||||
actionName: String? = null,
|
||||
actionId: String? = null,
|
||||
val cancelName: String? = null,
|
||||
val cancelActionId: String? = null
|
||||
val cancelActionId: String? = null,
|
||||
actionData: String? = null,
|
||||
val icon: ImageVariable? = null
|
||||
): Announcement(
|
||||
id= id,
|
||||
title = title,
|
||||
@@ -315,13 +461,40 @@ class SessionAnnouncement(
|
||||
time = time,
|
||||
category = category,
|
||||
actionName = actionName,
|
||||
actionId = actionId
|
||||
);
|
||||
actionId = actionId,
|
||||
actionData = actionData
|
||||
) {
|
||||
var extraActionName: String? = null;
|
||||
var extraActionId: String? = null;
|
||||
var extraActionData: String? = null;
|
||||
|
||||
var extraObj: Any? = null;
|
||||
|
||||
var progress: Double? = null;
|
||||
val onProgressChanged = Event1<SessionAnnouncement>();
|
||||
|
||||
fun withExtraAction(name: String, id: String, data: String? = null): SessionAnnouncement {
|
||||
extraActionName = name;
|
||||
extraActionId = id;
|
||||
extraActionData = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
fun setProgress(progress: Double) {
|
||||
this.progress = progress;
|
||||
onProgressChanged?.emit(this);
|
||||
}
|
||||
fun setProgress(progress: Int) {
|
||||
this.progress = progress.toDouble().div(100);
|
||||
onProgressChanged?.emit(this);
|
||||
}
|
||||
}
|
||||
|
||||
enum class AnnouncementType(val value : Int) {
|
||||
DELETABLE(0), //Close button deletes announcement (generally for actions)
|
||||
RECURRING(1), //Shows up till never is pressed (generally for patchnotes etc)
|
||||
PERMANENT(2), //Shows up until deleted through other means (action)
|
||||
SESSION(3), //Not persistent, only during this session
|
||||
SESSION_RECURRING(4); //Not persistent, only during this session, recurring id
|
||||
SESSION_RECURRING(4), //Not persistent, only during this session, recurring id
|
||||
ONGOING(5);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import com.futo.platformplayer.logging.AndroidLogConsumer
|
||||
import com.futo.platformplayer.logging.FileLogConsumer
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.receivers.AudioNoisyReceiver
|
||||
import com.futo.platformplayer.services.DownloadService
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
@@ -732,8 +733,10 @@ class StateApp {
|
||||
));
|
||||
|
||||
for(update in updateAvailable)
|
||||
if(StatePlatform.instance.isClientEnabled(update.first.id))
|
||||
UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
||||
if(StatePlatform.instance.isClientEnabled(update.first.id)) {
|
||||
//UIDialogs.showPluginUpdateDialog(context, update.first, update.second);
|
||||
StateAnnouncement.instance.registerPluginUpdate(update.first, update.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class StatePlugins {
|
||||
_updatesAvailableMap = updatesAvailableFor
|
||||
return@withContext configs;
|
||||
}
|
||||
private suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
||||
suspend fun checkForUpdates(c: SourcePluginConfig): SourcePluginConfig? = withContext(Dispatchers.IO) {
|
||||
val sourceUrl = c.sourceUrl ?: return@withContext null;
|
||||
|
||||
Logger.i(TAG, "Check for source updates '${c.name}'.");
|
||||
|
||||
@@ -113,7 +113,10 @@ class StateUpdate {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
return File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk");
|
||||
//if(result.exists())
|
||||
// result.delete();
|
||||
return result;
|
||||
}
|
||||
|
||||
fun getPartialApkFile(context: Context, version: Int): File {
|
||||
@@ -121,7 +124,10 @@ class StateUpdate {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||
val result = File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
|
||||
//if(result.exists())
|
||||
// result.delete();
|
||||
return result;
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Announcement
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
@@ -162,6 +160,10 @@ class AnnouncementView : LinearLayout {
|
||||
_textClose.visibility = View.VISIBLE;
|
||||
_textNever.visibility = View.VISIBLE;
|
||||
}
|
||||
AnnouncementType.ONGOING -> {
|
||||
_textClose.visibility = View.GONE;
|
||||
_textNever.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
if (announcement.time != null) {
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package com.futo.platformplayer.views.notification
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.Announcement
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.SessionAnnouncement
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NotificationOverlayView: ConstraintLayout {
|
||||
|
||||
lateinit var recycler: RecyclerView;
|
||||
var adapterNotifications: AnyAdapterView<Announcement, ViewHolder>;
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
inflate(context, R.layout.overlay_notifications, this)
|
||||
|
||||
recycler = findViewById<RecyclerView>(R.id.container_notifications);
|
||||
adapterNotifications = recycler.asAny<Announcement, ViewHolder>(RecyclerView.VERTICAL, false, {
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any?) {
|
||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
adapterNotifications.adapter.setData(announcements);
|
||||
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
Logger.i("NotificationOverlayView", "Announcements Changed");
|
||||
val adapter = adapterNotifications;
|
||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
adapter.adapter.setData(announcements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
StateAnnouncement.instance.onAnnouncementChanged.remove(this);
|
||||
}
|
||||
|
||||
class ViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Announcement>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(
|
||||
R.layout.list_announcement,
|
||||
_viewGroup, false)) {
|
||||
|
||||
protected var _announcement: Announcement? = null;
|
||||
protected val _textName: TextView
|
||||
protected val _textMetadata: TextView;
|
||||
protected val _icon: ImageView;
|
||||
protected val _buttonIgnore: ImageView
|
||||
protected val _buttonNever: LinearLayout
|
||||
protected val _buttonAction: LinearLayout
|
||||
protected val _buttonActionText: TextView
|
||||
protected val _buttonExtra: LinearLayout
|
||||
protected val _buttonExtraText: TextView
|
||||
protected val _loader: LoaderView;
|
||||
protected val _progress: ProgressBar;
|
||||
|
||||
init {
|
||||
_textName = _view.findViewById(R.id.text_name);
|
||||
_textMetadata = _view.findViewById(R.id.text_metadata);
|
||||
_buttonIgnore = _view.findViewById(R.id.button_ignore);
|
||||
_buttonNever = _view.findViewById(R.id.button_never);
|
||||
_buttonAction = _view.findViewById(R.id.button_action);
|
||||
_buttonActionText = _view.findViewById(R.id.button_action_text);
|
||||
_buttonExtra = _view.findViewById(R.id.button_extra);
|
||||
_buttonExtraText = _view.findViewById(R.id.button_extra_text);
|
||||
_icon = _view.findViewById(R.id.icon);
|
||||
_loader = _view.findViewById(R.id.loader);
|
||||
_progress = _view.findViewById(R.id.progress);
|
||||
|
||||
_buttonIgnore.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.closeAnnouncement(it?.id);
|
||||
}
|
||||
}
|
||||
_buttonNever.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.neverAnnouncement(it?.id);
|
||||
}
|
||||
}
|
||||
_buttonExtra.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.actionAnnouncement(it?.id, true)
|
||||
}
|
||||
}
|
||||
_buttonAction.setOnClickListener {
|
||||
_announcement.let {
|
||||
StateAnnouncement.instance.actionAnnouncement(it?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun bind(value: Announcement) {
|
||||
val oldAnnouncement = _announcement;
|
||||
_announcement = value;
|
||||
|
||||
if(oldAnnouncement is SessionAnnouncement)
|
||||
oldAnnouncement.onProgressChanged.clear();
|
||||
|
||||
_textName.text = value.title;
|
||||
_textMetadata.text = value.msg;
|
||||
|
||||
if(value is SessionAnnouncement) {
|
||||
if(value.icon != null) {
|
||||
value.icon.setImageView(_icon);
|
||||
_icon.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_icon.visibility = View.GONE;
|
||||
if(value.extraActionName != null && value.extraActionId != null) {
|
||||
_buttonExtraText.text = value.extraActionName;
|
||||
_buttonExtra.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_buttonExtra.visibility = View.GONE;
|
||||
|
||||
if(value.announceType == AnnouncementType.ONGOING) {
|
||||
_buttonIgnore.visibility = View.GONE;
|
||||
}
|
||||
else {
|
||||
_buttonIgnore.visibility = View.VISIBLE;
|
||||
}
|
||||
if(value.progress != null && value.announceType == AnnouncementType.ONGOING) {
|
||||
_progress.isVisible = true;
|
||||
_progress.min = 0;
|
||||
_progress.max = 100;
|
||||
value.onProgressChanged.subscribe {
|
||||
val prog = it.progress;
|
||||
if(prog == 0.toDouble() || prog == 100.toDouble()) {
|
||||
_progress.isIndeterminate = true;
|
||||
}
|
||||
else {
|
||||
_progress.isIndeterminate = false;
|
||||
_progress.setProgress(it.progress?.times(100)?.toInt() ?: 0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
_progress.isVisible = false;
|
||||
}
|
||||
else {
|
||||
_buttonExtra.visibility = View.GONE;
|
||||
_icon.visibility = View.GONE;
|
||||
_buttonIgnore.visibility = View.VISIBLE;
|
||||
}
|
||||
|
||||
if(value.announceType == AnnouncementType.ONGOING) {
|
||||
_loader.visibility = View.VISIBLE;
|
||||
_loader.start();
|
||||
}
|
||||
else {
|
||||
_loader.visibility = View.GONE;
|
||||
_loader.stop();
|
||||
}
|
||||
|
||||
_buttonNever.visibility =
|
||||
if (value.announceType == AnnouncementType.RECURRING || value.announceType == AnnouncementType.SESSION_RECURRING)
|
||||
View.VISIBLE
|
||||
else
|
||||
View.GONE;
|
||||
|
||||
_buttonAction.visibility =
|
||||
if(value.actionId != null && value.actionName != null)
|
||||
View.VISIBLE;
|
||||
else View.GONE;
|
||||
|
||||
if(value.actionId != null && value.actionName != null) {
|
||||
_buttonActionText.text = value.actionName;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Frag : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _view: NotificationOverlayView? = null;
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
_view?.onShown(parameter);
|
||||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = NotificationOverlayView(requireContext());
|
||||
_view = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_view = null;
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
_view?.onResume();
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
_view?.onPause();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#2D63ED" />
|
||||
<corners android:radius="20dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880Z"/>
|
||||
</vector>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
<LinearLayout
|
||||
android:id="@+id/root"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@@ -11,4 +12,4 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</LinearLayout>
|
||||
@@ -38,11 +38,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!--
|
||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
android:id="@+id/announcement_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
android:visibility="gone" /> -->
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_sort_by"
|
||||
|
||||
@@ -46,6 +46,42 @@
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_cast_white_25dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/button_notifs">
|
||||
<ImageButton
|
||||
android:id="@+id/button_notifs_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/cd_button_notifs"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="11dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:clickable="false"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:srcCompat="@drawable/ic_notifications" />
|
||||
<TextView
|
||||
android:id="@+id/button_notifs_count"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:text="5"
|
||||
android:textSize="12dp"
|
||||
android:textAlignment="center"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:background="@drawable/background_primary_round_20dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:clickable="false"
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!--Back Button-->
|
||||
<ImageButton
|
||||
android:id="@+id/button_search"
|
||||
|
||||
@@ -30,11 +30,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!--
|
||||
<com.futo.platformplayer.views.announcements.AnnouncementView
|
||||
android:id="@+id/announcement_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
android:visibility="gone" /> -->
|
||||
|
||||
<com.futo.platformplayer.views.others.RadioGroupView
|
||||
android:id="@+id/radio_group"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="13dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
tools:text="Example Artist"
|
||||
android:maxLines="1"
|
||||
app:layout_constraintLeft_toRightOf="@id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_ignore"
|
||||
android:layout_marginRight="20dp"
|
||||
android:layout_marginTop="10dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/text_metadata"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_metadata"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="12dp"
|
||||
android:textColor="#888888"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
tools:text="3 videos"
|
||||
android:maxLines="2"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_name"
|
||||
app:layout_constraintLeft_toRightOf="@id/icon"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_ignore"
|
||||
android:layout_marginRight="20dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:layout_marginStart="10dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/button_ignore"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:src="@drawable/ic_close"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||
|
||||
<com.futo.platformplayer.views.LoaderView
|
||||
android:id="@+id/loader"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/text_metadata" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_metadata"
|
||||
app:layout_constraintBottom_toTopOf="@id/separator"
|
||||
android:gravity="center"
|
||||
android:paddingBottom="10dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_never"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:text="Never" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/button_extra"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_button_accent"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp">
|
||||
<TextView
|
||||
android:id="@+id/button_extra_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:text="Extra" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/button_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:background="@drawable/background_button_primary"
|
||||
android:clickable="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/button_action_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Action"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingEnd="28dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:progressTint="@color/primary"
|
||||
/>
|
||||
|
||||
<View
|
||||
android:id="@+id/separator"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/container_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:background="#181818" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<View
|
||||
android:id="@+id/overlay_slide_up_menu_background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#C9000000" />
|
||||
|
||||
<View
|
||||
android:id="@+id/separator"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:background="#181818" />
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/container_notifications"
|
||||
app:layout_constraintTop_toBottomOf="@id/separator"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -896,6 +896,7 @@
|
||||
<string name="cd_creator_thumbnail">Creator thumbnail</string>
|
||||
<string name="cd_button_clear_search">Clear search</string>
|
||||
<string name="cd_button_search">Search</string>
|
||||
<string name="cd_button_notifs">Notifications</string>
|
||||
<string name="cd_search_icon">Search icon</string>
|
||||
<string name="cd_button_back">Back button</string>
|
||||
<string name="cd_app_icon">App icon</string>
|
||||
@@ -1112,11 +1113,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Submodule app/src/stable/assets/sources/bilibili updated: b153339c93...9186672f0f
Submodule app/src/stable/assets/sources/dailymotion updated: d95df7dca2...70f625a3bd
Submodule app/src/stable/assets/sources/kick updated: 96503584d9...5cae761620
Submodule app/src/stable/assets/sources/rumble updated: 3b51471010...34e0a15016
Submodule app/src/stable/assets/sources/soundcloud updated: 49db9e3e15...e785c5d8c9
Submodule app/src/stable/assets/sources/youtube updated: 079dc6e3dc...8fa41bceca
@@ -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>
|
||||
|
||||
Submodule app/src/unstable/assets/sources/bilibili updated: b153339c93...9186672f0f
Submodule app/src/unstable/assets/sources/dailymotion updated: d95df7dca2...70f625a3bd
Submodule app/src/unstable/assets/sources/kick updated: 96503584d9...5cae761620
Submodule app/src/unstable/assets/sources/rumble updated: 3b51471010...34e0a15016
Submodule app/src/unstable/assets/sources/soundcloud updated: 49db9e3e15...e785c5d8c9
Submodule app/src/unstable/assets/sources/youtube updated: 079dc6e3dc...8fa41bceca
Reference in New Issue
Block a user