diff --git a/app/build.gradle b/app/build.gradle index 866a47dd..8d55d000 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,7 +197,7 @@ dependencies { implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.arthenica:ffmpeg-kit-full:5.1' + implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.google.zxing:core:3.4.1' diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 0c87cac4..b6b4ab6d 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -263,6 +263,10 @@ class PlatformVideoDetails extends PlatformVideo { this.rating = obj.rating ?? null; //IRating this.subtitles = obj.subtitles ?? []; this.isShort = !!obj.isShort ?? false; + + if (obj.getContentRecommendations) { + this.getContentRecommendations = obj.getContentRecommendations + } } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index 2d9e51da..d1cd7706 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -101,7 +101,8 @@ class SyncHomeActivity : AppCompatActivity() { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { val connected = session?.connected ?: false syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) - .setName(publicKey) + .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) + //TODO: also display public key? .setStatus(if (connected) "Connected" else "Disconnected") return syncDeviceView } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 147eb2c2..afda7722 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -579,6 +579,14 @@ class VideoDetailView : ConstraintLayout { _minimize_title.setOnClickListener { onMaximize.emit(false) }; _minimize_meta.setOnClickListener { onMaximize.emit(false) }; + _player.onStateChange.subscribe { + if (_player.activelyPlaying) { + Logger.i(TAG, "Play changed, resetting error counter _didTriggerDatasourceErrorCount = 0 (_player.activelyPlaying: ${_player.activelyPlaying})") + _didTriggerDatasourceErrorCount = 0; + _didTriggerDatasourceError = false; + } + } + _player.onPlayChanged.subscribe { if (StateCasting.instance.activeDevice == null) { handlePlayChanged(it); @@ -922,7 +930,7 @@ class VideoDetailView : ConstraintLayout { } else if(devices.size == 1){ val device = devices.first(); Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url) - UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , { + UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non '${device.displayName}'" , { Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url) fragment.lifecycleScope.launch(Dispatchers.IO) { @@ -963,6 +971,7 @@ class VideoDetailView : ConstraintLayout { throw IllegalStateException("Expected media content, found ${video.contentType}"); withContext(Dispatchers.Main) { + _videoResumePositionMilliseconds = _player.position setVideoDetails(video); } } @@ -1265,8 +1274,6 @@ class VideoDetailView : ConstraintLayout { @OptIn(ExperimentalCoroutinesApi::class) fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) { Logger.i(TAG, "setVideoDetails (${videoDetail.name})") - _didTriggerDatasourceErrroCount = 0; - _didTriggerDatasourceError = false; _autoplayVideo = null Logger.i(TAG, "Autoplay video cleared (setVideoDetails)") @@ -1277,6 +1284,10 @@ class VideoDetailView : ConstraintLayout { _lastVideoSource = null; _lastAudioSource = null; _lastSubtitleSource = null; + + Logger.i(TAG, "_didTriggerDatasourceErrorCount reset to 0 because new video") + _didTriggerDatasourceErrorCount = 0; + _didTriggerDatasourceError = false; } if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now()) @@ -1831,7 +1842,7 @@ class VideoDetailView : ConstraintLayout { } } - private var _didTriggerDatasourceErrroCount = 0; + private var _didTriggerDatasourceErrorCount = 0; private var _didTriggerDatasourceError = false; private fun onDataSourceError(exception: Throwable) { Logger.e(TAG, "onDataSourceError", exception); @@ -1841,32 +1852,53 @@ class VideoDetailView : ConstraintLayout { return; val config = currentVideo.sourceConfig; - if(_didTriggerDatasourceErrroCount <= 3) { + if(_didTriggerDatasourceErrorCount <= 3) { _didTriggerDatasourceError = true; - _didTriggerDatasourceErrroCount++; + _didTriggerDatasourceErrorCount++; + + UIDialogs.toast("Detected video error, attempting automatic reload (${_didTriggerDatasourceErrorCount})"); + Logger.i(TAG, "Block detected, attempting bypass (_didTriggerDatasourceErrorCount = ${_didTriggerDatasourceErrorCount})"); - UIDialogs.toast("Block detected, attempting bypass"); //return; fragment.lifecycleScope.launch(Dispatchers.IO) { - val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await(); - val previousVideoSource = _lastVideoSource; - val previousAudioSource = _lastAudioSource; + try { + val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await(); + val previousVideoSource = _lastVideoSource; + val previousAudioSource = _lastAudioSource; - if(newDetails is IPlatformVideoDetails) { - val newVideoSource = if(previousVideoSource != null) - VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS); - else null; - val newAudioSource = if(previousAudioSource != null) - VideoHelper.selectBestAudioSource(newDetails.video, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, previousAudioSource.language, previousAudioSource.bitrate.toLong()); - else null; - withContext(Dispatchers.Main) { - video = newDetails; - _player.setSource(newVideoSource, newAudioSource, true, true); + if (newDetails is IPlatformVideoDetails) { + val newVideoSource = if (previousVideoSource != null) + VideoHelper.selectBestVideoSource( + newDetails.video, + previousVideoSource.height * previousVideoSource.width, + FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS + ); + else null; + val newAudioSource = if (previousAudioSource != null) + VideoHelper.selectBestAudioSource( + newDetails.video, + FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, + previousAudioSource.language, + previousAudioSource.bitrate.toLong() + ); + else null; + withContext(Dispatchers.Main) { + video = newDetails; + _player.setSource(newVideoSource, newAudioSource, true, true); + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get video details, attempting retrying without reloading.", e) + fragment.lifecycleScope.launch(Dispatchers.Main) { + video?.let { + _videoResumePositionMilliseconds = _player.position + setVideoDetails(it, false) + } } } } } - else if(_didTriggerDatasourceErrroCount > 3) { + else if(_didTriggerDatasourceErrorCount > 3) { UIDialogs.showDialog(context, R.drawable.ic_error_pred, context.getString(R.string.media_error), context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental), diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 0197b856..96c25f9d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -44,6 +44,7 @@ import kotlin.system.measureTimeMillis class StateSync { private val _authorizedDevices = FragmentedStorage.get("authorized_devices") + private val _nameStorage = FragmentedStorage.get("sync_remembered_name_storage") private val _syncKeyPair = FragmentedStorage.get("sync_key_pair") private val _lastAddressStorage = FragmentedStorage.get("sync_last_address_storage") private val _syncSessionData = FragmentedStorage.get>("syncSessionData") @@ -305,12 +306,22 @@ class StateSync { synchronized(_sessions) { session = _sessions[s.remotePublicKey] if (session == null) { + val remoteDeviceName = synchronized(_nameStorage) { + _nameStorage.get(remotePublicKey) + } + session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> if (!isNewSession) { return@SyncSession } - Logger.i(TAG, "${s.remotePublicKey} authorized") + it.remoteDeviceName?.let { remoteDeviceName -> + synchronized(_nameStorage) { + _nameStorage.setAndSave(remotePublicKey, remoteDeviceName) + } + } + + Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})") synchronized(_lastAddressStorage) { _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) } @@ -341,7 +352,7 @@ class StateSync { deviceRemoved.emit(it.remotePublicKey) - }) + }, remoteDeviceName) _sessions[remotePublicKey] = session!! } session!!.addSocketSession(s) @@ -469,6 +480,12 @@ class StateSync { } } + fun getCachedName(publicKey: String): String? { + return synchronized(_nameStorage) { + _nameStorage.get(publicKey) + } + } + suspend fun delete(publicKey: String) { withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 6281ca23..8b5621e0 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -6,12 +6,10 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.smartMerge import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateHistory -import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions @@ -30,6 +28,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.nio.ByteBuffer +import java.nio.ByteOrder import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -53,6 +52,9 @@ class SyncSession : IAuthorizable { private val _id = UUID.randomUUID() private var _remoteId: UUID? = null private var _lastAuthorizedRemoteId: UUID? = null + var remoteDeviceName: String? = null + private set + val displayName: String get() = remoteDeviceName ?: remotePublicKey var connected: Boolean = false private set(v) { @@ -62,7 +64,7 @@ class SyncSession : IAuthorizable { } } - constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) { + constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) { this.remotePublicKey = remotePublicKey _onAuthorized = onAuthorized _onUnauthorized = onUnauthorized @@ -85,7 +87,20 @@ class SyncSession : IAuthorizable { fun authorize(socketSession: SyncSocketSession) { Logger.i(TAG, "Sent AUTHORIZED with session id $_id") - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) + + if (socketSession.remoteVersion >= 3) { + val idStringBytes = _id.toString().toByteArray() + val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray() + val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size) + socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply { + put(idStringBytes.size.toByte()) + put(idStringBytes) + put(nameBytes.size.toByte()) + put(nameBytes) + }.apply { flip() }) + } else { + socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) + } _authorized = true checkAuthorized() } @@ -138,15 +153,37 @@ class SyncSession : IAuthorizable { when (opcode) { Opcode.NOTIFY_AUTHORIZED.value -> { - val str = data.toUtf8String() - _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") + if (socketSession.remoteVersion >= 3) { + val idByteCount = data.get().toInt() + if (idByteCount > 64) + throw Exception("Id should always be smaller than 64 bytes") + + val idBytes = ByteArray(idByteCount) + data.get(idBytes) + + val nameByteCount = data.get().toInt() + if (nameByteCount > 64) + throw Exception("Name should always be smaller than 64 bytes") + + val nameBytes = ByteArray(nameByteCount) + data.get(nameBytes) + + _remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8)) + remoteDeviceName = nameBytes.toString(Charsets.UTF_8) + } else { + val str = data.toUtf8String() + _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") + remoteDeviceName = null + } + _remoteAuthorized = true - Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId") + Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") checkAuthorized() return } Opcode.NOTIFY_UNAUTHORIZED.value -> { _remoteId = null + remoteDeviceName = null _lastAuthorizedRemoteId = null _remoteAuthorized = false _onUnauthorized(this) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 4a1def91..c997cec4 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -46,6 +46,8 @@ class SyncSocketSession { val localPublicKey: String get() = _localPublicKey private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit var authorizable: IAuthorizable? = null + var remoteVersion: Int = -1 + private set val remoteAddress: String @@ -162,11 +164,12 @@ class SyncSocketSession { } private fun performVersionCheck() { - val CURRENT_VERSION = 2 + val CURRENT_VERSION = 3 + val MINIMUM_VERSION = 2 _outputStream.writeInt(CURRENT_VERSION) - val version = _inputStream.readInt() - Logger.i(TAG, "performVersionCheck (version = $version)") - if (version != CURRENT_VERSION) + remoteVersion = _inputStream.readInt() + Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") + if (remoteVersion < MINIMUM_VERSION) throw Exception("Invalid version") } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 61366bf5..c872ca02 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -96,6 +96,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val exoPlayerStateName: String; var playing: Boolean = false; + val activelyPlaying: Boolean get() = (exoPlayer?.player?.playbackState == Player.STATE_READY) && (exoPlayer?.player?.playWhenReady ?: false) val position: Long get() = exoPlayer?.player?.currentPosition ?: 0; val duration: Long get() = exoPlayer?.player?.duration ?: 0; @@ -829,7 +830,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss"); when (error.errorCode) { - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}"); if(error.cause is HttpDataSource.InvalidResponseCodeException) { val cause = error.cause as HttpDataSource.InvalidResponseCodeException diff --git a/docs/Authentication.md b/docs/Authentication.md index f21581a1..f54eced5 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -8,7 +8,7 @@ The goal of the authentication system is to provide plugins the ability to make > >You should always only login (and install for that matter) plugins you trust. -How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](_blank)). +How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](docs/packages/packageHttp.md)). This documentation will exclusively focus on configuring authentication and how it behaves. ## How it works @@ -58,5 +58,5 @@ Headers are exclusively applied to the domains they are retrieved from. A plugin By default, when authentication requests are made, the authenticated client will behave similar to that of a normal browser. Meaning that if the server you are communicating with sets new cookies, the client will use those cookies instead. These new cookies are NOT saved to disk, meaning that whenever that plugin reloads the cookies will revert to those assigned at login. This behavior can be modified by using custom http clients as described in the http package documentation. - (See [Package: Http](_blank)) + (See [Package: Http](docs/packages/packageHttp.md))