mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-09-25 18:59:04 +00:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
75e97ed008
11 changed files with 421 additions and 178 deletions
|
@ -56,7 +56,7 @@
|
|||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleInstance"
|
||||
|
|
|
@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
|
|||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InterfaceAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.time.OffsetDateTime
|
||||
|
@ -331,4 +337,98 @@ fun ByteArray.fromGzip(): ByteArray {
|
|||
}
|
||||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
fun findPreferredAddress(): InetAddress? {
|
||||
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||
.toList()
|
||||
.asSequence()
|
||||
.filter(::isUsableInterface)
|
||||
.flatMap { nif ->
|
||||
nif.interfaceAddresses
|
||||
.asSequence()
|
||||
.mapNotNull { ia ->
|
||||
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||
nif to ia
|
||||
}
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
return candidates
|
||||
.minWithOrNull(
|
||||
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||
{ addressScore(it.second.address) },
|
||||
{ interfaceScore(it.first) },
|
||||
{ -it.second.networkPrefixLength.toInt() },
|
||||
{ -it.first.mtu }
|
||||
)
|
||||
)?.second?.address
|
||||
}
|
||||
|
||||
private fun isUsableInterface(nif: NetworkInterface): Boolean {
|
||||
val name = nif.name.lowercase()
|
||||
return try {
|
||||
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
|
||||
nif.isUp
|
||||
&& !nif.isLoopback
|
||||
&& !nif.isPointToPoint
|
||||
&& !nif.isVirtual
|
||||
&& !name.startsWith("docker")
|
||||
&& !name.startsWith("veth")
|
||||
&& !name.startsWith("br-")
|
||||
&& !name.startsWith("virbr")
|
||||
&& !name.startsWith("vmnet")
|
||||
&& !name.startsWith("tun")
|
||||
&& !name.startsWith("tap")
|
||||
} catch (e: SocketException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isUsableAddress(addr: InetAddress): Boolean {
|
||||
return when {
|
||||
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
|
||||
addr.isLoopbackAddress -> false
|
||||
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
|
||||
addr.isMulticastAddress -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun interfaceScore(nif: NetworkInterface): Int {
|
||||
val name = nif.name.lowercase()
|
||||
return when {
|
||||
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
|
||||
name.startsWith("eth") || name.contains("ethernet") -> 0
|
||||
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
|
||||
name.contains("wi-fi") || name.contains("wifi") -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun addressScore(addr: InetAddress): Int {
|
||||
return when (addr) {
|
||||
is Inet4Address -> {
|
||||
val octets = addr.address.map { it.toInt() and 0xFF }
|
||||
when {
|
||||
octets[0] == 10 -> 0 // 10/8
|
||||
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
|
||||
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.16–31/12
|
||||
else -> 1 // public IPv4
|
||||
}
|
||||
}
|
||||
is Inet6Address -> {
|
||||
// ULA (fc00::/7) vs global vs others
|
||||
val b0 = addr.address[0].toInt() and 0xFF
|
||||
when {
|
||||
(b0 and 0xFE) == 0xFC -> 2 // ULA
|
||||
(b0 and 0xE0) == 0x20 -> 3 // global
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
|
@ -33,6 +33,8 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.whenStateAtLeast
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -203,7 +205,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
lifecycleScope.launch {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -280,7 +282,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
try {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
|
@ -707,7 +713,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
}
|
||||
|
||||
"IMPORT_OPTIONS" -> {
|
||||
|
@ -725,11 +731,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
"Sources" -> {
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
||||
navigate(_fragMainSources);
|
||||
navigateWhenReady(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"BROWSE_PLUGINS" -> {
|
||||
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if (it is MainActivity) {
|
||||
|
@ -747,8 +753,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
runBlocking {
|
||||
handleUrlAll(targetData)
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -776,10 +786,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
startActivity(intent);
|
||||
} else if (url.startsWith("grayjay://video/")) {
|
||||
val videoUrl = url.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
navigateWhenReady(_fragVideoDetail, videoUrl);
|
||||
} else if (url.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = url.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
navigateWhenReady(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -847,27 +857,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
Logger.i(TAG, "handleUrl(url=$url) on IO");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (position > 0)
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
else
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainRemotePlaylist, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainRemotePlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
|
@ -1094,6 +1104,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
return fragCurrent is T;
|
||||
}
|
||||
|
||||
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.widget.LinearLayout
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
StateSync.instance.confirmStarted(this, {
|
||||
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||
}, {
|
||||
StateSync.instance.confirmStarted(this, onStarted = {
|
||||
if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
|
||||
UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
|
||||
}
|
||||
if (StateSync.instance.syncService?.relayConnected == false) {
|
||||
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
|
||||
}
|
||||
if (StateSync.instance.syncService?.serverSocketStarted == false) {
|
||||
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
|
||||
}
|
||||
}, onNotStarted = {
|
||||
finish()
|
||||
}, {
|
||||
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,9 @@ 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
|
||||
|
@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
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
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
|
@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
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() {
|
||||
|
@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
|
@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
@ -393,7 +402,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
synchronized(_inputStreamLock)
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
|
@ -405,7 +414,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
|
@ -414,15 +423,19 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
//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 message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
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) {
|
||||
|
@ -512,6 +525,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
|
@ -526,21 +540,40 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
_sessionId = null;
|
||||
_mediaSessionId = null;
|
||||
setTime(0.0);
|
||||
_transportId = null;
|
||||
Logger.w(TAG, "Session not found.");
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_launching) {
|
||||
Logger.i(TAG, "Player not found, launching.");
|
||||
launchPlayer();
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
// 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 after $_launchRetries attempts; giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Player not found, disconnecting.");
|
||||
stop();
|
||||
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;
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
|
@ -582,6 +615,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -616,6 +651,9 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ import android.os.Build
|
|||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Inet4Address
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -41,6 +43,7 @@ import com.futo.platformplayer.builders.DashBuilder
|
|||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.findPreferredAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
|
@ -55,9 +58,11 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
|
||||
class StateCasting {
|
||||
|
@ -483,7 +488,7 @@ class StateCasting {
|
|||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
|
@ -578,7 +583,7 @@ class StateCasting {
|
|||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
|
@ -597,7 +602,7 @@ class StateCasting {
|
|||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
|
@ -616,7 +621,7 @@ class StateCasting {
|
|||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||
val url = getLocalUrl(ad)
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -712,7 +717,7 @@ class StateCasting {
|
|||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -762,7 +767,7 @@ class StateCasting {
|
|||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val videoPath = "/video-${id}"
|
||||
|
@ -827,7 +832,7 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -997,7 +1002,7 @@ class StateCasting {
|
|||
|
||||
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -1127,7 +1132,7 @@ class StateCasting {
|
|||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -1213,6 +1218,15 @@ class StateCasting {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getLocalUrl(ad: CastingDevice): String {
|
||||
var address = ad.localAddress!!
|
||||
if (address is Inet6Address && address.isLinkLocalAddress) {
|
||||
address = findPreferredAddress() ?: address
|
||||
Logger.i(TAG, "Selected casting address: $address")
|
||||
}
|
||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
@ -1220,7 +1234,7 @@ class StateCasting {
|
|||
cleanExecutors()
|
||||
_castServer.removeAllHandlers("castDashRaw")
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
|
|
@ -176,6 +176,11 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
}
|
||||
|
||||
private fun updateSearchViewVisibility() {
|
||||
if (subType != null) {
|
||||
_searchView?.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
|
||||
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
|
||||
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
|
||||
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
|
||||
|
|
|
@ -412,24 +412,12 @@ class StateApp {
|
|||
}
|
||||
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context, {
|
||||
try {
|
||||
UIDialogs.toast("Failed to start sync, port in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
})
|
||||
StateSync.instance.start(context)
|
||||
}
|
||||
|
||||
settingsActivityClosed.subscribe {
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context, {
|
||||
try {
|
||||
UIDialogs.toast("Failed to start sync, port in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
})
|
||||
StateSync.instance.start(context)
|
||||
} else {
|
||||
StateSync.instance.stop()
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ class StateSync {
|
|||
val deviceRemoved: Event1<String> = Event1()
|
||||
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
|
||||
|
||||
fun start(context: Context, onServerBindFail: () -> Unit) {
|
||||
fun start(context: Context) {
|
||||
if (syncService != null) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
|
@ -150,24 +150,14 @@ class StateSync {
|
|||
}
|
||||
}
|
||||
|
||||
syncService?.start(context, onServerBindFail)
|
||||
syncService?.start(context)
|
||||
}
|
||||
|
||||
fun showFailedToBindDialogIfNecessary(context: Context) {
|
||||
if (syncService?.serverSocketFailedToStart == true && Settings.instance.synchronization.localConnections) {
|
||||
try {
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) {
|
||||
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) {
|
||||
if (syncService == null) {
|
||||
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
|
||||
Settings.instance.synchronization.enabled = true
|
||||
start(context, onServerBindFail)
|
||||
start(context)
|
||||
Settings.instance.save()
|
||||
onStarted.invoke()
|
||||
}, {
|
||||
|
|
|
@ -72,6 +72,8 @@ class SyncService(
|
|||
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
|
||||
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
|
||||
var serverSocketFailedToStart = false
|
||||
var serverSocketStarted = false
|
||||
var relayConnected = false
|
||||
//TODO: Should sync mdns and casting mdns be merged?
|
||||
//TODO: Decrease interval that devices are updated
|
||||
//TODO: Send less data
|
||||
|
@ -212,7 +214,7 @@ class SyncService(
|
|||
var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null
|
||||
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
|
||||
|
||||
fun start(context: Context, onServerBindFail: (() -> Unit)? = null) {
|
||||
fun start(context: Context) {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
|
@ -273,10 +275,12 @@ class SyncService(
|
|||
|
||||
Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)")
|
||||
|
||||
serverSocketStarted = false
|
||||
if (settings.bindListener) {
|
||||
startListener(onServerBindFail)
|
||||
startListener()
|
||||
}
|
||||
|
||||
relayConnected = false
|
||||
if (settings.relayEnabled) {
|
||||
startRelayLoop()
|
||||
}
|
||||
|
@ -286,13 +290,15 @@ class SyncService(
|
|||
}
|
||||
}
|
||||
|
||||
private fun startListener(onServerBindFail: (() -> Unit)? = null) {
|
||||
private fun startListener() {
|
||||
serverSocketFailedToStart = false
|
||||
serverSocketStarted = false
|
||||
_thread = Thread {
|
||||
try {
|
||||
val serverSocket = ServerSocket(settings.listenerPort)
|
||||
_serverSocket = serverSocket
|
||||
|
||||
serverSocketStarted = true
|
||||
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
|
||||
|
||||
while (_started) {
|
||||
|
@ -300,10 +306,12 @@ class SyncService(
|
|||
val session = createSocketSession(socket, true)
|
||||
session.startAsResponder()
|
||||
}
|
||||
|
||||
serverSocketStarted = false
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
|
||||
serverSocketFailedToStart = true
|
||||
onServerBindFail?.invoke()
|
||||
serverSocketStarted = false
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
@ -386,121 +394,192 @@ class SyncService(
|
|||
}
|
||||
|
||||
private fun startRelayLoop() {
|
||||
relayConnected = false
|
||||
_threadRelay = Thread {
|
||||
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
|
||||
var backoffIndex = 0;
|
||||
try {
|
||||
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
|
||||
var backoffIndex = 0;
|
||||
|
||||
while (_started) {
|
||||
try {
|
||||
Log.i(TAG, "Starting relay session...")
|
||||
while (_started) {
|
||||
try {
|
||||
Log.i(TAG, "Starting relay session...")
|
||||
relayConnected = false
|
||||
|
||||
var socketClosed = false;
|
||||
val socket = Socket(relayServer, 9000)
|
||||
_relaySession = SyncSocketSession(
|
||||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
||||
keyPair!!,
|
||||
socket,
|
||||
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) },
|
||||
onNewChannel = { _, c ->
|
||||
val remotePublicKey = c.remotePublicKey
|
||||
if (remotePublicKey == null) {
|
||||
Log.e(TAG, "Remote public key should never be null in onNewChannel.")
|
||||
return@SyncSocketSession
|
||||
}
|
||||
|
||||
Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').")
|
||||
|
||||
var session: SyncSession?
|
||||
synchronized(_sessions) {
|
||||
session = _sessions[remotePublicKey]
|
||||
if (session == null) {
|
||||
val remoteDeviceName = database.getDeviceName(remotePublicKey)
|
||||
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
|
||||
_sessions[remotePublicKey] = session!!
|
||||
var socketClosed = false;
|
||||
val socket = Socket(relayServer, 9000)
|
||||
_relaySession = SyncSocketSession(
|
||||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
||||
keyPair!!,
|
||||
socket,
|
||||
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId ->
|
||||
isHandshakeAllowed(
|
||||
linkType,
|
||||
syncSocketSession,
|
||||
publicKey,
|
||||
pairingCode,
|
||||
appId
|
||||
)
|
||||
},
|
||||
onNewChannel = { _, c ->
|
||||
val remotePublicKey = c.remotePublicKey
|
||||
if (remotePublicKey == null) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Remote public key should never be null in onNewChannel."
|
||||
)
|
||||
return@SyncSocketSession
|
||||
}
|
||||
session!!.addChannel(c)
|
||||
}
|
||||
|
||||
c.setDataHandler { _, channel, opcode, subOpcode, data ->
|
||||
session?.handlePacket(opcode, subOpcode, data)
|
||||
}
|
||||
c.setCloseHandler { channel ->
|
||||
session?.removeChannel(channel)
|
||||
}
|
||||
},
|
||||
onChannelEstablished = { _, channel, isResponder ->
|
||||
handleAuthorization(channel, isResponder)
|
||||
},
|
||||
onClose = { socketClosed = true },
|
||||
onHandshakeComplete = { relaySession ->
|
||||
backoffIndex = 0
|
||||
Log.i(
|
||||
TAG,
|
||||
"New channel established from relay (pk: '$remotePublicKey')."
|
||||
)
|
||||
|
||||
Thread {
|
||||
try {
|
||||
while (_started && !socketClosed) {
|
||||
val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf()
|
||||
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed)
|
||||
var session: SyncSession?
|
||||
synchronized(_sessions) {
|
||||
session = _sessions[remotePublicKey]
|
||||
if (session == null) {
|
||||
val remoteDeviceName =
|
||||
database.getDeviceName(remotePublicKey)
|
||||
session =
|
||||
createNewSyncSession(remotePublicKey, remoteDeviceName)
|
||||
_sessions[remotePublicKey] = session!!
|
||||
}
|
||||
session!!.addChannel(c)
|
||||
}
|
||||
|
||||
Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information")
|
||||
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) }
|
||||
Logger.v(TAG, "Received ${connectionInfos.size} devices connection information")
|
||||
c.setDataHandler { _, channel, opcode, subOpcode, data ->
|
||||
session?.handlePacket(opcode, subOpcode, data)
|
||||
}
|
||||
c.setCloseHandler { channel ->
|
||||
session?.removeChannel(channel)
|
||||
}
|
||||
},
|
||||
onChannelEstablished = { _, channel, isResponder ->
|
||||
handleAuthorization(channel, isResponder)
|
||||
},
|
||||
onClose = { socketClosed = true },
|
||||
onHandshakeComplete = { relaySession ->
|
||||
backoffIndex = 0
|
||||
|
||||
for ((targetKey, connectionInfo) in connectionInfos) {
|
||||
val potentialLocalAddresses = connectionInfo.ipv4Addresses
|
||||
.filter { it != connectionInfo.remoteIp }
|
||||
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
|
||||
Thread {
|
||||
Thread {
|
||||
try {
|
||||
while (_started && !socketClosed) {
|
||||
val unconnectedAuthorizedDevices =
|
||||
database.getAllAuthorizedDevices()
|
||||
?.filter { !isConnected(it) }?.toTypedArray()
|
||||
?: arrayOf()
|
||||
relaySession.publishConnectionInformation(
|
||||
unconnectedAuthorizedDevices,
|
||||
settings.listenerPort,
|
||||
settings.relayConnectDirect,
|
||||
false,
|
||||
false,
|
||||
settings.relayConnectRelayed
|
||||
)
|
||||
|
||||
Logger.v(
|
||||
TAG,
|
||||
"Requesting ${unconnectedAuthorizedDevices.size} devices connection information"
|
||||
)
|
||||
val connectionInfos = runBlocking {
|
||||
relaySession.requestBulkConnectionInfo(
|
||||
unconnectedAuthorizedDevices
|
||||
)
|
||||
}
|
||||
Logger.v(
|
||||
TAG,
|
||||
"Received ${connectionInfos.size} devices connection information"
|
||||
)
|
||||
|
||||
for ((targetKey, connectionInfo) in connectionInfos) {
|
||||
val potentialLocalAddresses =
|
||||
connectionInfo.ipv4Addresses
|
||||
.filter { it != connectionInfo.remoteIp }
|
||||
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
|
||||
Thread {
|
||||
try {
|
||||
Log.v(
|
||||
TAG,
|
||||
"Attempting to connect directly, locally to '$targetKey'."
|
||||
)
|
||||
connect(
|
||||
potentialLocalAddresses.map { it }
|
||||
.toTypedArray(),
|
||||
settings.listenerPort,
|
||||
targetKey,
|
||||
null
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Failed to start direct connection using connection info with $targetKey.",
|
||||
e
|
||||
)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteDirect) {
|
||||
// TODO: Implement direct remote connection if needed
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteHolePunched) {
|
||||
// TODO: Implement hole punching if needed
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
|
||||
try {
|
||||
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.")
|
||||
connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null)
|
||||
Logger.v(
|
||||
TAG,
|
||||
"Attempting relayed connection with '$targetKey'."
|
||||
)
|
||||
runBlocking {
|
||||
relaySession.startRelayedChannel(
|
||||
targetKey,
|
||||
appId,
|
||||
null
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e)
|
||||
Logger.e(
|
||||
TAG,
|
||||
"Failed to start relayed channel with $targetKey.",
|
||||
e
|
||||
)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteDirect) {
|
||||
// TODO: Implement direct remote connection if needed
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteHolePunched) {
|
||||
// TODO: Implement hole punching if needed
|
||||
}
|
||||
|
||||
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
|
||||
try {
|
||||
Logger.v(TAG, "Attempting relayed connection with '$targetKey'.")
|
||||
runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e)
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(15000)
|
||||
}
|
||||
|
||||
Thread.sleep(15000)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in relay session.", e)
|
||||
relaySession.stop()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in relay session.", e)
|
||||
relaySession.stop()
|
||||
}
|
||||
}.start()
|
||||
}.start()
|
||||
}
|
||||
)
|
||||
|
||||
_relaySession!!.authorizable = object : IAuthorizable {
|
||||
override val isAuthorized: Boolean get() = true
|
||||
}
|
||||
)
|
||||
|
||||
_relaySession!!.authorizable = object : IAuthorizable {
|
||||
override val isAuthorized: Boolean get() = true
|
||||
relayConnected = true
|
||||
_relaySession!!.runAsInitiator(relayPublicKey, appId, null)
|
||||
|
||||
Log.i(TAG, "Started relay session.")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Relay session failed.", e)
|
||||
} finally {
|
||||
relayConnected = false
|
||||
_relaySession?.stop()
|
||||
_relaySession = null
|
||||
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
|
||||
}
|
||||
|
||||
_relaySession!!.runAsInitiator(relayPublicKey, appId, null)
|
||||
|
||||
Log.i(TAG, "Started relay session.")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Relay session failed.", e)
|
||||
} finally {
|
||||
_relaySession?.stop()
|
||||
_relaySession = null
|
||||
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Log.i(TAG, "Unhandled exception in relay loop.", ex)
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
|
|
@ -529,7 +529,7 @@ class SyncSocketSession {
|
|||
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
|
||||
if (!isAllowed) {
|
||||
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
rp.putInt(2) // Status code for not allowed
|
||||
rp.putInt(7) // Status code for not allowed
|
||||
rp.putLong(connectionId)
|
||||
rp.putInt(requestId)
|
||||
rp.rewind()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue