diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 173a6f10..00000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -aar/* filter=lfs diff=lfs merge=lfs -text -app/aar/* filter=lfs diff=lfs merge=lfs -text diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml similarity index 54% rename from .github/ISSUE_TEMPLATE/1-bug_report.yml rename to .github/ISSUE_TEMPLATE/bug_report.yml index e2108e3e..776ef91f 100644 --- a/.github/ISSUE_TEMPLATE/1-bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,9 +1,6 @@ name: Bug Report description: Let us know about an unexpected error, a crash, or an incorrect behavior. -labels: ["Bug", "Android"] -title: "Bug: " -type: bug -projects: ["futo-org/19"] +labels: ["Bug"] body: - type: markdown attributes: @@ -21,33 +18,11 @@ body: * if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown - type: textarea - id: reproduction-steps + id: what-happened attributes: - label: Reproduction steps - description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible. - placeholder: | - 0. Play a Youtube video - 1. Press on Download button - 2. Select quality 1440p - 3. Grayjay crashes when attempting to download - validations: - required: true - - - type: textarea - id: actual-result - attributes: - label: Actual result - description: What happend? - placeholder: Tell us what you saw! - validations: - required: true - - - type: textarea - id: expected-result - attributes: - label: Expected result - description: What was suppose to happen? - placeholder: Tell us what you expected to happen! + label: What happened? + description: What did you expect to happen? + placeholder: Tell us what you see! validations: required: true @@ -56,7 +31,7 @@ body: attributes: label: Grayjay Version description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name". - placeholder: "311" + placeholder: "242" validations: required: true @@ -67,23 +42,19 @@ body: multiple: true options: - "All" - - "Apple Podcasts" + - "Youtube" + - "Odysee" + - "Rumble" + - "Kick" + - "Twitch" + - "PeerTube" + - "Patreon" + - "Nebula" - "BiliBili (CN)" - "Bitchute" - - "Crunchyroll" - - "CuriosityStream" - - "Dailymotion" - - "Kick" - - "Nebula" - - "Odysee" - - "Patreon" - - "PeerTube" - - "Rumble" - "SoundCloud" - - "Spotify" - - "TedTalks" - - "Twitch" - - "Youtube" + - "Dailymotion" + - "Apple Podcasts" - "Other" validations: required: true @@ -95,30 +66,6 @@ body: description: In the application, select Sources > [the broken plugin], write down the value under "Version". placeholder: "12" - - type: input - id: android-version - attributes: - label: Which android version are you using? - placeholder: "Android 15" - validations: - required: true - - - type: input - id: phone-model - attributes: - label: Which device are you using? - placeholder: "Google Pixel 9" - validations: - required: true - - - type: input - id: os-version - attributes: - label: Which operating system are you using? - placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..." - validations: - required: true - - type: checkboxes id: login attributes: @@ -139,28 +86,9 @@ body: validations: required: true - - type: textarea - id: grayjay-references - attributes: - label: References - description: | - Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example: - ``` - - #10 - ``` - placeholder: - value: - validations: - required: false - - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell - - - type: markdown - attributes: - value: | - **Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue. diff --git a/.github/ISSUE_TEMPLATE/3-documentation_issue.yml b/.github/ISSUE_TEMPLATE/documentation_issue.yml similarity index 95% rename from .github/ISSUE_TEMPLATE/3-documentation_issue.yml rename to .github/ISSUE_TEMPLATE/documentation_issue.yml index 40d245dc..c416d012 100644 --- a/.github/ISSUE_TEMPLATE/3-documentation_issue.yml +++ b/.github/ISSUE_TEMPLATE/documentation_issue.yml @@ -1,16 +1,13 @@ name: Documentation Issue description: Report an issue or suggest a change in the documentation. labels: ["Documentation"] -title: "Documentation: " -type: task -projects: ["futo-org/19"] body: - type: markdown attributes: value: | # Thank you for opening a documentation change request. - The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app) + The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app) Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention. For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml similarity index 91% rename from .github/ISSUE_TEMPLATE/2-feature_request.yml rename to .github/ISSUE_TEMPLATE/feature_request.yml index 2058150f..ebba5241 100644 --- a/.github/ISSUE_TEMPLATE/2-feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,16 +1,13 @@ name: Feature Request description: Suggest a new feature or other enhancement. -labels: ["Enhancement", "Android"] -title: "Feature request: " -type: feature -projects: ["futo-org/19"] +labels: ["Enhancement"] body: - type: markdown attributes: value: | # Thank you for opening a feature request. - The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application + The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) diff --git a/.gitmodules b/.gitmodules index 00037939..c906834c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -94,15 +94,3 @@ [submodule "app/src/unstable/assets/sources/tedtalks"] path = app/src/unstable/assets/sources/tedtalks url = ../plugins/tedtalks.git -[submodule "app/src/stable/assets/sources/curiositystream"] - path = app/src/stable/assets/sources/curiositystream - url = ../plugins/curiositystream.git -[submodule "app/src/unstable/assets/sources/curiositystream"] - path = app/src/unstable/assets/sources/curiositystream - url = ../plugins/curiositystream.git -[submodule "app/src/unstable/assets/sources/crunchyroll"] - path = app/src/unstable/assets/sources/crunchyroll - url = ../plugins/crunchyroll.git -[submodule "app/src/stable/assets/sources/crunchyroll"] - path = app/src/stable/assets/sources/crunchyroll - url = ../plugins/crunchyroll.git diff --git a/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar b/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar deleted file mode 100644 index 27b62b35..00000000 --- a/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81 -size 65512557 diff --git a/app/build.gradle b/app/build.gradle index 25d458d4..8d55d000 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,7 +156,6 @@ android { dependencies { implementation 'com.google.dagger:dagger:2.48' implementation 'androidx.test:monitor:1.7.2' - implementation 'com.google.android.material:material:1.12.0' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core @@ -181,7 +180,6 @@ dependencies { //JS implementation("com.caoccao.javet:javet-android:3.0.2") - //implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved. //Exoplayer implementation 'androidx.media3:media3-exoplayer:1.2.1' @@ -199,8 +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 fileTree(dir: 'aar', include: ['*.aar']) - implementation 'com.arthenica:smart-exception-java:0.2.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/androidTest/java/com/futo/platformplayer/CSSColorTests.kt b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt deleted file mode 100644 index 66686260..00000000 --- a/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.futo.platformplayer - -import android.graphics.Color -import org.junit.Assert.assertEquals -import org.junit.Test -import toAndroidColor - -class CSSColorTests { - @Test - fun test1() { - val androidHex = "#80336699" - val androidColorInt = Color.parseColor(androidHex) - - val cssHex = "#33669980" - val cssColor = CSSColor.parseColor(cssHex) - - assertEquals( - "CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)", - androidColorInt, - cssColor.toAndroidColor(), - ) - } - - @Test - fun test2() { - val androidHex = "#123ABC" - val androidColorInt = Color.parseColor(androidHex) - - val cssHex = "#123ABCFF" - val cssColor = CSSColor.parseColor(cssHex) - - assertEquals( - "CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)", - androidColorInt, - cssColor.toAndroidColor() - ) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt deleted file mode 100644 index f3e12645..00000000 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt +++ /dev/null @@ -1,338 +0,0 @@ -package com.futo.platformplayer - -import com.futo.platformplayer.noise.protocol.Noise -import com.futo.platformplayer.sync.internal.* -import kotlinx.coroutines.* -import kotlinx.coroutines.selects.select -import org.junit.Assert.* -import org.junit.Test -import java.net.Socket -import java.nio.ByteBuffer -import kotlin.random.Random -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -/* -class SyncServerTests { - - //private val relayHost = "relay.grayjay.app" - //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" - private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw=" - private val relayHost = "192.168.1.138" - private val relayPort = 9000 - - /** Creates a client connected to the live relay server. */ - private suspend fun createClient( - onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null, - onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null, - onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null, - isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null, - onException: ((Throwable) -> Unit)? = null - ): SyncSocketSession = withContext(Dispatchers.IO) { - val p = Noise.createDH("25519") - p.generateKeyPair() - val socket = Socket(relayHost, relayPort) - val inputStream = LittleEndianDataInputStream(socket.getInputStream()) - val outputStream = LittleEndianDataOutputStream(socket.getOutputStream()) - val tcs = CompletableDeferred() - val socketSession = SyncSocketSession( - relayHost, - p, - inputStream, - outputStream, - onClose = { socket.close() }, - onHandshakeComplete = { s -> - onHandshakeComplete?.invoke(s) - tcs.complete(true) - }, - onData = onData ?: { _, _, _, _ -> }, - onNewChannel = onNewChannel ?: { _, _ -> }, - isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true } - ) - socketSession.authorizable = AlwaysAuthorized() - try { - socketSession.startAsInitiator(relayKey) - } catch (e: Throwable) { - onException?.invoke(e) - } - withTimeout(5000.milliseconds) { tcs.await() } - return@withContext socketSession - } - - @Test - fun multipleClientsHandshake_Success() = runBlocking { - val client1 = createClient() - val client2 = createClient() - assertNotNull(client1.remotePublicKey, "Client 1 handshake failed") - assertNotNull(client2.remotePublicKey, "Client 2 handshake failed") - client1.stop() - client2.stop() - } - - @Test - fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking { - val clientA = createClient() - val clientB = createClient() - val clientC = createClient() - clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true) - delay(100.milliseconds) - val infoB = clientB.requestConnectionInfo(clientA.localPublicKey) - val infoC = clientC.requestConnectionInfo(clientA.localPublicKey) - assertNotNull("Client B should receive connection info", infoB) - assertEquals(12345.toUShort(), infoB!!.port) - assertNull("Client C should not receive connection info (unauthorized)", infoC) - clientA.stop() - clientB.stop() - clientC.stop() - } - - @Test - fun relayedTransport_Bidirectional_Success() = runBlocking { - val tcsA = CompletableDeferred() - val tcsB = CompletableDeferred() - val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) - val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) - val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } - val channelA = withTimeout(5000.milliseconds) { tcsA.await() } - channelA.authorizable = AlwaysAuthorized() - val channelB = withTimeout(5000.milliseconds) { tcsB.await() } - channelB.authorizable = AlwaysAuthorized() - channelTask.await() - - val tcsDataB = CompletableDeferred() - channelB.setDataHandler { _, _, o, so, d -> - val b = ByteArray(d.remaining()) - d.get(b) - if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) - } - channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3))) - - val tcsDataA = CompletableDeferred() - channelA.setDataHandler { _, _, o, so, d -> - val b = ByteArray(d.remaining()) - d.get(b) - if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b) - } - channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6))) - - val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() } - val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() } - assertArrayEquals(byteArrayOf(1, 2, 3), receivedB) - assertArrayEquals(byteArrayOf(4, 5, 6), receivedA) - clientA.stop() - clientB.stop() - } - - @Test - fun relayedTransport_MaximumMessageSize_Success() = runBlocking { - val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16 - val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) } - val tcsA = CompletableDeferred() - val tcsB = CompletableDeferred() - val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) - val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) - val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } - val channelA = withTimeout(5000.milliseconds) { tcsA.await() } - channelA.authorizable = AlwaysAuthorized() - val channelB = withTimeout(5000.milliseconds) { tcsB.await() } - channelB.authorizable = AlwaysAuthorized() - channelTask.await() - - val tcsDataB = CompletableDeferred() - channelB.setDataHandler { _, _, o, so, d -> - val b = ByteArray(d.remaining()) - d.get(b) - if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) - } - channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData)) - val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() } - assertArrayEquals(maxSizeData, receivedData) - clientA.stop() - clientB.stop() - } - - @Test - fun publishAndGetRecord_Success() = runBlocking { - val clientA = createClient() - val clientB = createClient() - val clientC = createClient() - val data = byteArrayOf(1, 2, 3) - val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data) - val recordB = clientB.getRecord(clientA.localPublicKey, "testKey") - val recordC = clientC.getRecord(clientA.localPublicKey, "testKey") - assertTrue(success) - assertNotNull(recordB) - assertArrayEquals(data, recordB!!.first) - assertNull("Unauthorized client should not access record", recordC) - clientA.stop() - clientB.stop() - clientC.stop() - } - - @Test - fun getNonExistentRecord_ReturnsNull() = runBlocking { - val clientA = createClient() - val clientB = createClient() - val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey") - assertNull("Getting non-existent record should return null", record) - clientA.stop() - clientB.stop() - } - - @Test - fun updateRecord_TimestampUpdated() = runBlocking { - val clientA = createClient() - val clientB = createClient() - val key = "updateKey" - val data1 = byteArrayOf(1) - val data2 = byteArrayOf(2) - clientA.publishRecords(listOf(clientB.localPublicKey), key, data1) - val record1 = clientB.getRecord(clientA.localPublicKey, key) - delay(1000.milliseconds) - clientA.publishRecords(listOf(clientB.localPublicKey), key, data2) - val record2 = clientB.getRecord(clientA.localPublicKey, key) - assertNotNull(record1) - assertNotNull(record2) - assertTrue(record2!!.second > record1!!.second) - assertArrayEquals(data2, record2.first) - clientA.stop() - clientB.stop() - } - - @Test - fun deleteRecord_Success() = runBlocking { - val clientA = createClient() - val clientB = createClient() - val data = byteArrayOf(1, 2, 3) - clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data) - val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete")) - val record = clientB.getRecord(clientA.localPublicKey, "toDelete") - assertTrue(success) - assertNull(record) - clientA.stop() - clientB.stop() - } - - @Test - fun listRecordKeys_Success() = runBlocking { - val clientA = createClient() - val clientB = createClient() - val keys = arrayOf("key1", "key2", "key3") - keys.forEach { key -> - clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1)) - } - val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey) - assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray()) - clientA.stop() - clientB.stop() - } - - @Test - fun singleLargeMessageViaRelayedChannel_Success() = runBlocking { - val largeData = ByteArray(100000).apply { Random.nextBytes(this) } - val tcsA = CompletableDeferred() - val tcsB = CompletableDeferred() - val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) - val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) - val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } - val channelA = withTimeout(5000.milliseconds) { tcsA.await() } - channelA.authorizable = AlwaysAuthorized() - val channelB = withTimeout(5000.milliseconds) { tcsB.await() } - channelB.authorizable = AlwaysAuthorized() - channelTask.await() - - val tcsDataB = CompletableDeferred() - channelB.setDataHandler { _, _, o, so, d -> - val b = ByteArray(d.remaining()) - d.get(b) - if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) - } - channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData)) - val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() } - assertArrayEquals(largeData, receivedData) - clientA.stop() - clientB.stop() - } - - @Test - fun publishAndGetLargeRecord_Success() = runBlocking { - val largeData = ByteArray(1000000).apply { Random.nextBytes(this) } - val clientA = createClient() - val clientB = createClient() - val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData) - val record = clientB.getRecord(clientA.localPublicKey, "largeRecord") - assertTrue(success) - assertNotNull(record) - assertArrayEquals(largeData, record!!.first) - clientA.stop() - clientB.stop() - } - - @Test - fun relayedTransport_WithValidAppId_Success() = runBlocking { - // Arrange: Set up clients - val allowedAppId = 1234u - val tcsB = CompletableDeferred() - - // Client B requires appId 1234 - val clientB = createClient( - onNewChannel = { _, c -> tcsB.complete(c) }, - isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId } - ) - - val clientA = createClient() - - // Act: Start relayed channel with valid appId - val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) } - val channelB = withTimeout(5.seconds) { tcsB.await() } - withTimeout(5.seconds) { channelTask.await() } - - // Assert: Channel is established - assertNotNull("Channel should be created on target with valid appId", channelB) - - // Clean up - clientA.stop() - clientB.stop() - } - - @Test - fun relayedTransport_WithInvalidAppId_Fails() = runBlocking { - // Arrange: Set up clients - val allowedAppId = 1234u - val invalidAppId = 5678u - val tcsB = CompletableDeferred() - - // Client B requires appId 1234 - val clientB = createClient( - onNewChannel = { _, c -> tcsB.complete(c) }, - isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }, - onException = { } - ) - - val clientA = createClient() - - // Act & Assert: Attempt with invalid appId should fail - try { - withTimeout(5.seconds) { - clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId) - } - fail("Starting relayed channel with invalid appId should fail") - } catch (e: Throwable) { - // Expected: The channel creation should time out or fail - } - - // Ensure no channel was created on client B - val completedTask = select { - tcsB.onAwait { "channel" } - async { delay(1.seconds); "timeout" }.onAwait { "timeout" } - } - assertEquals("No channel should be created with invalid appId", "timeout", completedTask) - - // Clean up - clientA.stop() - clientB.stop() - } -} - -class AlwaysAuthorized : IAuthorizable { - override val isAuthorized: Boolean get() = true -}*/ \ No newline at end of file diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt deleted file mode 100644 index d34bfad4..00000000 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt +++ /dev/null @@ -1,512 +0,0 @@ -package com.futo.platformplayer - -import com.futo.platformplayer.noise.protocol.DHState -import com.futo.platformplayer.noise.protocol.Noise -import com.futo.platformplayer.sync.internal.* -import kotlinx.coroutines.* -import org.junit.Assert.* -import org.junit.Test -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.nio.ByteBuffer -import kotlin.random.Random -import java.io.InputStream -import java.io.OutputStream -import kotlin.time.Duration.Companion.seconds -/* -data class PipeStreams( - val initiatorInput: LittleEndianDataInputStream, - val initiatorOutput: LittleEndianDataOutputStream, - val responderInput: LittleEndianDataInputStream, - val responderOutput: LittleEndianDataOutputStream -) - -typealias OnHandshakeComplete = (SyncSocketSession) -> Unit -typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean -typealias OnClose = (SyncSocketSession) -> Unit -typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit - -class SyncSocketTests { - private fun createPipeStreams(): PipeStreams { - val initiatorOutput = PipedOutputStream() - val responderOutput = PipedOutputStream() - val responderInput = PipedInputStream(initiatorOutput) - val initiatorInput = PipedInputStream(responderOutput) - return PipeStreams( - LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput), - LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput) - ) - } - - fun generateKeyPair(): DHState { - val p = Noise.createDH("25519") - p.generateKeyPair() - return p - } - - private fun createSessions( - initiatorInput: LittleEndianDataInputStream, - initiatorOutput: LittleEndianDataOutputStream, - responderInput: LittleEndianDataInputStream, - responderOutput: LittleEndianDataOutputStream, - initiatorKeyPair: DHState, - responderKeyPair: DHState, - onInitiatorHandshakeComplete: OnHandshakeComplete, - onResponderHandshakeComplete: OnHandshakeComplete, - onInitiatorClose: OnClose? = null, - onResponderClose: OnClose? = null, - onClose: OnClose? = null, - isHandshakeAllowed: IsHandshakeAllowed? = null, - onDataA: OnData? = null, - onDataB: OnData? = null - ): Pair { - val initiatorSession = SyncSocketSession( - "", initiatorKeyPair, initiatorInput, initiatorOutput, - onClose = { - onClose?.invoke(it) - onInitiatorClose?.invoke(it) - }, - onHandshakeComplete = onInitiatorHandshakeComplete, - onData = onDataA, - isHandshakeAllowed = isHandshakeAllowed - ) - - val responderSession = SyncSocketSession( - "", responderKeyPair, responderInput, responderOutput, - onClose = { - onClose?.invoke(it) - onResponderClose?.invoke(it) - }, - onHandshakeComplete = onResponderHandshakeComplete, - onData = onDataB, - isHandshakeAllowed = isHandshakeAllowed - ) - - return Pair(initiatorSession, responderSession) - } - - @Test - fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - val validPairingCode = "secret" - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode) - responderSession.startAsResponder() - - withTimeout(5.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - } - - @Test - fun handshake_WithInvalidPairingCode_Fails() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - val validPairingCode = "secret" - val invalidPairingCode = "wrong" - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val initiatorClosed = CompletableDeferred() - val responderClosed = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onInitiatorClose = { - initiatorClosed.complete(true) - }, - onResponderClose = { - responderClosed.complete(true) - }, - isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode) - responderSession.startAsResponder() - - withTimeout(100.seconds) { - initiatorClosed.await() - responderClosed.await() - } - - assertFalse(handshakeInitiatorCompleted.isCompleted) - assertFalse(handshakeResponderCompleted.isCompleted) - } - - @Test - fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - val validPairingCode = "secret" - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val initiatorClosed = CompletableDeferred() - val responderClosed = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onInitiatorClose = { - initiatorClosed.complete(true) - }, - onResponderClose = { - responderClosed.complete(true) - }, - isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code - responderSession.startAsResponder() - - withTimeout(5.seconds) { - initiatorClosed.await() - responderClosed.await() - } - - assertFalse(handshakeInitiatorCompleted.isCompleted) - assertFalse(handshakeResponderCompleted.isCompleted) - } - - @Test - fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - val pairingCode = "unnecessary" - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode) - responderSession.startAsResponder() - - withTimeout(10.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - } - - @Test - fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val tcsDataReceived = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onDataB = { _, opcode, subOpcode, data -> - if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { - val b = ByteArray(data.remaining()) - data.get(b) - tcsDataReceived.complete(b) - } - } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey) - responderSession.startAsResponder() - - withTimeout(10.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - - // Ensure both sessions are authorized - initiatorSession.authorizable = Authorized() - responderSession.authorizable = Authorized() - - val smallData = byteArrayOf(1, 2, 3) - initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData)) - - val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } - assertArrayEquals(smallData, receivedData) - } - - @Test - fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val tcsDataReceived = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onDataB = { _, opcode, subOpcode, data -> - if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { - val b = ByteArray(data.remaining()) - data.get(b) - tcsDataReceived.complete(b) - } - } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey) - responderSession.startAsResponder() - - withTimeout(10.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - - // Ensure both sessions are authorized - initiatorSession.authorizable = Authorized() - responderSession.authorizable = Authorized() - - val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) } - initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData)) - - val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } - assertArrayEquals(maxData, receivedData) - } - - @Test - fun stream_LargeData_Succeeds() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val tcsDataReceived = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onDataB = { _, opcode, subOpcode, data -> - if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { - val b = ByteArray(data.remaining()) - data.get(b) - tcsDataReceived.complete(b) - } - } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey) - responderSession.startAsResponder() - - withTimeout(10.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - - // Ensure both sessions are authorized - initiatorSession.authorizable = Authorized() - responderSession.authorizable = Authorized() - - val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) } - initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData)) - - val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } - assertArrayEquals(largeData, receivedData) - } - - @Test - fun authorizedSession_CanSendData() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val tcsDataReceived = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onDataB = { _, opcode, subOpcode, data -> - if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { - val b = ByteArray(data.remaining()) - data.get(b) - tcsDataReceived.complete(b) - } - } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey) - responderSession.startAsResponder() - - withTimeout(10.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - - // Authorize both sessions - initiatorSession.authorizable = Authorized() - responderSession.authorizable = Authorized() - - val data = byteArrayOf(1, 2, 3) - initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data)) - - val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } - assertArrayEquals(data, receivedData) - } - - @Test - fun unauthorizedSession_CannotSendData() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val tcsDataReceived = CompletableDeferred() - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onDataB = { _, _, _, _ -> } - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey) - responderSession.startAsResponder() - - withTimeout(10.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - - // Authorize initiator but not responder - initiatorSession.authorizable = Authorized() - responderSession.authorizable = Unauthorized() - - val data = byteArrayOf(1, 2, 3) - initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data)) - - delay(1.seconds) - assertFalse(tcsDataReceived.isCompleted) - } - - @Test - fun directHandshake_WithValidAppId_Succeeds() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - val allowedAppId = 1234u - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - - val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt -> - linkType == LinkType.Direct && appId == allowedAppId - } - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - isHandshakeAllowed = responderIsHandshakeAllowed - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId) - responderSession.startAsResponder() - - withTimeout(5.seconds) { - handshakeInitiatorCompleted.await() - handshakeResponderCompleted.await() - } - - assertNotNull(initiatorSession.remotePublicKey) - assertNotNull(responderSession.remotePublicKey) - } - - @Test - fun directHandshake_WithInvalidAppId_Fails() = runBlocking { - val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() - val initiatorKeyPair = generateKeyPair() - val responderKeyPair = generateKeyPair() - val allowedAppId = 1234u - val invalidAppId = 5678u - - val handshakeInitiatorCompleted = CompletableDeferred() - val handshakeResponderCompleted = CompletableDeferred() - val initiatorClosed = CompletableDeferred() - val responderClosed = CompletableDeferred() - - val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt -> - linkType == LinkType.Direct && appId == allowedAppId - } - - val (initiatorSession, responderSession) = createSessions( - initiatorInput, initiatorOutput, responderInput, responderOutput, - initiatorKeyPair, responderKeyPair, - { handshakeInitiatorCompleted.complete(true) }, - { handshakeResponderCompleted.complete(true) }, - onInitiatorClose = { - initiatorClosed.complete(true) - }, - onResponderClose = { - responderClosed.complete(true) - }, - isHandshakeAllowed = responderIsHandshakeAllowed - ) - - initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId) - responderSession.startAsResponder() - - withTimeout(5.seconds) { - initiatorClosed.await() - responderClosed.await() - } - - assertFalse(handshakeInitiatorCompleted.isCompleted) - assertFalse(handshakeResponderCompleted.isCompleted) - } -} - -class Authorized : IAuthorizable { - override val isAuthorized: Boolean = true -} - -class Unauthorized : IAuthorizable { - override val isAuthorized: Boolean = false -}*/ \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea6f3e5b..c9917a2d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,7 +55,7 @@ - + \ No newline at end of file diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 85195f47..b6b4ab6d 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -32,8 +32,7 @@ let Type = { Text: { RAW: 0, HTML: 1, - MARKUP: 2, - CODE: 3 + MARKUP: 2 }, Chapter: { NORMAL: 0, @@ -103,12 +102,6 @@ class UnavailableException extends ScriptException { super("UnavailableException", msg); } } -class ReloadRequiredException extends ScriptException { - constructor(msg, reloadData) { - super("ReloadRequiredException", msg); - this.reloadData = reloadData; - } -} class AgeException extends ScriptException { constructor(msg) { super("AgeException", msg); @@ -251,9 +244,6 @@ class PlatformVideo extends PlatformContent { this.duration = obj.duration ?? -1; //Long this.viewCount = obj.viewCount ?? -1; //Long - this.playbackTime = obj.playbackTime ?? -1; - this.playbackDate = obj.playbackDate ?? undefined; - this.isLive = obj.isLive ?? false; //Boolean this.isShort = !!obj.isShort ?? false; } @@ -301,39 +291,15 @@ class PlatformPostDetails extends PlatformPost { } } -class PlatformWeb extends PlatformContent { - constructor(obj) { - super(obj, 7); - obj = obj ?? {}; - this.plugin_type = "PlatformWeb"; - } -} -class PlatformWebDetails extends PlatformWeb { - constructor(obj) { - super(obj, 7); - obj = obj ?? {}; - this.plugin_type = "PlatformWebDetails"; - this.html = obj.html; - } -} - -class PlatformArticle extends PlatformContent { - constructor(obj) { - super(obj, 3); - obj = obj ?? {}; - this.plugin_type = "PlatformArticle"; - this.rating = obj.rating ?? new RatingLikes(-1); - this.summary = obj.summary ?? ""; - this.thumbnails = obj.thumbnails ?? new Thumbnails([]); - } -} -class PlatformArticleDetails extends PlatformArticle { +class PlatformArticleDetails extends PlatformContent { constructor(obj) { super(obj, 3); obj = obj ?? {}; this.plugin_type = "PlatformArticleDetails"; this.rating = obj.rating ?? new RatingLikes(-1); + this.summary = obj.summary ?? ""; this.segments = obj.segments ?? []; + this.thumbnails = obj.thumbnails ?? new Thumbnails([]); } } class ArticleSegment { @@ -349,17 +315,9 @@ class ArticleTextSegment extends ArticleSegment { } } class ArticleImagesSegment extends ArticleSegment { - constructor(images, caption) { + constructor(images) { super(2); this.images = images; - this.caption = caption; - } -} -class ArticleHeaderSegment extends ArticleSegment { - constructor(content, level) { - super(3); - this.level = level; - this.content = content; } } class ArticleNestedSegment extends ArticleSegment { @@ -637,8 +595,6 @@ class PlatformComment { this.date = obj.date ?? 0; this.replyCount = obj.replyCount ?? 0; this.context = obj.context ?? {}; - if(obj.getReplies) - this.getReplies = obj.getReplies; } } @@ -710,12 +666,11 @@ class LiveEventViewCount extends LiveEvent { } } class LiveEventRaid extends LiveEvent { - constructor(targetUrl, targetName, targetThumbnail, isOutgoing) { + constructor(targetUrl, targetName, targetThumbnail) { super(100); this.targetUrl = targetUrl; this.targetName = targetName; this.targetThumbnail = targetThumbnail; - this.isOutgoing = isOutgoing ?? true; } } @@ -788,7 +743,6 @@ let plugin = { //To override by plugin const source = { getHome() { return new ContentPager([], false, {}); }, - getShorts() { return new VideoPager([], false, {}); }, enable(config){ }, disable() {}, diff --git a/app/src/main/java/com/futo/platformplayer/CSSColor.kt b/app/src/main/java/com/futo/platformplayer/CSSColor.kt deleted file mode 100644 index 73b50413..00000000 --- a/app/src/main/java/com/futo/platformplayer/CSSColor.kt +++ /dev/null @@ -1,319 +0,0 @@ -import kotlin.math.* - -class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) { - init { - require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) { - "RGBA channels must be in [0,1]" - } - } - - // -- RGB(A) channels stored 0–1 -- - var r: Float = r.coerceIn(0f, 1f) - set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } - var g: Float = g.coerceIn(0f, 1f) - set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } - var b: Float = b.coerceIn(0f, 1f) - set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } - var a: Float = a.coerceIn(0f, 1f) - set(v) { field = v.coerceIn(0f, 1f) } - - // -- Int views of RGBA 0–255 -- - var red: Int - get() = (r * 255).roundToInt() - set(v) { r = (v.coerceIn(0, 255) / 255f) } - var green: Int - get() = (g * 255).roundToInt() - set(v) { g = (v.coerceIn(0, 255) / 255f) } - var blue: Int - get() = (b * 255).roundToInt() - set(v) { b = (v.coerceIn(0, 255) / 255f) } - var alpha: Int - get() = (a * 255).roundToInt() - set(v) { a = (v.coerceIn(0, 255) / 255f) } - - // -- HSLA storage & lazy recompute flags -- - private var _h: Float = 0f - private var _s: Float = 0f - private var _l: Float = 0f - private var _hslDirty = true - - /** Hue [0...360) */ - var hue: Float - get() { computeHslIfNeeded(); return _h } - set(v) { setHsl(v, saturation, lightness) } - - /** Saturation [0...1] */ - var saturation: Float - get() { computeHslIfNeeded(); return _s } - set(v) { setHsl(hue, v, lightness) } - - /** Lightness [0...1] */ - var lightness: Float - get() { computeHslIfNeeded(); return _l } - set(v) { setHsl(hue, saturation, v) } - - private fun computeHslIfNeeded() { - if (!_hslDirty) return - val max = max(max(r, g), b) - val min = min(min(r, g), b) - val d = max - min - _l = (max + min) / 2f - _s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f)) - _h = when { - d == 0f -> 0f - max == r -> ((g - b) / d % 6f) * 60f - max == g -> (((b - r) / d) + 2f) * 60f - else -> (((r - g) / d) + 4f) * 60f - }.let { if (it < 0f) it + 360f else it } - _hslDirty = false - } - - /** - * Set all three HSL channels at once. - * Hue in degrees [0...360), s/l [0...1]. - */ - fun setHsl(h: Float, s: Float, l: Float) { - val hh = ((h % 360f) + 360f) % 360f - val cc = (1f - abs(2f * l - 1f)) * s - val x = cc * (1f - abs((hh / 60f) % 2f - 1f)) - val m = l - cc / 2f - - val (rp, gp, bp) = when { - hh < 60f -> Triple(cc, x, 0f) - hh < 120f -> Triple(x, cc, 0f) - hh < 180f -> Triple(0f, cc, x) - hh < 240f -> Triple(0f, x, cc) - hh < 300f -> Triple(x, 0f, cc) - else -> Triple(cc, 0f, x) - } - - r = rp + m; g = gp + m; b = bp + m - _h = hh; _s = s; _l = l; _hslDirty = false - } - - /** Return 0xRRGGBBAA int */ - fun toRgbaInt(): Int { - val ai = (a * 255).roundToInt() and 0xFF - val ri = (r * 255).roundToInt() and 0xFF - val gi = (g * 255).roundToInt() and 0xFF - val bi = (b * 255).roundToInt() and 0xFF - return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai - } - - /** Return 0xAARRGGBB int */ - fun toArgbInt(): Int { - val ai = (a * 255).roundToInt() and 0xFF - val ri = (r * 255).roundToInt() and 0xFF - val gi = (g * 255).roundToInt() and 0xFF - val bi = (b * 255).roundToInt() and 0xFF - return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi - } - - // — Convenience modifiers (chainable) — - - /** Lighten by fraction [0...1] */ - fun lighten(fraction: Float): CSSColor = apply { - lightness = (lightness + fraction).coerceIn(0f, 1f) - } - - /** Darken by fraction [0...1] */ - fun darken(fraction: Float): CSSColor = apply { - lightness = (lightness - fraction).coerceIn(0f, 1f) - } - - /** Increase saturation by fraction [0...1] */ - fun saturate(fraction: Float): CSSColor = apply { - saturation = (saturation + fraction).coerceIn(0f, 1f) - } - - /** Decrease saturation by fraction [0...1] */ - fun desaturate(fraction: Float): CSSColor = apply { - saturation = (saturation - fraction).coerceIn(0f, 1f) - } - - /** Rotate hue by degrees (can be negative) */ - fun rotateHue(degrees: Float): CSSColor = apply { - hue = (hue + degrees) % 360f - } - - companion object { - /** Create from Android 0xAARRGGBB */ - @JvmStatic fun fromArgb(color: Int): CSSColor { - val a = ((color ushr 24) and 0xFF) / 255f - val r = ((color ushr 16) and 0xFF) / 255f - val g = ((color ushr 8) and 0xFF) / 255f - val b = ( color and 0xFF) / 255f - return CSSColor(r, g, b, a) - } - - /** Create from Android 0xRRGGBBAA */ - @JvmStatic fun fromRgba(color: Int): CSSColor { - val r = ((color ushr 24) and 0xFF) / 255f - val g = ((color ushr 16) and 0xFF) / 255f - val b = ((color ushr 8) and 0xFF) / 255f - val a = ( color and 0xFF) / 255f - return CSSColor(r, g, b, a) - } - - @JvmStatic fun fromAndroidColor(color: Int): CSSColor { - return fromArgb(color) - } - - private val NAMED_HEX = mapOf( - "aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF", - "aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC", - "bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD", - "blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A", - "burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00", - "chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED", - "cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF", - "darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B", - "darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9", - "darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F", - "darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000", - "darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B", - "darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1", - "darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF", - "dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF", - "firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22", - "fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF", - "gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080", - "green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080", - "honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C", - "indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C", - "lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00", - "lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080", - "lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3", - "lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1", - "lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA", - "lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE", - "lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32", - "linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000", - "mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3", - "mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE", - "mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585", - "midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1", - "moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080", - "oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23", - "orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6", - "palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE", - "palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9", - "peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD", - "powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399", - "red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1", - "saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460", - "seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D", - "silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD", - "slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA", - "springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C", - "teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347", - "turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3", - "white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00", - "yellowgreen" to "9ACD32" - ) - private val NAMED: Map = NAMED_HEX - .mapValues { (_, hexRgb) -> - // parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity - val rgb = hexRgb.toInt(16) - (rgb shl 8) or 0xFF - } + ("transparent" to 0x00000000) - - private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE) - private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE) - private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE) - - @JvmStatic - fun parseColor(s: String): CSSColor { - val str = s.trim() - // named - NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() } - - // hex - HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part -> - return parseHexPart(part) - } - - // rgb/rgba - RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { - return parseRgbParts(it.split(',').map(String::trim)) - } - - // hsl/hsla - HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { - return parseHslParts(it.split(',').map(String::trim)) - } - - error("Cannot parse color: \"$s\"") - } - - private fun parseHexPart(p: String): CSSColor { - // expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA" - val hex = when (p.length) { - 3 -> p.map { "$it$it" }.joinToString("") + "FF" - 4 -> p.map { "$it$it" }.joinToString("") - 6 -> p + "FF" - 8 -> p - else -> error("Invalid hex color: #$p") - } - - val parsed = hex.toLong(16).toInt() - val alpha = (parsed and 0xFF) shl 24 - val rgbOnly = (parsed ushr 8) and 0x00FFFFFF - val argb = alpha or rgbOnly - return fromArgb(argb) - } - - private fun parseRgbParts(parts: List): CSSColor { - require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" } - - // r/g/b: "128" → 128/255, "50%" → 0.5 - fun channel(ch: String): Float = - if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f - else ch.toFloat().coerceIn(0f, 255f) / 255f - - // alpha: "0.5" → 0.5, "50%" → 0.5 - fun alpha(a: String): Float = - if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f - else a.toFloat().coerceIn(0f, 1f) - - val r = channel(parts[0]) - val g = channel(parts[1]) - val b = channel(parts[2]) - val a = if (parts.size == 4) alpha(parts[3]) else 1f - - return CSSColor(r, g, b, a) - } - - private fun parseHslParts(parts: List): CSSColor { - require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" } - - fun hueOf(h: String): Float = when { - h.endsWith("deg") -> h.removeSuffix("deg").toFloat() - h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f - h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat()) - h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f - else -> h.toFloat() - } - - // for s and l you only ever see percentages - fun pct(p: String): Float = - p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f - - // alpha: "0.5" → 0.5, "50%" → 0.5 - fun alpha(a: String): Float = - if (a.endsWith("%")) pct(a) - else a.toFloat().coerceIn(0f, 1f) - - val h = hueOf(parts[0]) - val s = pct(parts[1]) - val l = pct(parts[2]) - val a = if (parts.size == 4) alpha(parts[3]) else 1f - - return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) } - } - } -} - -fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this) -fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this) -fun CSSColor.toAndroidColor(): Int = toArgbInt() diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt index 42210c60..4ddf37ad 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -14,6 +14,7 @@ import java.text.DecimalFormat import java.time.OffsetDateTime import java.time.temporal.ChronoUnit import kotlin.math.abs +import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -375,19 +376,14 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a fun String.matchesDomain(queryDomain: String): Boolean { if(queryDomain.startsWith(".")) { - val parts = this.lowercase().split("."); - val queryParts = queryDomain.lowercase().trimStart("."[0]).split("."); - if(queryParts.size < 2) + + val parts = queryDomain.lowercase().split("."); + if(parts.size < 3) throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")"); - else { - val possibleDomain = "." + queryParts.joinToString("."); - if(slds.contains(possibleDomain)) + if(parts.size >= 3){ + val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); + if(isSLD && parts.size <= 3) throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); - /* - val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]); - if(isSLD && queryParts.size <= 3) - throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); - */ } //TODO: Should be safe, but double verify if can't be exploited @@ -399,11 +395,9 @@ fun String.matchesDomain(queryDomain: String): Boolean { fun String.getSubdomainWildcardQuery(): String { val domainParts = this.split("."); - var wildcardDomain = if(domainParts.size > 2) - "." + domainParts.drop(1).joinToString(".") + val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase(); + if(slds.contains(sldParts)) + return "." + domainParts.drop(domainParts.size - 3).joinToString("."); else - "." + domainParts.joinToString("."); - if(slds.contains(wildcardDomain.lowercase())) - "." + domainParts.joinToString("."); - return wildcardDomain; + return "." + domainParts.drop(domainParts.size - 2).joinToString("."); } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index fca7deda..00f47885 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -217,9 +217,9 @@ private fun ByteArray.toInetAddress(): InetAddress { } fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? { - ensureNotMainThread() + val timeout = 2000 + - val timeout = 10000 val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance() else attemptAddresses; if(addresses.isEmpty()) throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})"); @@ -241,11 +241,8 @@ fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? return null; } - val sortedAddresses: List = addresses - .sortedBy { addr -> addressScore(addr) } - val sockets: ArrayList = arrayListOf(); - for (i in sortedAddresses.indices) { + for (i in addresses.indices) { sockets.add(Socket()); } @@ -253,7 +250,7 @@ fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? var connectedSocket: Socket? = null; val threads: ArrayList = arrayListOf(); for (i in 0 until sockets.size) { - val address = sortedAddresses[i]; + val address = addresses[i]; val socket = sockets[i]; val thread = Thread { try { diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index f1e63366..442304d4 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -7,9 +7,6 @@ import java.net.InetAddress import java.net.URI import java.net.URISyntaxException import java.net.URLEncoder -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset //Syntax sugaring inline fun Any.assume(): T?{ @@ -36,37 +33,13 @@ fun Boolean?.toYesNo(): String { fun InetAddress?.toUrlAddress(): String { return when (this) { is Inet6Address -> { - val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null") - val index = hostAddr.indexOf('%') - if (index != -1) { - val addrPart = hostAddr.substring(0, index) - val scopeId = hostAddr.substring(index + 1) - "[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%' - } else { - "[$hostAddr]" - } + "[${hostAddress}]" } is Inet4Address -> { - this.hostAddress ?: throw Exception("Invalid address: hostAddress is null") + hostAddress } else -> { throw Exception("Invalid address type") } } -} - -fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime { - if (this == null || this < 0) - return OffsetDateTime.MIN - if(this > 4070912400) - return OffsetDateTime.MAX; - return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC) -} - -fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime { - if (this == null || this < 0) - return OffsetDateTime.MIN - if(this > 4070912400) - return OffsetDateTime.MAX; - return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC) } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt index fc1f5cf3..e31d3dac 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt @@ -2,30 +2,10 @@ package com.futo.platformplayer import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.* -import com.caoccao.javet.values.reference.IV8ValuePromise import com.caoccao.javet.values.reference.V8ValueArray -import com.caoccao.javet.values.reference.V8ValueError import com.caoccao.javet.values.reference.V8ValueObject -import com.caoccao.javet.values.reference.V8ValuePromise import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.engine.V8Plugin -import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException -import com.futo.platformplayer.logging.Logger -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.selects.SelectClause0 -import kotlinx.coroutines.selects.SelectClause1 -import java.util.concurrent.CancellationException -import java.util.concurrent.CountDownLatch -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext -import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType //V8 @@ -44,10 +24,6 @@ fun V8Value?.orDefault(default: R, handler: (V8Value)->R): R { return handler(this); } -inline fun V8Value.getSourcePlugin(): V8Plugin? { - return V8Plugin.getPluginFromRuntime(this.v8Runtime); -} - inline fun V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T { if(this !is T) throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}"); @@ -113,29 +89,7 @@ inline fun V8ValueArray.expectV8Variants(config: IV8PluginConfig, co .map { kv-> kv.second.orNull { it.expectV8Variant(config, contextName + "[${kv.first}]", ) } as T }; } -inline fun V8Plugin.ensureIsBusy() { - this.let { - if (!it.isThreadAlreadyBusy()) { - //throw IllegalStateException("Tried to access V8Plugin without busy"); - val stacktrace = Thread.currentThread().stackTrace; - Logger.w("Extensions_V8", - "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() + - ", " + stacktrace.drop(4)?.firstOrNull().toString() + - ", " + stacktrace.drop(5)?.firstOrNull()?.toString() + - ", " + stacktrace.drop(6)?.firstOrNull()?.toString() - ); - } - } -} -inline fun V8Value.ensureIsBusy() { - this?.getSourcePlugin()?.let { - it.ensureIsBusy(); - } -} - inline fun V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T { - if(false) - ensureIsBusy(); return when(T::class) { String::class -> this.expectOrThrow(config, contextName).value as T; Int::class -> { @@ -192,137 +146,4 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap { for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get(it).toString() }) map.put(prop, obj.getString(prop)); return map; -} - - -fun V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T { - val latch = CountDownLatch(1); - var promiseResult: T? = null; - var promiseException: Throwable? = null; - plugin.busy { - this.register(object: IV8ValuePromise.IListener { - override fun onFulfilled(p0: V8Value?) { - if(p0 is V8ValueError) - promiseException = ScriptExecutionException(plugin.config, p0.message); - else - promiseResult = p0 as T; - latch.countDown(); - } - override fun onRejected(p0: V8Value?) { - promiseException = (NotImplementedError("onRejected promise not implemented..")); - latch.countDown(); - } - override fun onCatch(p0: V8Value?) { - promiseException = (NotImplementedError("onCatch promise not implemented..")); - latch.countDown(); - } - }); - } - - plugin.registerPromise(this) { - promiseException = CancellationException("Cancelled by system"); - latch.countDown(); - } - plugin.unbusy { - latch.await(); - } - if(promiseException != null) - throw promiseException!!; - return promiseResult!!; -} -fun V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred { - val underlyingDef = CompletableDeferred(); - val def = if(this.has("estDuration")) - V8Deferred(underlyingDef, - this.getOrDefault(plugin.config, "estDuration", "toV8ValueAsync", -1) ?: -1); - else - V8Deferred(underlyingDef); - - if(def.estDuration > 0) - Logger.i("V8", "Promise with duration: [${def.estDuration}]"); - - val promise = this; - plugin.busy { - this.register(object: IV8ValuePromise.IListener { - override fun onFulfilled(p0: V8Value?) { - plugin.resolvePromise(promise); - underlyingDef.complete(p0 as T); - } - override fun onRejected(p0: V8Value?) { - plugin.resolvePromise(promise); - underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented..")); - } - override fun onCatch(p0: V8Value?) { - plugin.resolvePromise(promise); - underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented..")); - } - }); - } - plugin.registerPromise(promise) { - if(def.isActive) - def.cancel("Cancelled by system"); - } - return def; -} - -class V8Deferred(val deferred: Deferred, val estDuration: Int = -1): Deferred by deferred { - - fun convert(conversion: (result: T)->R): V8Deferred{ - val newDef = CompletableDeferred() - this.invokeOnCompletion { - if(it != null) - newDef.completeExceptionally(it); - else - newDef.complete(conversion(this@V8Deferred.getCompleted())); - } - - return V8Deferred(newDef, estDuration); - } - - - companion object { - fun merge(scope: CoroutineScope, defs: List>, conversion: (result: List)->R): V8Deferred { - - var amount = -1; - for(def in defs) - amount = Math.max(amount, def.estDuration); - - val def = scope.async { - val results = defs.map { it.await() }; - return@async conversion(results); - } - return V8Deferred(def, amount); - } - } -} - - -fun V8ValueObject.invokeV8(method: String, vararg obj: Any?): T { - var result = this.invoke(method, *obj); - if(result is V8ValuePromise) { - return result.toV8ValueBlocking(this.getSourcePlugin()!!); - } - return result as T; -} -fun V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred { - var result = this.invoke(method, *obj); - if(result is V8ValuePromise) { - return result.toV8ValueAsync(this.getSourcePlugin()!!); - } - return V8Deferred(CompletableDeferred(result as T)); -} -fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value { - var result = this.invoke(method, *obj); - if(result is V8ValuePromise) { - return result.toV8ValueBlocking(this.getSourcePlugin()!!); - } - return result; -} -fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred { - var result = this.invoke(method, *obj); - if(result is V8ValuePromise) { - val result = result.toV8ValueAsync(this.getSourcePlugin()!!); - return result; - } - return V8Deferred(CompletableDeferred(result)); } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index da414d8f..2bd95905 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -29,7 +29,6 @@ import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.fields.AdvancedField import com.futo.platformplayer.views.fields.DropdownFieldOptionsId import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField @@ -176,10 +175,6 @@ class Settings : FragmentedStorageFileJson() { } }*/ - - @FormField(R.string.advanced_settings, FieldForm.TOGGLE, R.string.advanced_settings_description, -1, "advancedSettings") - var advancedSettings: Boolean = false; - @FormField(R.string.language, "group", -1, 0) var language = LanguageSettings(); @Serializable @@ -226,11 +221,10 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5) var showHomeFiltersPluginNames: Boolean = false; - @AdvancedField @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; - @AdvancedField + @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) var progressBar: Boolean = true; @@ -259,11 +253,9 @@ class Settings : FragmentedStorageFileJson() { @DropdownFieldOptionsId(R.array.feed_style) var searchFeedStyle: Int = 1; - @AdvancedField @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5) var previewFeedItems: Boolean = true; - @AdvancedField @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) var progressBar: Boolean = true; @@ -285,7 +277,6 @@ class Settings : FragmentedStorageFileJson() { @Serializable class ChannelSettings { - @AdvancedField @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) var progressBar: Boolean = true; } @@ -311,20 +302,16 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) var useSubscriptionExchange: Boolean = false; - @AdvancedField @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; - @AdvancedField @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7) var progressBar: Boolean = true; - @AdvancedField @FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8) @Serializable(with = FlexibleBooleanSerializer::class) var fetchOnAppBoot: Boolean = true; - @AdvancedField @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9) var fetchOnTabOpen: Boolean = true; @@ -355,16 +342,13 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12) var showWatchMetrics: Boolean = false; - @AdvancedField @FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13) var allowPlaytimeTracking: Boolean = true; - @AdvancedField @FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14) var alwaysReloadFromCache: Boolean = false; - @AdvancedField @FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15) var peekChannelContents: Boolean = false; @@ -441,11 +425,9 @@ class Settings : FragmentedStorageFileJson() { var preferredPreviewQuality: Int = 5; fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); - @AdvancedField @FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4) var simplifySources: Boolean = true; - @AdvancedField @FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5) var alwaysAllowReverseLandscapeAutoRotate: Boolean = true @@ -456,7 +438,6 @@ class Settings : FragmentedStorageFileJson() { fun isBackgroundContinue() = backgroundPlay == 1; fun isBackgroundPictureInPicture() = backgroundPlay == 2; - @AdvancedField @FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7) @DropdownFieldOptionsId(R.array.resume_after_preview) var resumeAfterPreview: Int = 1; @@ -483,10 +464,14 @@ class Settings : FragmentedStorageFileJson() { }; } - @AdvancedField @FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9) var useLiveChatWindow: Boolean = true; + + + @FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10) + var backgroundSwitchToAudio: Boolean = true; + @FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11) @DropdownFieldOptionsId(R.array.restart_playback_after_loss) var restartPlaybackAfterLoss: Int = 1; @@ -512,97 +497,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21) var autoplay: Boolean = false; - @AdvancedField @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) var deleteFromWatchLaterAuto: Boolean = true; - - @FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23) - @DropdownFieldOptionsId(R.array.seek_offset_duration) - var seekOffset: Int = 2; - - fun getSeekOffset(): Long { - return when(seekOffset) { - 0 -> 3_000L; - 1 -> 5_000L; - 2 -> 10_000L; - 3 -> 20_000L; - 4 -> 30_000L; - 5 -> 60_000L; - else -> 10_000L; - } - } - - - @FormField(R.string.min_playback_speed, FieldForm.DROPDOWN, R.string.min_playback_speed_description, 25) - @DropdownFieldOptionsId(R.array.min_playback_speed) - var minimumPlaybackSpeed: Int = 0; - @FormField(R.string.max_playback_speed, FieldForm.DROPDOWN, R.string.max_playback_speed_description, 26) - @DropdownFieldOptionsId(R.array.max_playback_speed) - var maximumPlaybackSpeed: Int = 2; - @FormField(R.string.step_playback_speed, FieldForm.DROPDOWN, R.string.step_playback_speed_description, 26) - @DropdownFieldOptionsId(R.array.step_playback_speed) - var stepPlaybackSpeed: Int = 1; - - fun getPlaybackSpeedStep(): Double { - return when(stepPlaybackSpeed) { - 0 -> 0.05 - 1 -> 0.1 - 2 -> 0.25 - else -> 0.1; - } - } - fun getPlaybackSpeeds(): List { - val playbackSpeeds = mutableListOf(); - playbackSpeeds.add(1.0); - val minSpeed = when(minimumPlaybackSpeed) { - 0 -> 0.25 - 1 -> 0.5 - 2 -> 1.0 - else -> 0.25 - } - val maxSpeed = when(maximumPlaybackSpeed) { - 0 -> 2.0 - 1 -> 2.25 - 2 -> 3.0 - 3 -> 4.0 - 4 -> 5.0 - else -> 2.25; - } - var testSpeed = 1.0; - - while(testSpeed > minSpeed) { - val nextSpeed = (testSpeed - 0.25) as Double; - testSpeed = Math.max(nextSpeed, minSpeed); - playbackSpeeds.add(testSpeed); - } - testSpeed = 1.0; - while(testSpeed < maxSpeed) { - val nextSpeed = (testSpeed + if(testSpeed < 2) 0.25 else 1.0) as Double; - testSpeed = Math.min(nextSpeed, maxSpeed); - playbackSpeeds.add(testSpeed); - } - playbackSpeeds.sort(); - return playbackSpeeds; - } - - @FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27) - @DropdownFieldOptionsId(R.array.hold_playback_speeds) - var holdPlaybackSpeed: Int = 4; - - fun getHoldPlaybackSpeed(): Double { - return when(holdPlaybackSpeed) { - 0 -> 1.0 - 1 -> 1.25 - 2 -> 1.5 - 3 -> 1.75 - 4 -> 2.0 - 5 -> 2.25 - 6 -> 2.5 - 7 -> 2.75 - 8 -> 3.0 - else -> 2.0 - } - } } @FormField(R.string.comments, "group", R.string.comments_description, 6) @@ -618,7 +514,6 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0) var recommendationsDefault: Boolean = false; - @AdvancedField @FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0) var hideRecommendations: Boolean = false; @@ -655,12 +550,10 @@ class Settings : FragmentedStorageFileJson() { var preferredAudioQuality: Int = 1; fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0; - @AdvancedField @FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4) @Serializable(with = FlexibleBooleanSerializer::class) var byteRangeDownload: Boolean = true; - @AdvancedField @FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5) @DropdownFieldOptionsId(R.array.thread_count) var byteRangeConcurrency: Int = 3; @@ -690,20 +583,14 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var keepScreenOn: Boolean = true; - @AdvancedField @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3) @Serializable(with = FlexibleBooleanSerializer::class) var alwaysProxyRequests: Boolean = false; - @AdvancedField + @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) @Serializable(with = FlexibleBooleanSerializer::class) - var allowIpv6: Boolean = true; - - @AdvancedField - @FormField(R.string.allow_ipv4, FieldForm.TOGGLE, R.string.allow_ipv4_description, 5) - @Serializable(with = FlexibleBooleanSerializer::class) - var allowLinkLocalIpv4: Boolean = false; + var allowIpv6: Boolean = false; /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @@ -772,11 +659,9 @@ class Settings : FragmentedStorageFileJson() { @Serializable class Plugins { - @AdvancedField @FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1) var checkDisabledPluginsForUpdates: Boolean = false; - @AdvancedField @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) var clearCookiesOnLogout: Boolean = true; @@ -977,23 +862,7 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1) val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown"; - @FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2) - fun viewLicenseStatus() { - SettingsActivity.getActivity()?.let { - try { - if (StatePayment.instance.hasPaid) { - val paymentKey = StatePayment.instance.getPaymentKey() - UIDialogs.showDialogOk(it, R.drawable.ic_paid, "License activated\n" + paymentKey.first) - } else { - UIDialogs.showDialogOk(it, R.drawable.ic_paid, "No license activated") - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to show license status dialog", e) - } - } - } - - @FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3) + @FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2) fun clearPayment() { SettingsActivity.getActivity()?.let { context -> UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", { @@ -1011,20 +880,15 @@ class Settings : FragmentedStorageFileJson() { var other = Other(); @Serializable class Other { - @AdvancedField @FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2) var playlistDeleteConfirmation: Boolean = true; - @AdvancedField @FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3) var playlistAllowDups: Boolean = true; - @FormField(R.string.watch_later_add_start, FieldForm.TOGGLE, R.string.watch_later_add_start_description, 4) - var watchLaterAddStart: Boolean = true; - - @FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 5) + @FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4) var polycentricEnabled: Boolean = true; - @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7) + @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5) var polycentricLocalCache: Boolean = true; } @@ -1062,7 +926,7 @@ class Settings : FragmentedStorageFileJson() { @Serializable class Synchronization { @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1) - var enabled: Boolean = false; + var enabled: Boolean = true; @FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1) var broadcast: Boolean = false; @@ -1072,21 +936,6 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3) var connectLast: Boolean = true; - - @FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3) - var discoverThroughRelay: Boolean = true; - - @FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3) - var pairThroughRelay: Boolean = true; - - @FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3) - var connectThroughRelay: Boolean = true; - - @FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3) - var connectLocalDirectThroughRelay: Boolean = true; - - @FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3) - var localConnections: Boolean = true; } @FormField(R.string.info, FieldForm.GROUP, -1, 21) @@ -1155,4 +1004,4 @@ class Settings : FragmentedStorageFileJson() { } } //endregion -} +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 36755512..8034854d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -319,11 +319,7 @@ class UIDialogs { closeAction?.invoke() }, UIDialogs.ActionStyle.NONE), UIDialogs.Action(context.getString(R.string.retry), { - try { - retryAction?.invoke(); - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception retrying", e) - } + retryAction?.invoke(); }, UIDialogs.ActionStyle.PRIMARY) ); else @@ -337,11 +333,7 @@ class UIDialogs { closeAction?.invoke() }, UIDialogs.ActionStyle.NONE), UIDialogs.Action(context.getString(R.string.retry), { - try { - retryAction?.invoke(); - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception retrying", e) - } + retryAction?.invoke(); }, UIDialogs.ActionStyle.PRIMARY) ); } @@ -424,7 +416,7 @@ class UIDialogs { } - fun showCastingDialog(context: Context, ownerActivity: Activity? = null) { + fun showCastingDialog(context: Context) { val d = StateCasting.instance.activeDevice; if (d != null) { val dialog = ConnectedCastingDialog(context); @@ -432,7 +424,6 @@ class UIDialogs { dialog.setOwnerActivity(context) } registerDialogOpened(dialog); - ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } else { @@ -445,24 +436,21 @@ class UIDialogs { if (c is Activity) { dialog.setOwnerActivity(c); } - ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } } - fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) { + fun showCastingTutorialDialog(context: Context) { val dialog = CastingHelpDialog(context); registerDialogOpened(dialog); - ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } - fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) { + fun showCastingAddDialog(context: Context) { val dialog = CastingAddDialog(context); registerDialogOpened(dialog); - ownerActivity?.let { dialog.setOwnerActivity(it) } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 409adbf5..874ffd4f 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -4,14 +4,8 @@ import android.app.NotificationManager import android.content.ContentResolver import android.content.Context import android.content.Intent -import android.net.Uri import android.view.View import android.view.ViewGroup -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory -import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist -import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity @@ -43,9 +37,6 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.parsers.HLS -import com.futo.platformplayer.parsers.HLS.MediaRendition -import com.futo.platformplayer.parsers.HLS.StreamInfo -import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateHistory @@ -72,8 +63,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.ByteArrayInputStream -import androidx.core.net.toUri class UISlideOverlays { companion object { @@ -129,163 +118,115 @@ class UISlideOverlays { val originalVideo = subscription.doFetchVideos; val originalPosts = subscription.doFetchPosts; - val menu = SlideUpMenuOverlay( - container.context, - container, - "Subscription Settings", - null, - true, - listOf() - ); + val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf()); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); - val capabilities = plugin.getChannelCapabilities(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ + val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); + val capabilities = plugin.getChannelCapabilities(); - withContext(Dispatchers.Main) { - items.addAll( - listOf( - SlideUpMenuItem( - container.context, - R.drawable.ic_notifications, - "Notifications", - "", - tag = "notifications", - call = { - subscription.doNotifications = - menu?.selectOption(null, "notifications", true, true) - ?: subscription.doNotifications; - }, - invokeParent = false - ), - if (StateSubscriptionGroups.instance.getSubscriptionGroups() - .isNotEmpty() - ) - SlideUpMenuGroup( - container.context, "Subscription Groups", - "You can select which groups this subscription is part of.", - -1, listOf() - ) else null, - if (StateSubscriptionGroups.instance.getSubscriptionGroups() - .isNotEmpty() - ) - SlideUpMenuRecycler(container.context, "as") { - val groups = - ArrayList( - StateSubscriptionGroups.instance.getSubscriptionGroups() - .map { - SubscriptionGroup.Selectable( - it, - it.urls.contains(subscription.channel.url) - ) - } - .sortedBy { !it.selected }); - var adapter: AnyAdapterView? = - null; - adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { - it.onClick.subscribe { - if (it is SubscriptionGroup.Selectable) { - val actualGroup = - StateSubscriptionGroups.instance.getSubscriptionGroup( - it.id - ) - ?: return@subscribe; - groups.clear(); - if (it.selected) - actualGroup.urls.remove(subscription.channel.url); - else - actualGroup.urls.add(subscription.channel.url); + withContext(Dispatchers.Main) { + items.addAll(listOf( + SlideUpMenuItem( + container.context, + R.drawable.ic_notifications, + "Notifications", + "", + tag = "notifications", + call = { + subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; + }, + invokeParent = false + ), + if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) + SlideUpMenuGroup(container.context, "Subscription Groups", + "You can select which groups this subscription is part of.", + -1, listOf()) else null, + if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) + SlideUpMenuRecycler(container.context, "as") { + val groups = ArrayList(StateSubscriptionGroups.instance.getSubscriptionGroups() + .map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } + .sortedBy { !it.selected }); + var adapter: AnyAdapterView? = null; + adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { + it.onClick.subscribe { + if(it is SubscriptionGroup.Selectable) { + val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id) + ?: return@subscribe; + groups.clear(); + if(it.selected) + actualGroup.urls.remove(subscription.channel.url); + else + actualGroup.urls.add(subscription.channel.url); - StateSubscriptionGroups.instance.updateSubscriptionGroup( - actualGroup - ); - groups.addAll( - StateSubscriptionGroups.instance.getSubscriptionGroups() - .map { - SubscriptionGroup.Selectable( - it, - it.urls.contains(subscription.channel.url) - ) - } - .sortedBy { !it.selected }); - adapter?.notifyContentChanged(); - } - } - }; - return@SlideUpMenuRecycler adapter; - } else null, - SlideUpMenuGroup( - container.context, "Fetch Settings", - "Depending on the platform you might not need to enable a type for it to be available.", - -1, listOf() - ), - if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem( - container.context, - R.drawable.ic_live_tv, - "Livestreams", - "Check for livestreams", - tag = "fetchLive", - call = { - subscription.doFetchLive = - menu?.selectOption(null, "fetchLive", true, true) - ?: subscription.doFetchLive; - }, - invokeParent = false - ) else null, - if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Streams", - "Check for streams", - tag = "fetchStreams", - call = { - subscription.doFetchStreams = - menu?.selectOption(null, "fetchStreams", true, true) - ?: subscription.doFetchStreams; - }, - invokeParent = false - ) else null, - if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Videos", - "Check for videos", - tag = "fetchVideos", - call = { - subscription.doFetchVideos = - menu?.selectOption(null, "fetchVideos", true, true) - ?: subscription.doFetchVideos; - }, - invokeParent = false - ) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) - SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Content", - "Check for content", - tag = "fetchVideos", - call = { - subscription.doFetchVideos = - menu?.selectOption(null, "fetchVideos", true, true) - ?: subscription.doFetchVideos; - }, - invokeParent = false - ) else null, - if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( - container.context, - R.drawable.ic_chat, - "Posts", - "Check for posts", - tag = "fetchPosts", - call = { - subscription.doFetchPosts = - menu?.selectOption(null, "fetchPosts", true, true) - ?: subscription.doFetchPosts; - }, - invokeParent = false - ) else null/*,, + StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup); + groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups() + .map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } + .sortedBy { !it.selected }); + adapter?.notifyContentChanged(); + } + } + }; + return@SlideUpMenuRecycler adapter; + } else null, + SlideUpMenuGroup(container.context, "Fetch Settings", + "Depending on the platform you might not need to enable a type for it to be available.", + -1, listOf()), + if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem( + container.context, + R.drawable.ic_live_tv, + "Livestreams", + "Check for livestreams", + tag = "fetchLive", + call = { + subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; + }, + invokeParent = false + ) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Streams", + "Check for streams", + tag = "fetchStreams", + call = { + subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; + }, + invokeParent = false + ) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Videos", + "Check for videos", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Content", + "Check for content", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( + container.context, + R.drawable.ic_chat, + "Posts", + "Check for posts", + tag = "fetchPosts", + call = { + subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; + }, + invokeParent = false + ) else null/*,, SlideUpMenuGroup(container.context, "Actions", "Various things you can do with this subscription", @@ -293,82 +234,61 @@ class UISlideOverlays { SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", { showCreateSubscriptionGroup(container, subscription.channel); }, false)*/ - ).filterNotNull() - ); + ).filterNotNull()); - menu.setItems(items); + menu.setItems(items); - if (subscription.doNotifications) - menu.selectOption(null, "notifications", true, true); - if (subscription.doFetchLive) - menu.selectOption(null, "fetchLive", true, true); - if (subscription.doFetchStreams) - menu.selectOption(null, "fetchStreams", true, true); - if (subscription.doFetchVideos) - menu.selectOption(null, "fetchVideos", true, true); - if (subscription.doFetchPosts) - menu.selectOption(null, "fetchPosts", true, true); + if(subscription.doNotifications) + menu.selectOption(null, "notifications", true, true); + if(subscription.doFetchLive) + menu.selectOption(null, "fetchLive", true, true); + if(subscription.doFetchStreams) + menu.selectOption(null, "fetchStreams", true, true); + if(subscription.doFetchVideos) + menu.selectOption(null, "fetchVideos", true, true); + if(subscription.doFetchPosts) + menu.selectOption(null, "fetchPosts", true, true); - menu.onOK.subscribe { - subscription.save(); - menu.hide(true); + menu.onOK.subscribe { + subscription.save(); + menu.hide(true); - if (subscription.doNotifications && !originalNotif) { - val mainContext = StateApp.instance.contextOrNull; - if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { - UIDialogs.toast( - container.context, - "Enable 'Background Update' in settings for notifications to work" - ); + if(subscription.doNotifications && !originalNotif) { + val mainContext = StateApp.instance.contextOrNull; + if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { + UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work"); - if (mainContext is MainActivity) { - UIDialogs.showDialog( - mainContext, - R.drawable.ic_settings, - "Background Updating Required", - "You need to set a Background Updating interval for notifications", - null, - 0, - UIDialogs.Action("Cancel", {}), - UIDialogs.Action("Configure", { - val intent = Intent( - mainContext, - SettingsActivity::class.java - ); - intent.putExtra( - "query", - mainContext.getString(R.string.background_update) - ); - mainContext.startActivity(intent); - }, UIDialogs.ActionStyle.PRIMARY) - ); - } - return@subscribe; - } else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { - UIDialogs.toast( - container.context, - "Android notifications are disabled" - ); - if (mainContext is MainActivity) { - mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); - } + if(mainContext is MainActivity) { + UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required", + "You need to set a Background Updating interval for notifications", null, 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Configure", { + val intent = Intent(mainContext, SettingsActivity::class.java); + intent.putExtra("query", mainContext.getString(R.string.background_update)); + mainContext.startActivity(intent); + }, UIDialogs.ActionStyle.PRIMARY)); + } + return@subscribe; + } + else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { + UIDialogs.toast(container.context, "Android notifications are disabled"); + if(mainContext is MainActivity) { + mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); } } - }; - menu.onCancel.subscribe { - subscription.doNotifications = originalNotif; - subscription.doFetchLive = originalLive; - subscription.doFetchStreams = originalStream; - subscription.doFetchVideos = originalVideo; - subscription.doFetchPosts = originalPosts; - }; + } + }; + menu.onCancel.subscribe { + subscription.doNotifications = originalNotif; + subscription.doFetchLive = originalLive; + subscription.doFetchStreams = originalStream; + subscription.doFetchVideos = originalVideo; + subscription.doFetchPosts = originalPosts; + }; - menu.setOk("Save"); + menu.setOk("Save"); - menu.show(); - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to show subscription overlay.", e) + menu.show(); } } @@ -379,7 +299,6 @@ class UISlideOverlays { } - @OptIn(UnstableApi::class) fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { val items = arrayListOf(LoaderView(container.context)) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) @@ -391,8 +310,6 @@ class UISlideOverlays { val masterPlaylistContent = masterPlaylistResponse.body?.string() ?: throw Exception("Master playlist content is empty") - val resolvedPlaylistUrl = masterPlaylistResponse.url - val videoButtons = arrayListOf() val audioButtons = arrayListOf() //TODO: Implement subtitles @@ -405,103 +322,55 @@ class UISlideOverlays { val masterPlaylist: HLS.MasterPlaylist try { - val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray()) - val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser() - .parse(sourceUrl.toUri(), inputStream) + masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) - if (playlist is HlsMediaPlaylist) { - if (source is IHLSManifestAudioSource) { - val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!! + masterPlaylist.getAudioSources().forEach { it -> - val estSize = VideoHelper.estimateSourceSize(variant); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - audioButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_music, - variant.name, - listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), - (prefix + variant.codec).trim(), - tag = variant, - call = { - selectedAudioVariant = variant - slideUpMenuOverlay.selectOption(audioButtons, variant) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, - invokeParent = false - )) - } else { - val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null))) - - val estSize = VideoHelper.estimateSourceSize(variant); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - videoButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_movie, - variant.name, - "${variant.width}x${variant.height}", - (prefix + variant.codec).trim(), - tag = variant, - call = { - selectedVideoVariant = variant - slideUpMenuOverlay.selectOption(videoButtons, variant) - if (audioButtons.isEmpty()){ - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - } - }, - invokeParent = false - )) - } - } else if (playlist is HlsMultivariantPlaylist) { - masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl) - - masterPlaylist.getAudioSources().forEach { it -> - - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - audioButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_music, - it.name, - listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), - (prefix + it.codec).trim(), - tag = it, - call = { - selectedAudioVariant = it - slideUpMenuOverlay.selectOption(audioButtons, it) - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, - invokeParent = false - )) - } - - /*masterPlaylist.getSubtitleSources().forEach { it -> - subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, { - selectedSubtitleVariant = it - slideUpMenuOverlay.selectOption(subtitleButtons, it) + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + audioButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_music, + it.name, + listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), + (prefix + it.codec).trim(), + tag = it, + call = { + selectedAudioVariant = it + slideUpMenuOverlay.selectOption(audioButtons, it) slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - }, false)) - }*/ + }, + invokeParent = false + )) + } - masterPlaylist.getVideoSources().forEach { - val estSize = VideoHelper.estimateSourceSize(it); - val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; - videoButtons.add(SlideUpMenuItem( - container.context, - R.drawable.ic_movie, - it.name, - "${it.width}x${it.height}", - (prefix + it.codec).trim(), - tag = it, - call = { - selectedVideoVariant = it - slideUpMenuOverlay.selectOption(videoButtons, it) - if (audioButtons.isEmpty()){ - slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) - } - }, - invokeParent = false - )) - } + /*masterPlaylist.getSubtitleSources().forEach { it -> + subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, { + selectedSubtitleVariant = it + slideUpMenuOverlay.selectOption(subtitleButtons, it) + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + }, false)) + }*/ + + masterPlaylist.getVideoSources().forEach { + val estSize = VideoHelper.estimateSourceSize(it); + val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; + videoButtons.add(SlideUpMenuItem( + container.context, + R.drawable.ic_movie, + it.name, + "${it.width}x${it.height}", + (prefix + it.codec).trim(), + tag = it, + call = { + selectedVideoVariant = it + slideUpMenuOverlay.selectOption(videoButtons, it) + if (audioButtons.isEmpty()){ + slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) + } + }, + invokeParent = false + )) } val newItems = arrayListOf() @@ -529,11 +398,11 @@ class UISlideOverlays { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { withContext(Dispatchers.Main) { if (source is IHLSManifestSource) { - StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null) + StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null) UIDialogs.toast(container.context, "Variant video HLS playlist download started") slideUpMenuOverlay.hide() } else if (source is IHLSManifestAudioSource) { - StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null) + StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { @@ -815,10 +684,6 @@ class UISlideOverlays { } } } - if(!Settings.instance.downloads.shouldDownload()) { - UIDialogs.appToast("Download will start when you're back on wifi.\n" + - "(You can change this in settings)", true); - } } }; return menu.apply { show() }; @@ -1115,30 +980,26 @@ class UISlideOverlays { + actions).filterNotNull() )); items.add( - SlideUpMenuGroup( - container.context, container.context.getString(R.string.add_to), "addto", - SlideUpMenuItem( - container.context, + SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", + SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), tag = "queue", call = { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem( - container.context, + SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), tag = "watch later", call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), - SlideUpMenuItem( - container.context, + SlideUpMenuItem(container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", tag = "history", call = { StateHistory.instance.markAsWatched(video); }), - )); + )); val playlistItems = arrayListOf(); playlistItems.add(SlideUpMenuItem( @@ -1202,17 +1063,14 @@ class UISlideOverlays { val queue = StatePlayer.instance.getQueue(); val watchLater = StatePlaylists.instance.getWatchLater(); items.add( - SlideUpMenuGroup( - container.context, container.context.getString(R.string.other), "other", - SlideUpMenuItem( - container.context, + SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", + SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), tag = "queue", call = { StatePlayer.instance.addToQueue(video); }), - SlideUpMenuItem( - container.context, + SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), @@ -1220,10 +1078,8 @@ class UISlideOverlays { call = { if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true)) UIDialogs.appToast("Added to watch later", false); - else - UIDialogs.toast(container.context.getString(R.string.already_in_watch_later)) }), - ) + ) ); val playlistItems = arrayListOf(); @@ -1261,8 +1117,8 @@ class UISlideOverlays { return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; } - fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>): SlideUpMenuFilters { - val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues); + fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>, isChannelSearch: Boolean = false): SlideUpMenuFilters { + val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch); overlay.show(); return overlay; } diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 0875aadb..5cd5d26f 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -28,17 +28,12 @@ import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.others.PlatformLinkMovementMethod import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.File 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.nio.ByteOrder import java.time.OffsetDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom @@ -75,14 +70,7 @@ fun warnIfMainThread(context: String) { } fun ensureNotMainThread() { - val isMainLooper = try { - Looper.myLooper() == Looper.getMainLooper() - } catch (e: Throwable) { - //Ignore, for unit tests where its not mocked - false - } - - if (isMainLooper) { + if (Looper.myLooper() == Looper.getMainLooper()) { Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread") throw IllegalStateException("Cannot run on main thread") } @@ -285,7 +273,7 @@ fun findNewIndex(originalArr: List, newArr: List, item: T): Int{ } } if(newIndex < 0) - return newArr.size; + return originalArr.size; else return newIndex; } @@ -296,18 +284,6 @@ fun ByteBuffer.toUtf8String(): String { return String(remainingBytes, Charsets.UTF_8) } -fun generateReadablePassword(length: Int): String { - val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" - val secureRandom = SecureRandom() - val randomBytes = ByteArray(length) - secureRandom.nextBytes(randomBytes) - val sb = StringBuilder(length) - for (byte in randomBytes) { - val index = (byte.toInt() and 0xFF) % validChars.length - sb.append(validChars[index]) - } - return sb.toString() -} fun ByteArray.toGzip(): ByteArray { if (this == null || this.isEmpty()) return ByteArray(0) @@ -337,125 +313,4 @@ fun ByteArray.fromGzip(): ByteArray { } } return outputStream.toByteArray() -} - -fun findCandidateAddresses(): List { - 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 - .sortedWith( - compareBy>( - { addressScore(it.second.address) }, - { interfaceScore(it.first) }, - { -it.second.networkPrefixLength.toInt() }, - { -it.first.mtu } - ) - ).map { it.second.address } -} - -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>( - { 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 - } -} - -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 Enumeration.toList(): List = Collections.list(this) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 0d5bf8d9..25febdb1 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,15 +1,14 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint -import android.app.AlertDialog -import android.app.UiModeManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.content.res.Configuration +import android.media.AudioManager import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.StrictMode import android.os.StrictMode.VmPolicy @@ -22,7 +21,6 @@ import android.widget.ImageView import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.motion.widget.MotionLayout import androidx.core.app.ActivityCompat @@ -32,7 +30,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.withStateAtLeast import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R @@ -41,9 +38,7 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.dp import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment -import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment @@ -62,7 +57,6 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment -import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment @@ -71,9 +65,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment -import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment -import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment @@ -82,6 +74,7 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.UrlVideoWithTime +import com.futo.platformplayer.receivers.MediaButtonReceiver import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup @@ -114,7 +107,7 @@ import java.io.PrintWriter import java.io.StringWriter import java.lang.reflect.InvocationTargetException import java.util.LinkedList -import java.util.UUID +import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue @@ -153,8 +146,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { //Frags Main lateinit var _fragMainHome: HomeFragment; lateinit var _fragPostDetail: PostDetailFragment; - lateinit var _fragArticleDetail: ArticleDetailFragment; - lateinit var _fragWebDetail: WebDetailFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; @@ -170,7 +161,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; - lateinit var _fragShorts: ShortsFragment; lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; @@ -185,7 +175,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragVideoDetail: VideoDetailFragment; //State - private val _queue: LinkedList> = LinkedList(); + private val _queue: Queue> = LinkedList(); lateinit var fragCurrent: MainFragment private set; private var _parameterCurrent: Any? = null; @@ -195,9 +185,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private var _isVisible = true; private var _wasStopped = false; - private var _privateModeEnabled = false - private var _pictureInPictureEnabled = false - private var _isFullscreen = false private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) @@ -209,7 +196,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } try { - lifecycleScope.launch { + runBlocking { handleUrlAll(content) } } catch (e: Throwable) { @@ -219,8 +206,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } - val mainId = UUID.randomUUID().toString().substring(0, 5) - constructor() : super() { if (BuildConfig.DEBUG) { StrictMode.setVmPolicy( @@ -272,15 +257,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { @UnstableApi override fun onCreate(savedInstanceState: Bundle?) { - Logger.w(TAG, "MainActivity Starting [$mainId]"); - StateApp.instance.setGlobalContext(this, lifecycleScope, mainId); + Logger.i(TAG, "MainActivity Starting"); + StateApp.instance.setGlobalContext(this, lifecycleScope); StateApp.instance.mainAppStarting(this); super.onCreate(savedInstanceState); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val uiMode = getSystemService(UiModeManager::class.java) - uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES) - } setContentView(R.layout.activity_main); setNavigationBarColorAndIcons(); if (Settings.instance.playback.allowVideoToGoUnderCutout) @@ -288,11 +269,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES runBlocking { - try { - StatePlatform.instance.updateAvailableClients(this@MainActivity); - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in updateAvailableClients", e) - } + StatePlatform.instance.updateAvailableClients(this@MainActivity); } //Preload common files to memory @@ -336,11 +313,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance(); - _fragArticleDetail = ArticleDetailFragment.newInstance(); - _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); - _fragShorts = ShortsFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); _fragDownloads = DownloadsFragment(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); @@ -380,18 +354,22 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSubscriptionsFeed.setPreviewsEnabled(true); _fragContainerVideoDetail.visibility = View.INVISIBLE; updateSegmentPaddings(); - updatePrivateModeVisibility() }; _buttonIncognito = findViewById(R.id.incognito_button); - updatePrivateModeVisibility() + _buttonIncognito.elevation = -99f; + _buttonIncognito.alpha = 0f; StateApp.instance.privateModeChanged.subscribe { //Messing with visibility causes some issues with layout ordering? - _privateModeEnabled = it - updatePrivateModeVisibility() + if (it) { + _buttonIncognito.elevation = 99f; + _buttonIncognito.alpha = 1f; + } else { + _buttonIncognito.elevation = -99f; + _buttonIncognito.alpha = 0f; + } } - _buttonIncognito.setOnClickListener { if (!StateApp.instance.privateMode) return@setOnClickListener; @@ -408,16 +386,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }; _fragVideoDetail.onFullscreenChanged.subscribe { Logger.i(TAG, "onFullscreenChanged ${it}"); - _isFullscreen = it - updatePrivateModeVisibility() - } - _fragVideoDetail.onMinimize.subscribe { - updatePrivateModeVisibility() - } - - _fragVideoDetail.onMaximized.subscribe { - updatePrivateModeVisibility() + if (it) { + _buttonIncognito.elevation = -99f; + _buttonIncognito.alpha = 0f; + } else { + if (StateApp.instance.privateMode) { + _buttonIncognito.elevation = 99f; + _buttonIncognito.alpha = 1f; + } else { + _buttonIncognito.elevation = -99f; + _buttonIncognito.alpha = 0f; + } + } } StatePlayer.instance.also { @@ -465,8 +446,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainPlaylist.topBar = _fragTopBarNavigation; _fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation; - _fragArticleDetail.topBar = _fragTopBarNavigation; - _fragWebDetail.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation; _fragSourceDetail.topBar = _fragTopBarNavigation; @@ -611,8 +590,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }, UIDialogs.ActionStyle.PRIMARY) ) } - - //startActivity(Intent(this, TestActivity::class.java)) } /* @@ -636,18 +613,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); }*/ - private var _qrCodeLoadingDialog: AlertDialog? = null - fun showUrlQrCodeScanner() { try { - _qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true, - "Launching QR scanner", - "Make sure your camera is enabled", null, -2, - UIDialogs.Action("Close", { - _qrCodeLoadingDialog?.dismiss() - _qrCodeLoadingDialog = null - })); - val integrator = IntentIntegrator(this) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setPrompt(getString(R.string.scan_a_qr_code)) @@ -663,36 +630,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } - @OptIn(UnstableApi::class) - private fun updatePrivateModeVisibility() { - if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) { - _buttonIncognito.elevation = 99f; - _buttonIncognito.alpha = 1f; - _buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f - } else { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } - } - override fun onResume() { super.onResume(); - Logger.w(TAG, "onResume [$mainId]") + Logger.v(TAG, "onResume") _isVisible = true; } override fun onPause() { super.onPause(); - Logger.w(TAG, "onPause [$mainId]") + Logger.v(TAG, "onPause") _isVisible = false; - - _qrCodeLoadingDialog?.dismiss() - _qrCodeLoadingDialog = null } override fun onStop() { super.onStop() - Logger.w(TAG, "onStop [$mainId]"); + Logger.v(TAG, "_wasStopped = true"); _wasStopped = true; } @@ -726,7 +678,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { "VIDEO" -> { val url = intent.getStringExtra("VIDEO"); - navigateWhenReady(_fragVideoDetail, url); + navigate(_fragVideoDetail, url); } "IMPORT_OPTIONS" -> { @@ -744,11 +696,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { "Sources" -> { runBlocking { StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. - navigateWhenReady(_fragMainSources); + navigate(_fragMainSources); } }; "BROWSE_PLUGINS" -> { - navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( + navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( Pair("grayjay") { req -> StateApp.instance.contextOrNull?.let { if (it is MainActivity) { @@ -766,12 +718,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { try { if (targetData != null) { - lifecycleScope.launch(Dispatchers.Main) { - try { - handleUrlAll(targetData) - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in handleUrlAll", e) - } + runBlocking { + handleUrlAll(targetData) } } } catch (ex: Throwable) { @@ -799,10 +747,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { startActivity(intent); } else if (url.startsWith("grayjay://video/")) { val videoUrl = url.substring("grayjay://video/".length); - navigateWhenReady(_fragVideoDetail, videoUrl); + navigate(_fragVideoDetail, videoUrl); } else if (url.startsWith("grayjay://channel/")) { val channelUrl = url.substring("grayjay://channel/".length); - navigateWhenReady(_fragMainChannel, channelUrl); + navigate(_fragMainChannel, channelUrl); } } @@ -868,29 +816,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { return withContext(Dispatchers.IO) { Logger.i(TAG, "handleUrl(url=$url) on IO"); - if (StatePlatform.instance.hasEnabledContentClient(url)) { + if (StatePlatform.instance.hasEnabledVideoClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found video client"); - withContext(Dispatchers.Main) { + lifecycleScope.launch(Dispatchers.Main) { if (position > 0) - navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); + navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); else - navigateWhenReady(_fragVideoDetail, url); + navigate(_fragVideoDetail, url); _fragVideoDetail.maximizeVideoDetail(true); } return@withContext true; } else if (StatePlatform.instance.hasEnabledChannelClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found channel client"); - withContext(Dispatchers.Main) { - navigateWhenReady(_fragMainChannel, url); + lifecycleScope.launch(Dispatchers.Main) { + navigate(_fragMainChannel, url); delay(100); _fragVideoDetail.minimizeVideoDetail(); }; return@withContext true; } else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) { Logger.i(TAG, "handleUrl(url=$url) found playlist client"); - withContext(Dispatchers.Main) { - navigateWhenReady(_fragMainRemotePlaylist, url); + lifecycleScope.launch(Dispatchers.Main) { + navigate(_fragMainRemotePlaylist, url); delay(100); _fragVideoDetail.minimizeVideoDetail(); }; @@ -1102,33 +1050,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") _fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); Logger.v(TAG, "onPictureInPictureModeChanged Ready"); - - _pictureInPictureEnabled = isInPictureInPictureMode - updatePrivateModeVisibility() } override fun onDestroy() { super.onDestroy(); - Logger.w(TAG, "onDestroy [$mainId]") - StateApp.instance.mainAppDestroyed(this, mainId); + Logger.v(TAG, "onDestroy") + StateApp.instance.mainAppDestroyed(this); } inline fun isFragmentActive(): Boolean { 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 @@ -1190,6 +1123,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) fragBeforeOverlay = fragCurrent; + fragCurrent = segment; _parameterCurrent = parameter; } @@ -1252,11 +1186,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { PlaylistFragment::class -> _fragMainPlaylist as T; RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; PostDetailFragment::class -> _fragPostDetail as T; - ArticleDetailFragment::class -> _fragArticleDetail as T; - WebDetailFragment::class -> _fragWebDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; - ShortsFragment::class -> _fragShorts as T; SourceDetailFragment::class -> _fragSourceDetail as T; DownloadsFragment::class -> _fragDownloads as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt index 9cf58134..a0a0fac1 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -14,12 +14,10 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateApp.Companion.withContext import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.buttons.BigButton import com.futo.polycentric.core.ContentType @@ -31,9 +29,6 @@ import com.futo.polycentric.core.toBase64Url import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.google.zxing.common.BitMatrix -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import userpackage.Protocol import userpackage.Protocol.ExportBundle import userpackage.Protocol.URLInfo @@ -44,7 +39,6 @@ class PolycentricBackupActivity : AppCompatActivity() { private lateinit var _imageQR: ImageView; private lateinit var _exportBundle: String; private lateinit var _textQR: TextView; - private lateinit var _loader: View override fun attachBaseContext(newBase: Context?) { super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) @@ -55,47 +49,24 @@ class PolycentricBackupActivity : AppCompatActivity() { setContentView(R.layout.activity_polycentric_backup); setNavigationBarColorAndIcons(); - _buttonShare = findViewById(R.id.button_share) - _buttonCopy = findViewById(R.id.button_copy) - _imageQR = findViewById(R.id.image_qr) - _textQR = findViewById(R.id.text_qr) - _loader = findViewById(R.id.progress_loader) + _buttonShare = findViewById(R.id.button_share); + _buttonCopy = findViewById(R.id.button_copy); + _imageQR = findViewById(R.id.image_qr); + _textQR = findViewById(R.id.text_qr); findViewById(R.id.button_back).setOnClickListener { finish(); }; - _imageQR.visibility = View.INVISIBLE - _textQR.visibility = View.INVISIBLE - _loader.visibility = View.VISIBLE - _buttonShare.visibility = View.INVISIBLE - _buttonCopy.visibility = View.INVISIBLE + _exportBundle = createExportBundle(); - lifecycleScope.launch { - try { - val pair = withContext(Dispatchers.IO) { - val bundle = createExportBundle() - val dimension = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics - ).toInt() - val qr = generateQRCode(bundle, dimension, dimension) - Pair(bundle, qr) - } - - _exportBundle = pair.first - _imageQR.setImageBitmap(pair.second) - _imageQR.visibility = View.VISIBLE - _textQR.visibility = View.VISIBLE - _buttonShare.visibility = View.VISIBLE - _buttonCopy.visibility = View.VISIBLE - } catch (e: Exception) { - Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e) - _imageQR.visibility = View.INVISIBLE - _textQR.visibility = View.INVISIBLE - _buttonShare.visibility = View.INVISIBLE - _buttonCopy.visibility = View.INVISIBLE - } finally { - _loader.visibility = View.GONE - } + try { + val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt(); + val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension); + _imageQR.setImageBitmap(qrCodeBitmap); + } catch (e: Exception) { + Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e); + _imageQR.visibility = View.INVISIBLE; + _textQR.visibility = View.INVISIBLE; } _buttonShare.onClick.subscribe { 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 0fff7b06..d1cd7706 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -9,8 +9,6 @@ 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 import com.futo.platformplayer.states.StateSync @@ -31,16 +29,6 @@ class SyncHomeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - if (StateApp.instance.contextOrNull == null) { - Logger.w(TAG, "No main activity, restarting main.") - val intent = Intent(this, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - startActivity(intent) - finish() - return - } - setContentView(R.layout.activity_sync_home) setNavigationBarColorAndIcons() @@ -66,6 +54,7 @@ class SyncHomeActivity : AppCompatActivity() { val view = _viewMap[publicKey] if (!session.isAuthorized) { if (view != null) { + _layoutDevices.removeView(view) _viewMap.remove(publicKey) } return@launch @@ -100,20 +89,6 @@ class SyncHomeActivity : AppCompatActivity() { updateEmptyVisibility() } } - - 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() - }) } override fun onDestroy() { @@ -125,12 +100,10 @@ class SyncHomeActivity : AppCompatActivity() { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { val connected = session?.connected ?: false - val authorized = session?.isAuthorized ?: false - - syncDeviceView.setLinkType(session?.linkType ?: LinkType.None) + syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) //TODO: also display public key? - .setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized") + .setStatus(if (connected) "Connected" else "Disconnected") return syncDeviceView } diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt index b0ca616a..a7030b97 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt @@ -83,7 +83,6 @@ class SyncPairActivity : AppCompatActivity() { _layoutPairingSuccess.setOnClickListener { _layoutPairingSuccess.visibility = View.GONE - finish() } _layoutPairingError.setOnClickListener { _layoutPairingError.visibility = View.GONE @@ -110,17 +109,11 @@ class SyncPairActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - StateSync.instance.syncService?.connect(deviceInfo) { complete, message -> + StateSync.instance.connect(deviceInfo) { session, complete, message -> lifecycleScope.launch(Dispatchers.Main) { - if (complete != null) { - if (complete) { - _layoutPairingSuccess.visibility = View.VISIBLE - _layoutPairing.visibility = View.GONE - } else { - _textError.text = message - _layoutPairingError.visibility = View.VISIBLE - _layoutPairing.visibility = View.GONE - } + if (complete) { + _layoutPairingSuccess.visibility = View.VISIBLE + _layoutPairing.visibility = View.GONE } else { _textPairingStatus.text = message } @@ -144,6 +137,8 @@ class SyncPairActivity : AppCompatActivity() { _textError.text = e.message _layoutPairing.visibility = View.GONE Logger.e(TAG, "Failed to pair", e) + } finally { + _layoutPairing.visibility = View.GONE } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt index 848c320c..2fbb4b97 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt @@ -67,18 +67,11 @@ class SyncShowPairingCodeActivity : AppCompatActivity() { } val ips = getIPs() - val publicKey = StateSync.instance.syncService?.publicKey - val pairingCode = StateSync.instance.syncService?.pairingCode - if (publicKey == null || pairingCode == null) { - setCode("Public key or pairing code was not known, is sync enabled?") - } else { - val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode) - val json = Json.encodeToString(selfDeviceInfo) - val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - val url = "grayjay://sync/${base64}" - setCode(url) - } - + val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT) + val json = Json.encodeToString(selfDeviceInfo) + val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val url = "grayjay://sync/${base64}" + setCode(url) } fun setCode(code: String?) { diff --git a/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt index 5f9e0a10..608bda0a 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt @@ -2,24 +2,12 @@ package com.futo.platformplayer.activities import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R -import com.futo.platformplayer.views.TargetTapLoaderView -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch class TestActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_test); - - val view = findViewById(R.id.test_view) - view.startLoader(10000) - - lifecycleScope.launch { - delay(5000) - view.startLoader() - } } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt index 089c8106..641dbed2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt @@ -90,7 +90,6 @@ open class ManagedHttpClient { } fun tryHead(url: String): Map? { - ensureNotMainThread() try { val result = head(url); if(result.isOk) @@ -105,7 +104,7 @@ open class ManagedHttpClient { } fun socket(url: String, headers: MutableMap = HashMap(), listener: SocketListener): Socket { - ensureNotMainThread() + val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .url(url); if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" }) @@ -301,7 +300,6 @@ open class ManagedHttpClient { } fun send(msg: String) { - ensureNotMainThread() socket.send(msg); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 894dfca4..590ecc32 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -1,6 +1,5 @@ package com.futo.platformplayer.api.media -import com.futo.platformplayer.api.media.models.IPlatformChannelContent import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -13,7 +12,6 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails -import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.models.ImageVariable @@ -37,11 +35,6 @@ interface IPlatformClient { */ fun getHome(): IPager - /** - * Gets the shorts feed - */ - fun getShorts(): IPager - //Search /** * Gets search suggestion for the provided query string @@ -73,11 +66,6 @@ interface IPlatformClient { */ fun searchChannels(query: String): IPager; - /** - * Searches for channels and returns a content pager - */ - fun searchChannelsAsContent(query: String): IPager; - //Video Pages /** @@ -182,10 +170,6 @@ interface IPlatformClient { * Retrieves the subscriptions of the currently logged in user */ fun getUserSubscriptions(): Array; - /** - * Retrieves the history of the currently logged in user - */ - fun getUserHistory(): IPager; fun isClaimTypeSupported(claimType: Int): Boolean; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt index b6f61a33..ab903057 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/LiveChatManager.kt @@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.live.LiveEventComment import com.futo.platformplayer.api.media.models.live.LiveEventEmojis import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager -import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.BatchedTaskHandler import com.futo.platformplayer.logging.Logger @@ -27,17 +26,12 @@ class LiveChatManager { private val _emojiCache: EmojiCache = EmojiCache(); private val _pager: IPager?; - private var _position: Long = 0; - private var _eventsPosition: Long = 0; - private val _history: ArrayList = arrayListOf(); private var _startCounter = 0; private val _followers: HashMap) -> Unit> = hashMapOf(); - val isVOD get() = _pager is JSVODEventPager; - var viewCount: Long = 0 private set; @@ -45,24 +39,8 @@ class LiveChatManager { _scope = scope; _pager = pager; viewCount = initialViewCount; - if(pager is JSVODEventPager) - handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); - else - handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); - - if(pager is JSVODEventPager) { - var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis }; - //TODO: Remove this once dripfeed is done properly - replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis }; - if(replayResults.size > 0) { - _eventsPosition = replayResults.maxOf { it.time }; - Logger.i(TAG, "VOD Events last event: " + _eventsPosition); - } - else - _eventsPosition = _eventsPosition + 1500; - } - else - handleEvents(pager.getResults()); + handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); + handleEvents(pager.getResults()); } fun start() { @@ -74,10 +52,6 @@ class LiveChatManager { _startCounter++; } - fun setVideoPosition(ms: Long) { - _position = ms; - } - fun getHistory(): List { synchronized(_history) { return _history.toList(); @@ -111,34 +85,13 @@ class LiveChatManager { try { while(_startCounter == counter) { var nextInterval = 1000L; - if(_pager is JSVODEventPager && _eventsPosition > _position) { - delay(500); - continue; - } - try { if(_pager == null || !_pager.hasMorePages()) return@launch; - val newEvents = if(_pager is JSVODEventPager) { - val requestPosition = _position; - _pager.nextPage(requestPosition.toInt()); - var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis }; - if(replayResults.size > 0) { - _eventsPosition = replayResults.maxOf { it.time }; - Logger.i(TAG, "VOD Events last event: " + _eventsPosition); - } - else - _eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong(); - replayResults; - } - else { - _pager.nextPage(); - _pager.getResults(); - } + _pager.nextPage(); + val newEvents = _pager.getResults(); if(_pager is JSLiveEventPager) nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); - else if(_pager is JSVODEventPager) - nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); if(newEvents.size > 0) Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]"); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt index aa65a18b..cb62b66c 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt @@ -20,8 +20,7 @@ data class PlatformClientCapabilities( val hasGetContentChapters: Boolean = false, val hasPeekChannelContents: Boolean = false, val hasGetChannelPlaylists: Boolean = false, - val hasGetContentRecommendations: Boolean = false, - val hasGetUserHistory: Boolean = false + val hasGetContentRecommendations: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt index ce3a720e..0119bf38 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt @@ -14,16 +14,14 @@ class PlatformClientPool { private var _poolCounter = 0; private val _poolName: String?; private val _privatePool: Boolean; - private val _isolatedInitialization: Boolean var isDead: Boolean = false private set; val onDead = Event2(); - constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) { + constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) { _poolName = name; _privatePool = privatePool; - _isolatedInitialization = isolatedInitialization if(parentClient !is JSClient) throw IllegalArgumentException("Pooling only supported for JSClients right now"); Logger.i(TAG, "Pool for ${parentClient.name} was started"); @@ -34,10 +32,8 @@ class PlatformClientPool { isDead = true; onDead.emit(parentClient, this); - synchronized(_pool) { - for (clientPair in _pool) { - clientPair.key.disable(); - } + for(clientPair in _pool) { + clientPair.key.disable(); } }; } @@ -57,7 +53,7 @@ class PlatformClientPool { reserved = _pool.keys.find { !it.isBusy }; if(reserved == null && _pool.size < capacity) { Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})"); - reserved = _parent.getCopy(_privatePool, _isolatedInitialization); + reserved = _parent.getCopy(_privatePool); reserved?.onCaptchaException?.subscribe { client, ex -> StateApp.instance.handleCaptchaException(client, ex); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt index 4ff3a549..9b063c9b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformID.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullable @@ -45,7 +44,6 @@ class PlatformID { val NONE = PlatformID("Unknown", null); fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID { - value.ensureIsBusy(); val contextName = "PlatformID"; return PlatformID( value.getOrThrow(config, "platform", contextName), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt index fcc85371..a9fc3819 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt @@ -7,15 +7,13 @@ class PlatformMultiClientPool { private var _isFake = false; private var _privatePool = false; - private val _isolatedInitialization: Boolean - constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) { + constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) { _name = name; _maxCap = if(maxCap > 0) maxCap else 99; _privatePool = isPrivatePool; - _isolatedInitialization = isolatedInitialization } fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient { @@ -23,7 +21,7 @@ class PlatformMultiClientPool { return parentClient; val pool = synchronized(_clientPools) { if(!_clientPools.containsKey(parentClient)) - _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply { + _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply { this.onDead.subscribe { _, pool -> synchronized(_clientPools) { if(_clientPools[parentClient] == pool) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt index 831f8ef7..e0acb91e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt @@ -2,11 +2,7 @@ package com.futo.platformplayer.api.media.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.models.contents.ContentType -import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.platforms.js.models.JSContent -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -34,7 +30,6 @@ open class PlatformAuthorLink { val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null); fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink { - value.ensureIsBusy(); if(value.has("membershipUrl")) return PlatformAuthorMembershipLink.fromV8(config, value); @@ -47,21 +42,4 @@ open class PlatformAuthorLink { ); } } -} - -interface IPlatformChannelContent : IPlatformContent { - val thumbnail: String? - val subscribers: Long? -} - -open class JSChannelContent : JSContent, IPlatformChannelContent { - override val contentType: ContentType get() = ContentType.CHANNEL - override val thumbnail: String? - override val subscribers: Long? - - constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { - val contextName = "Channel"; - thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null) - subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorMembershipLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorMembershipLink.kt index 6b73842f..03abad1a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorMembershipLink.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorMembershipLink.kt @@ -3,7 +3,6 @@ package com.futo.platformplayer.api.media.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -21,7 +20,6 @@ class PlatformAuthorMembershipLink: PlatformAuthorLink { companion object { fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink { - value.ensureIsBusy(); val context = "AuthorMembershipLink" return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)), value.getOrThrow(config ,"name", context), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt index e95b3fe0..fd24de30 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ResultCapabilities.kt @@ -5,7 +5,6 @@ import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.expectV8Variant import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -47,7 +46,6 @@ class ResultCapabilities( fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities { val contextName = "ResultCapabilities"; - value.ensureIsBusy(); return ResultCapabilities( value.getOrThrow(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") }, value.getOrThrow(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); }, @@ -71,7 +69,6 @@ class FilterGroup( companion object { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup { - value.ensureIsBusy(); return FilterGroup( value.getString("name"), value.getOrDefault(config, "filters", "FilterGroup", null) @@ -93,7 +90,6 @@ class FilterCapability( companion object { fun fromV8(obj: V8ValueObject): FilterCapability { - obj.ensureIsBusy(); val value = obj.get("value") as V8Value; return FilterCapability( obj.getString("name"), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt index b25936a0..a30d31c9 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/Thumbnails.kt @@ -4,7 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -32,7 +31,6 @@ class Thumbnails { companion object { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails { - value.ensureIsBusy(); return Thumbnails((value.getOrThrow(config, "sources", "Thumbnails")) .toArray() .map { Thumbnail.fromV8(config, it as V8ValueObject) } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt deleted file mode 100644 index 818f8a3b..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticle.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.futo.platformplayer.api.media.models.article - -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.contents.IPlatformContent - -interface IPlatformArticle: IPlatformContent { - val summary: String?; - val thumbnails: Thumbnails?; -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt deleted file mode 100644 index be7f816d..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/article/IPlatformArticleDetails.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.futo.platformplayer.api.media.models.article - -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails -import com.futo.platformplayer.api.media.models.ratings.IRating -import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment - -interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails { - val segments: List; - val rating : IRating; -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt index 736e9090..a310e089 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -8,12 +8,10 @@ enum class ContentType(val value: Int) { POST(2), ARTICLE(3), PLAYLIST(4), - WEB(7), URL(9), NESTED_VIDEO(11), - CHANNEL(60), LOCKED(70), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt index 8c7ca14c..89826b01 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt @@ -2,17 +2,14 @@ package com.futo.platformplayer.api.media.models.live import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow interface IPlatformLiveEvent { val type : LiveEventType; - var time: Long; companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent { - obj.ensureIsBusy(); val t = LiveEventType.fromInt(obj.getOrThrow(config, "type", contextName)); return when(t) { LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt index 33818eb0..28bbe15a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventComment.kt @@ -4,7 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -18,21 +17,16 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage { val colorName: String?; val badges: List; - override var time: Long = -1; - - constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List? = null, time: Long = -1) { + constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List? = null) { this.name = name; this.message = message; this.thumbnail = thumbnail; this.colorName = colorName; this.badges = badges ?: listOf(); - this.time = time; } companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment { - obj.ensureIsBusy(); - val contextName = "LiveEventComment" val colorName = obj.getOrDefault(config, "colorName", contextName, null); @@ -42,8 +36,7 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage { obj.getOrThrow(config, "name", contextName), obj.getOrThrow(config, "thumbnail", contextName, true), obj.getOrThrow(config, "message", contextName), - colorName, badges, - obj.getOrDefault(config, "time", contextName, -1) ?: -1); + colorName, badges); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt index 49044906..a4ac5d47 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventDonation.kt @@ -3,7 +3,6 @@ package com.futo.platformplayer.api.media.models.live import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -21,8 +20,6 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage { var expire: Int = 6000; - override var time: Long = -1; - constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) { this.name = name; @@ -40,7 +37,6 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation { - obj.ensureIsBusy(); val contextName = "LiveEventDonation" return LiveEventDonation( obj.getOrThrow(config, "name", contextName), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt index f4141174..6e29bac5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.live import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class LiveEventEmojis: IPlatformLiveEvent { @@ -10,17 +9,15 @@ class LiveEventEmojis: IPlatformLiveEvent { val emojis: HashMap; - override var time: Long = -1; - constructor(emojis: HashMap) { this.emojis = emojis; } companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis { - obj.ensureIsBusy(); val contextName = "LiveEventEmojis" - return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName)); + return LiveEventEmojis( + obj.getOrThrow(config, "emojis", contextName)); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt index 6f7740a0..ff5dd36f 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventRaid.kt @@ -2,8 +2,6 @@ package com.futo.platformplayer.api.media.models.live import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy -import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow class LiveEventRaid: IPlatformLiveEvent { @@ -12,26 +10,20 @@ class LiveEventRaid: IPlatformLiveEvent { val targetName: String; val targetThumbnail: String; val targetUrl: String; - val isOutgoing: Boolean; - override var time: Long = -1; - - constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) { + constructor(name: String, url: String, thumbnail: String) { this.targetName = name; this.targetUrl = url; this.targetThumbnail = thumbnail; - this.isOutgoing = isOutgoing; } companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid { - obj.ensureIsBusy(); val contextName = "LiveEventRaid" return LiveEventRaid( obj.getOrThrow(config, "targetName", contextName), obj.getOrThrow(config, "targetUrl", contextName), - obj.getOrThrow(config, "targetThumbnail", contextName), - obj.getOrDefault(config, "isOutgoing", contextName, true) ?: true); + obj.getOrThrow(config, "targetThumbnail", contextName)); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt index d69a6ccb..adcfb883 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventViewCount.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.live import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class LiveEventViewCount: IPlatformLiveEvent { @@ -10,15 +9,12 @@ class LiveEventViewCount: IPlatformLiveEvent { val viewCount: Int; - override var time: Long = -1; - constructor(viewCount: Int) { this.viewCount = viewCount; } companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount { - obj.ensureIsBusy(); val contextName = "LiveEventViewCount" return LiveEventViewCount( obj.getOrThrow(config, "viewCount", contextName)); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt index c59b7e1a..c1de57d1 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt @@ -5,8 +5,7 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException enum class TextType(val value: Int) { RAW(0), HTML(1), - MARKUP(2), - CODE(3); + MARKUP(2); companion object { fun fromInt(value: Int): TextType diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt index 1fdbb442..75286b44 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt @@ -3,7 +3,6 @@ package com.futo.platformplayer.api.media.models.ratings import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.orDefault import com.futo.platformplayer.serializers.IRatingSerializer @@ -14,12 +13,8 @@ interface IRating { companion object { - fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating): IRating { - obj?.ensureIsBusy(); - return obj.orDefault(default) { fromV8(config, it as V8ValueObject) } - }; + fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) }; fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating { - obj.ensureIsBusy(); val t = RatingType.fromInt(obj.getOrThrow(config, "type", contextName)); return when(t) { RatingType.LIKES -> RatingLikes.fromV8(config, obj); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt index 8ccc6b2e..6d0e787b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikeDislikes.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.ratings import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow /** @@ -15,7 +14,6 @@ class RatingLikeDislikes(val likes: Long, val dislikes: Long) : IRating { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes { - obj.ensureIsBusy(); return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes")); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt index 0a45f15b..e40169f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingLikes.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.ratings import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow /** @@ -14,7 +13,6 @@ class RatingLikes(val likes: Long) : IRating { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes { - obj.ensureIsBusy(); return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes")); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt index d656df5f..7646cf24 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingScaler.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.ratings import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow /** @@ -14,7 +13,6 @@ class RatingScaler(val value: Float) : IRating { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler { - obj.ensureIsBusy() return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler")); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt index de635396..bd4a80ec 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/IPlatformVideo.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.models.video import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import java.time.OffsetDateTime /** * A search result representing a video (overview data) @@ -13,9 +12,6 @@ interface IPlatformVideo : IPlatformContent { val duration: Long; val viewCount: Long; - val playbackTime: Long; - val playbackDate: OffsetDateTime?; - val isLive : Boolean; val isShort: Boolean; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index 7388dfa8..c9e02d92 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -3,10 +3,11 @@ package com.futo.platformplayer.api.media.models.video import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.models.PlatformAuthorLink -import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.polycentric.core.combineHashCodes +import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNames @@ -17,7 +18,7 @@ open class SerializedPlatformVideo( override val contentType: ContentType = ContentType.MEDIA, override val id: PlatformID, override val name: String, - override val thumbnails: Thumbnails = Thumbnails(), + override val thumbnails: Thumbnails, override val author: PlatformAuthorLink, @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) @JsonNames("datetime", "dateTime") @@ -32,10 +33,6 @@ open class SerializedPlatformVideo( override val isLive: Boolean = false; - override var playbackTime: Long = -1; - @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) - override var playbackDate: OffsetDateTime? = null; - override fun toJson() : String { return Json.encodeToString(this); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt index b7e343ca..5354352f 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideoDetails.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.OffsetDateTime @@ -42,10 +43,6 @@ open class SerializedPlatformVideoDetails( ) : IPlatformVideo, IPlatformVideoDetails { final override val contentType: ContentType get() = ContentType.MEDIA; - override var playbackTime: Long = -1; - @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) - override var playbackDate: OffsetDateTime? = null; - override val isLive: Boolean get() = false; override val dash: IDashManifestSource? get() = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt index 1f29bf2a..1d726dd5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt @@ -54,12 +54,8 @@ class DevJSClient : JSClient { return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings); } - override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient { - val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID); - client.setReloadData(getReloadData(true)); - if (noSaveState) - client.initialize() - return client + override fun getCopy(privateCopy: Boolean): JSClient { + return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID); } override fun initialize() { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 2233048f..2b6deaf8 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -10,7 +10,6 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.PlatformClientCapabilities -import com.futo.platformplayer.api.media.models.IPlatformChannelContent import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -23,7 +22,6 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails -import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter @@ -33,7 +31,6 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs import com.futo.platformplayer.api.media.platforms.js.models.IJSContent import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.JSChannel -import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager import com.futo.platformplayer.api.media.platforms.js.models.JSChapter import com.futo.platformplayer.api.media.platforms.js.models.JSComment @@ -44,7 +41,6 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager -import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 @@ -61,13 +57,9 @@ import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.OffsetDateTime -import java.util.Random import kotlin.Exception import kotlin.reflect.full.findAnnotations import kotlin.reflect.jvm.kotlinFunction @@ -89,8 +81,6 @@ open class JSClient : IPlatformClient { private var _channelCapabilities: ResultCapabilities? = null; private var _peekChannelTypes: List? = null; - private var _usedReloadData: String? = null; - protected val _script: String; private var _initialized: Boolean = false; @@ -106,14 +96,14 @@ open class JSClient : IPlatformClient { override val icon: ImageVariable; override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities(); + private val _busyLock = Object(); + private var _busyCounter = 0; private var _busyAction = ""; - val isBusy: Boolean get() = _plugin.isBusy; + val isBusy: Boolean get() = _busyCounter > 0; val isBusyAction: String get() { return _busyAction; } - val declareOnEnable = HashMap(); - val settings: HashMap get() = descriptor.settings; val flags: Array; @@ -126,7 +116,6 @@ open class JSClient : IPlatformClient { val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true - val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true fun getSubscriptionRateLimit(): Int? { val pluginRateLimit = config.subscriptionRateLimit; @@ -204,12 +193,8 @@ open class JSClient : IPlatformClient { _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); } - open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient { - val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials); - client.setReloadData(getReloadData(true)); - if (noSaveState) - client.initialize() - return client + open fun getCopy(withoutCredentials: Boolean = false): JSClient { + return JSClient(_context, descriptor, saveState(), _script, withoutCredentials); } fun getUnderlyingPlugin(): V8Plugin { @@ -223,31 +208,12 @@ open class JSClient : IPlatformClient { return plugin.httpClientOthers[id]; } - fun setReloadData(data: String?) { - if(data == null) { - if(declareOnEnable.containsKey("__reloadData")) - declareOnEnable.remove("__reloadData"); - } - else - declareOnEnable.put("__reloadData", data ?: ""); - } - fun getReloadData(orLast: Boolean): String? { - if(declareOnEnable.containsKey("__reloadData")) - return declareOnEnable["__reloadData"]; - else if(orLast) - return _usedReloadData; - return null; - } - override fun initialize() { - if (_initialized) return - + Logger.i(TAG, "Plugin [${config.name}] initializing"); plugin.start(); - plugin.execute("plugin.config = ${Json.encodeToString(config)}"); plugin.execute("plugin.settings = parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())})"); - descriptor.appSettings.loadDefaults(descriptor.config); _initialized = true; @@ -272,8 +238,7 @@ open class JSClient : IPlatformClient { hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false, hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false, hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false, - hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false, - hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false + hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false ); try { @@ -288,28 +253,19 @@ open class JSClient : IPlatformClient { } @JSDocs(0, "source.enable()", "Called when the plugin is enabled/started") - fun enable() = isBusyWith("enable") { + fun enable() { if(!_initialized) initialize(); - for(toDeclare in declareOnEnable) { - plugin.execute("var ${toDeclare.key} = " + Json.encodeToString(toDeclare.value)); - } plugin.execute("source.enable(${Json.encodeToString(config)}, parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())}), ${Json.encodeToString(_injectedSaveState)})"); - - if(declareOnEnable.containsKey("__reloadData")) { - Logger.i(TAG, "Plugin [${config.name}] enabled with reload data: ${declareOnEnable["__reloadData"]}"); - _usedReloadData = declareOnEnable["__reloadData"]; - declareOnEnable.remove("__reloadData"); - } _enabled = true; } @JSDocs(1, "source.saveState()", "Provide a string that is passed to enable for quicker startup of multiple instances") - fun saveState(): String? = isBusyWith("saveState") { + fun saveState(): String? { ensureEnabled(); if(!capabilities.hasSaveState) - return@isBusyWith null; + return null; val resp = plugin.executeTyped("source.saveState()").value; - return@isBusyWith resp; + return resp; } @JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped") @@ -332,13 +288,6 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getHome()")); } - @JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform") - override fun getShorts(): IPager = isBusyWith("getShorts") { - ensureEnabled() - return@isBusyWith JSVideoPager(config, this, - plugin.executeTyped("source.getShorts()")) - } - @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocsParameter("query", "Query to complete suggestions for") override fun searchSuggestions(query: String): Array = isBusyWith("searchSuggestions") { @@ -357,10 +306,8 @@ open class JSClient : IPlatformClient { return _searchCapabilities!!; } - return busy { - _searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()")); - return@busy _searchCapabilities!!; - } + _searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()")); + return _searchCapabilities!!; } catch(ex: Throwable) { announcePluginUnhandledException("getSearchCapabilities", ex); @@ -388,10 +335,8 @@ open class JSClient : IPlatformClient { if (_searchChannelContentsCapabilities != null) return _searchChannelContentsCapabilities!!; - return busy { - _searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()")); - return@busy _searchChannelContentsCapabilities!!; - } + _searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()")); + return _searchChannelContentsCapabilities!!; } @JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform") @JSDocsParameter("channelUrl", "Channel url to search") @@ -416,21 +361,17 @@ open class JSClient : IPlatformClient { return@isBusyWith JSChannelPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); } - override fun searchChannelsAsContent(query: String): IPager = isBusyWith("searchChannels") { - ensureEnabled(); - return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), ); - } @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform") @JSDocsParameter("url", "A channel url (May not be your platform)") - override fun isChannelUrl(url: String): Boolean = isBusyWith("isChannelUrl") { + override fun isChannelUrl(url: String): Boolean { try { - return@isBusyWith plugin.executeTyped("source.isChannelUrl(${Json.encodeToString(url)})") + return plugin.executeTyped("source.isChannelUrl(${Json.encodeToString(url)})") .value; } catch(ex: Throwable) { announcePluginUnhandledException("isChannelUrl", ex); - return@isBusyWith false; + return false; } } @JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url") @@ -448,10 +389,9 @@ open class JSClient : IPlatformClient { if (_channelCapabilities != null) { return _channelCapabilities!!; } - return busy { - _channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()")); - return@busy _channelCapabilities!!; - }; + + _channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()")); + return _channelCapabilities!!; } catch(ex: Throwable) { announcePluginUnhandledException("getChannelCapabilities", ex); @@ -562,14 +502,14 @@ open class JSClient : IPlatformClient { @JSDocs(13, "source.isContentDetailsUrl(url)", "Validates if an content url is for this platform") @JSDocsParameter("url", "A content url (May not be your platform)") - override fun isContentDetailsUrl(url: String): Boolean = isBusyWith("isContentDetailsUrl") { + override fun isContentDetailsUrl(url: String): Boolean { try { - return@isBusyWith plugin.executeTyped("source.isContentDetailsUrl(${Json.encodeToString(url)})") + return plugin.executeTyped("source.isContentDetailsUrl(${Json.encodeToString(url)})") .value; } catch(ex: Throwable) { announcePluginUnhandledException("isContentDetailsUrl", ex); - return@isBusyWith false; + return false; } } @JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url") @@ -601,7 +541,7 @@ open class JSClient : IPlatformClient { Logger.i(TAG, "JSClient.getPlaybackTracker(${url})"); val tracker = plugin.executeTyped("source.getPlaybackTracker(${Json.encodeToString(url)})"); if(tracker is V8ValueObject) - return@isBusyWith JSPlaybackTracker(this, tracker); + return@isBusyWith JSPlaybackTracker(config, tracker); else return@isBusyWith null; } @@ -643,6 +583,7 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})")); } + @JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page") @JSDocsParameter("url", "Url of content") override fun getContentRecommendations(url: String): IPager? = isBusyWith("getContentRecommendations") { @@ -670,19 +611,17 @@ open class JSClient : IPlatformClient { @JSOptional @JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform") @JSDocsParameter("url", "Url of playlist") - override fun isPlaylistUrl(url: String): Boolean = isBusyWith("isPlaylistUrl") { + override fun isPlaylistUrl(url: String): Boolean { if (!capabilities.hasGetPlaylist) - return@isBusyWith false; + return false; try { - return@isBusyWith busy { - return@busy plugin.executeTyped("source.isPlaylistUrl(${Json.encodeToString(url)})") - .value; - } + return plugin.executeTyped("source.isPlaylistUrl(${Json.encodeToString(url)})") + .value; } catch(ex: Throwable) { announcePluginUnhandledException("isPlaylistUrl", ex); - return@isBusyWith false; + return false; } } @JSOptional @@ -713,13 +652,6 @@ open class JSClient : IPlatformClient { .toTypedArray(); } - @JSOptional - @JSDocs(23, "source.getUserHistory()", "Gets the history of the current user") - override fun getUserHistory(): IPager { - ensureEnabled(); - return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()")); - } - fun validate() { try { plugin.start(); @@ -791,29 +723,19 @@ open class JSClient : IPlatformClient { return urls; } - fun busy(handle: ()->T): T { - return _plugin.busy { - return@busy handle(); - } - } - fun busyBlockingSuspended(handle: suspend ()->T): T { - return _plugin.busy { - return@busy runBlocking { - return@runBlocking handle(); - } - } - } - - fun isBusyWith(actionName: String, handle: ()->T): T { - //val busyId = kotlin.random.Random.nextInt(9999); - return busy { - try { - _busyAction = actionName; - return@busy handle(); + private fun isBusyWith(actionName: String, handle: ()->T): T { + try { + synchronized(_busyLock) { + _busyCounter++; } - finally { - _busyAction = ""; + _busyAction = actionName; + return handle(); + } + finally { + _busyAction = ""; + synchronized(_busyLock) { + _busyCounter--; } } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index 8d5675b6..a637e89d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -4,7 +4,6 @@ import android.net.Uri import com.futo.platformplayer.SignatureProvider import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.states.StatePlugins import kotlinx.serialization.Contextual @@ -48,7 +47,6 @@ class SourcePluginConfig( var subscriptionRateLimit: Int? = null, var enableInSearch: Boolean = true, var enableInHome: Boolean = true, - var enableInShorts: Boolean = true, var supportedClaimTypes: List = listOf(), var primaryClaimFieldType: Int? = null, var developerSubmitUrl: String? = null, @@ -170,17 +168,12 @@ class SourcePluginConfig( } fun validate(text: String): Boolean { - try { - if (scriptPublicKey.isNullOrEmpty()) - throw IllegalStateException("No public key present"); - if (scriptSignature.isNullOrEmpty()) - throw IllegalStateException("No signature present"); + if(scriptPublicKey.isNullOrEmpty()) + throw IllegalStateException("No public key present"); + if(scriptSignature.isNullOrEmpty()) + throw IllegalStateException("No signature present"); - return SignatureProvider.verify(text, scriptSignature, scriptPublicKey); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to verify due to an unhandled exception", e) - return false - } + return SignatureProvider.verify(text, scriptSignature, scriptPublicKey); } fun isUrlAllowed(url: String): Boolean { @@ -211,8 +204,6 @@ class SourcePluginConfig( obj.sourceUrl = sourceUrl; return obj; } - - private val TAG = "SourcePluginConfig" } @kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index 771e1feb..add53131 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -5,16 +5,10 @@ import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.StateAnnouncement -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateHistory -import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.fields.DropdownFieldOptions import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField -import com.futo.platformplayer.views.fields.FormFieldButton import com.futo.platformplayer.views.fields.FormFieldWarning -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @Serializable @@ -109,22 +103,12 @@ class SourcePluginDescriptor { @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1) var enableHome: Boolean? = null; + @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2) var enableSearch: Boolean? = null; - - @FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3) - var enableShorts: Boolean? = null; } - @FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync") - var sync = Sync(); - @Serializable - class Sync { - @FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory") - var enableHistorySync: Boolean? = null; - } - - @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4) + @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3) var rateLimit = RateLimit(); @Serializable class RateLimit { @@ -159,8 +143,6 @@ class SourcePluginDescriptor { tabEnabled.enableHome = config.enableInHome if(tabEnabled.enableSearch == null) tabEnabled.enableSearch = config.enableInSearch - if(tabEnabled.enableShorts == null) - tabEnabled.enableShorts = config.enableInShorts } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index 03c5c2c6..da10e2ec 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -67,25 +67,6 @@ class JSHttpClient : ManagedHttpClient { } - fun resetAuthCookies() { - _currentCookieMap.clear(); - if(!_auth?.cookieMap.isNullOrEmpty()) { - for(domainCookies in _auth!!.cookieMap!!) - _currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value)); - } - if(!_captcha?.cookieMap.isNullOrEmpty()) { - for(domainCookies in _captcha!!.cookieMap!!) { - if(_currentCookieMap.containsKey(domainCookies.key)) - _currentCookieMap[domainCookies.key]?.putAll(domainCookies.value); - else - _currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value)); - } - } - } - fun clearOtherCookies() { - _otherCookieMap.clear(); - } - override fun clone(): ManagedHttpClient { val newClient = JSHttpClient(_jsClient, _auth); newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) }) @@ -146,7 +127,7 @@ class JSHttpClient : ManagedHttpClient { } if(doApplyCookies) { - if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) { + if (_currentCookieMap.isNotEmpty()) { val cookiesToApply = hashMapOf(); synchronized(_currentCookieMap) { for(cookie in _currentCookieMap @@ -154,12 +135,6 @@ class JSHttpClient : ManagedHttpClient { .flatMap { it.value.toList() }) cookiesToApply[cookie.first] = cookie.second; }; - synchronized(_otherCookieMap) { - for(cookie in _otherCookieMap - .filter { domain.matchesDomain(it.key) } - .flatMap { it.value.toList() }) - cookiesToApply[cookie.first] = cookie.second; - } if(cookiesToApply.size > 0) { val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt index 326b4086..a6a15fb6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt @@ -1,12 +1,10 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.models.JSChannelContent import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -14,7 +12,6 @@ interface IJSContent: IPlatformContent { companion object { fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent { - obj.ensureIsBusy(); val config = plugin.config; val type: Int = obj.getOrThrow(config, "contentType", "ContentItem"); val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null); @@ -29,9 +26,6 @@ interface IJSContent: IPlatformContent { ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj); - ContentType.CHANNEL -> JSChannelContent(config, obj); - ContentType.ARTICLE -> JSArticle(config, obj); - ContentType.WEB -> JSWeb(config, obj); else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt index 16470c17..04382057 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContentDetails.kt @@ -6,20 +6,17 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow interface IJSContentDetails: IPlatformContent { companion object { fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails { - obj.ensureIsBusy(); val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails"); return when(ContentType.fromInt(type)) { ContentType.MEDIA -> JSVideoDetails(plugin, obj); ContentType.POST -> JSPostDetails(plugin.config, obj); ContentType.ARTICLE -> JSArticleDetails(plugin, obj); - ContentType.WEB -> JSWebDetails(plugin, obj); else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt deleted file mode 100644 index d1fb658e..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticle.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.futo.platformplayer.api.media.platforms.js.models - -import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.IPlatformClient -import com.futo.platformplayer.api.media.IPluginSourced -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.article.IPlatformArticle -import com.futo.platformplayer.api.media.models.comments.IPlatformComment -import com.futo.platformplayer.api.media.models.contents.ContentType -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails -import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker -import com.futo.platformplayer.api.media.models.post.IPlatformPost -import com.futo.platformplayer.api.media.models.post.TextType -import com.futo.platformplayer.api.media.models.ratings.IRating -import com.futo.platformplayer.api.media.models.ratings.RatingLikes -import com.futo.platformplayer.api.media.platforms.js.DevJSClient -import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.getOrThrowNullableList -import com.futo.platformplayer.states.StateDeveloper - -open class JSArticle : JSContent, IPlatformArticle, IPluginSourced { - final override val contentType: ContentType get() = ContentType.ARTICLE; - - override val summary: String; - override val thumbnails: Thumbnails?; - - constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { - val contextName = "PlatformArticle"; - - summary = _content.getOrDefault(config, "summary", contextName, "") ?: ""; - thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt index 40c63d48..453c59d8 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt @@ -4,8 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.article.IPlatformArticle -import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent @@ -21,23 +19,22 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullableList -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper -open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { +open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails { final override val contentType: ContentType get() = ContentType.ARTICLE; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; - override val rating: IRating; + val rating: IRating; - override val summary: String; - override val thumbnails: Thumbnails?; - override val segments: List; + val summary: String; + val thumbnails: Thumbnails?; + val segments: List; constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { - val contextName = "PlatformArticle"; + val contextName = "PlatformPost"; rating = obj.getOrDefault(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); summary = _content.getOrThrow(client.config, "summary", contextName); @@ -86,12 +83,12 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced } private fun getContentRecommendationsJS(client: JSClient): JSContentPager { - val contentPager = _content.invokeV8("getContentRecommendations", arrayOf()); + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); return JSContentPager(_pluginConfig, client, contentPager); } private fun getCommentsJS(client: JSClient): JSCommentPager { - val commentPager = _content.invokeV8("getComments", arrayOf()); + val commentPager = _content.invoke("getComments", arrayOf()); return JSCommentPager(_pluginConfig, client, commentPager); } @@ -102,7 +99,6 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) { SegmentType.TEXT -> JSTextSegment(client, obj); SegmentType.IMAGES -> JSImagesSegment(client, obj); - SegmentType.HEADER -> JSHeaderSegment(client, obj); SegmentType.NESTED -> JSNestedSegment(client, obj); else -> null; } @@ -114,7 +110,6 @@ enum class SegmentType(val value: Int) { UNKNOWN(0), TEXT(1), IMAGES(2), - HEADER(3), NESTED(9); @@ -155,17 +150,6 @@ class JSImagesSegment: IJSArticleSegment { caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: ""; } } -class JSHeaderSegment: IJSArticleSegment { - override val type = SegmentType.HEADER; - val content: String; - val level: Int; - - constructor(client: JSClient, obj: V8ValueObject) { - val contextName = "JSHeaderSegment"; - content = obj.getOrDefault(client.config, "content", contextName, "") ?: ""; - level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1; - } -} class JSNestedSegment: IJSArticleSegment { override val type = SegmentType.NESTED; val nested: IPlatformContent; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt index 3a0331d4..683c64af 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt @@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.engine.V8Plugin class JSChannelPager : JSPager, IPager { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt index 7767ef78..ab847b6b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt @@ -12,7 +12,6 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullable -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import java.time.LocalDateTime import java.time.OffsetDateTime @@ -61,7 +60,7 @@ class JSComment : IPlatformComment { if(!_hasGetReplies) return null; - val obj = _comment!!.invokeV8("getReplies", arrayOf()); + val obj = _comment!!.invoke("getReplies", arrayOf()); val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient"); return JSCommentPager(_config!!, plugin, obj); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt index 1e74bd0d..ab3b6f10 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt @@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced { else author = PlatformAuthorLink.UNKNOWN; - val datetimeInt = _content.getOrDefault(config, "datetime", contextName, null)?.toLong(); - if(datetimeInt == null || datetimeInt == 0.toLong()) + val datetimeInt = _content.getOrThrow(config, "datetime", contextName).toLong(); + if(datetimeInt == 0.toLong()) datetime = null; else datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt index 256e8a5a..490fa7c4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPluginSourced -import com.futo.platformplayer.api.media.models.JSChannelContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -16,14 +15,4 @@ class JSContentPager : JSPager, IPluginSourced { override fun convertResult(obj: V8ValueObject): IPlatformContent { return IJSContent.fromV8(plugin, obj); } -} - -class JSChannelContentPager : JSPager, IPluginSourced { - override val sourceConfig: SourcePluginConfig get() = config; - - constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {} - - override fun convertResult(obj: V8ValueObject): IPlatformContent { - return JSChannelContent(config, obj); - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt index dc2ba7b2..27731fea 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSLiveEventPager.kt @@ -15,7 +15,7 @@ class JSLiveEventPager : JSPager, IPlatformLiveEventPager { nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); } - override fun nextPage() = plugin.isBusyWith("JSLiveEventPager.nextPage") { + override fun nextPage() { super.nextPage(); nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt index d4aecc16..8782b742 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt @@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.warnIfMainThread abstract class JSPager : IPager { @@ -19,8 +18,8 @@ abstract class JSPager : IPager { protected var pager: V8ValueObject; private var _lastResults: List? = null; - protected var _resultChanged: Boolean = true; - protected var _hasMorePages: Boolean = false; + private var _resultChanged: Boolean = true; + private var _hasMorePages: Boolean = false; //private var _morePagesWasFalse: Boolean = false; val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false; @@ -30,9 +29,7 @@ abstract class JSPager : IPager { this.pager = pager; this.config = config; - plugin.busy { - _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; - } + _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; getResults(); } @@ -41,20 +38,17 @@ abstract class JSPager : IPager { } override fun hasMorePages(): Boolean { - return _hasMorePages && !pager.isClosed; + return _hasMorePages; } override fun nextPage() { warnIfMainThread("JSPager.nextPage"); - val pluginV8 = plugin.getUnderlyingPlugin(); - pluginV8.busy { - pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { - pager.invokeV8("nextPage", arrayOf()); - }; - _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; - _resultChanged = true; - } + pager = plugin.getUnderlyingPlugin().catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { + pager.invoke("nextPage", arrayOf()); + }; + _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; + _resultChanged = true; /* try { } @@ -76,18 +70,15 @@ abstract class JSPager : IPager { return previousResults; warnIfMainThread("JSPager.getResults"); - - return plugin.getUnderlyingPlugin().busy { - val items = pager.getOrThrow(config, "results", "JSPager"); - if (items.v8Runtime.isDead || items.v8Runtime.isClosed) - throw IllegalStateException("Runtime closed"); - val newResults = items.toArray() - .map { convertResult(it as V8ValueObject) } - .toList(); - _lastResults = newResults; - _resultChanged = false; - return@busy newResults; - } + val items = pager.getOrThrow(config, "results", "JSPager"); + if(items.v8Runtime.isDead || items.v8Runtime.isClosed) + throw IllegalStateException("Runtime closed"); + val newResults = items.toArray() + .map { convertResult(it as V8ValueObject) } + .toList(); + _lastResults = newResults; + _resultChanged = false; + return newResults; } abstract fun convertResult(obj: V8ValueObject): T; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt index bd0e4400..e5ee7b68 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPlaybackTracker.kt @@ -2,51 +2,37 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker -import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.warnIfMainThread class JSPlaybackTracker: IPlaybackTracker { - private lateinit var _client: JSClient; - private lateinit var _config: IV8PluginConfig; - private lateinit var _obj: V8ValueObject; + private val _config: IV8PluginConfig; + private val _obj: V8ValueObject; private var _hasCalledInit: Boolean = false; - private var _hasInit: Boolean = false; + private val _hasInit: Boolean; private var _lastRequest: Long = Long.MIN_VALUE; - private var _hasOnConcluded: Boolean = false; + private val _hasOnConcluded: Boolean; override var nextRequest: Int = 1000 private set; - constructor(client: JSClient, obj: V8ValueObject) { + constructor(config: IV8PluginConfig, obj: V8ValueObject) { warnIfMainThread("JSPlaybackTracker.constructor"); + if(!obj.has("onProgress")) + throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker"); + if(!obj.has("nextRequest")) + throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker"); + _hasOnConcluded = obj.has("onConcluded"); - client.busy { - if (!obj.has("onProgress")) - throw ScriptImplementationException( - client.config, - "Missing onProgress on PlaybackTracker" - ); - if (!obj.has("nextRequest")) - throw ScriptImplementationException( - client.config, - "Missing nextRequest on PlaybackTracker" - ); - _hasOnConcluded = obj.has("onConcluded"); - - this._client = client; - this._config = client.config; - this._obj = obj; - this._hasInit = obj.has("onInit"); - } + this._config = config; + this._obj = obj; + this._hasInit = obj.has("onInit"); } override fun onInit(seconds: Double) { @@ -54,15 +40,12 @@ class JSPlaybackTracker: IPlaybackTracker { synchronized(_obj) { if(_hasCalledInit) return; - - _client.busy { - if (_hasInit) { - Logger.i("JSPlaybackTracker", "onInit (${seconds})"); - _obj.invokeV8Void("onInit", seconds); - } - nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); - _hasCalledInit = true; + if (_hasInit) { + Logger.i("JSPlaybackTracker", "onInit (${seconds})"); + _obj.invokeVoid("onInit", seconds); } + nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); + _hasCalledInit = true; } } @@ -72,12 +55,10 @@ class JSPlaybackTracker: IPlaybackTracker { if(!_hasCalledInit && _hasInit) onInit(seconds); else { - _client.busy { - Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})"); - _obj.invokeV8Void("onProgress", Math.floor(seconds), isPlaying); - nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); - _lastRequest = System.currentTimeMillis(); - } + Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})"); + _obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying); + nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); + _lastRequest = System.currentTimeMillis(); } } } @@ -86,9 +67,7 @@ class JSPlaybackTracker: IPlaybackTracker { if(_hasOnConcluded) { synchronized(_obj) { Logger.i("JSPlaybackTracker", "onConcluded"); - _client.busy { - _obj.invokeV8Void("onConcluded", -1); - } + _obj.invokeVoid("onConcluded", -1); } } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt index 4d48a354..6c80d7dc 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt @@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { @@ -69,12 +68,12 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { return null; } private fun getContentRecommendationsJS(client: JSClient): JSContentPager { - val contentPager = _content.invokeV8("getContentRecommendations", arrayOf()); + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); return JSContentPager(_pluginConfig, client, contentPager); } private fun getCommentsJS(client: JSClient): JSCommentPager { - val commentPager = _content.invokeV8("getComments", arrayOf()); + val commentPager = _content.invoke("getComments", arrayOf()); return JSCommentPager(_pluginConfig, client, commentPager); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt index ed428790..70dfecfd 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt @@ -14,8 +14,6 @@ import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import kotlinx.serialization.Serializable @@ -48,55 +46,52 @@ class JSRequestExecutor { if (_executor.isClosed) throw IllegalStateException("Executor object is closed"); - return _plugin.getUnderlyingPlugin().busy { - - val result = if(_plugin is DevJSClient) - StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { - V8Plugin.catchScriptErrors( - _config, - "[${_config.name}] JSRequestExecutor", - "builder.modifyRequest()" - ) { - _executor.invokeV8("executeRequest", url, headers, method, body); - } as V8Value; - } + val result = if(_plugin is DevJSClient) + StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { + V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invoke("executeRequest", url, headers, method, body); + } as V8Value; + } else V8Plugin.catchScriptErrors( _config, "[${_config.name}] JSRequestExecutor", "builder.modifyRequest()" ) { - _executor.invokeV8("executeRequest", url, headers, method, body); + _executor.invoke("executeRequest", url, headers, method, body); } as V8Value; - try { - if(result is V8ValueString) { - val base64Result = Base64.getDecoder().decode(result.value); - return@busy base64Result; - } - if(result is V8ValueTypedArray) { - val buffer = result.buffer; - val byteBuffer = buffer.byteBuffer; - val bytesResult = ByteArray(result.byteLength); - byteBuffer.get(bytesResult, 0, result.byteLength); - buffer.close(); - return@busy bytesResult; - } - if(result is V8ValueObject && result.has("type")) { - val type = result.getOrThrow(_config, "type", "JSRequestModifier"); - when(type) { - //TODO: Buffer type? - } - } - if(result is V8ValueUndefined) { - if(_plugin is DevJSClient) - StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined"); - throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null); - } - throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name); + try { + if(result is V8ValueString) { + val base64Result = Base64.getDecoder().decode(result.value); + return base64Result; } - finally { - result.close(); + if(result is V8ValueTypedArray) { + val buffer = result.buffer; + val byteBuffer = buffer.byteBuffer; + val bytesResult = ByteArray(result.byteLength); + byteBuffer.get(bytesResult, 0, result.byteLength); + buffer.close(); + return bytesResult; } + if(result is V8ValueObject && result.has("type")) { + val type = result.getOrThrow(_config, "type", "JSRequestModifier"); + when(type) { + //TODO: Buffer type? + } + } + if(result is V8ValueUndefined) { + if(_plugin is DevJSClient) + StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined"); + throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null); + } + throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name); + } + finally { + result.close(); } } @@ -104,25 +99,24 @@ class JSRequestExecutor { open fun cleanup() { if (!hasCleanup || _executor.isClosed) return; - _plugin.busy { - if(_plugin is DevJSClient) - StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { - V8Plugin.catchScriptErrors( - _config, - "[${_config.name}] JSRequestExecutor", - "builder.modifyRequest()" - ) { - _executor.invokeV8("cleanup", null); - }; - } - else V8Plugin.catchScriptErrors( - _config, - "[${_config.name}] JSRequestExecutor", - "builder.modifyRequest()" - ) { - _executor.invokeV8("cleanup", null); - }; - } + + if(_plugin is DevJSClient) + StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { + V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeVoid("cleanup", null); + }; + } + else V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeVoid("cleanup", null); + }; } protected fun finalize() { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt index af03d070..150189e7 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt @@ -11,14 +11,12 @@ import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.invokeV8Void class JSRequestModifier: IRequestModifier { private val _plugin: JSClient; private val _config: IV8PluginConfig; private var _modifier: V8ValueObject; - override var allowByteSkip: Boolean = false; + override var allowByteSkip: Boolean; constructor(plugin: JSClient, modifier: V8ValueObject) { this._plugin = plugin; @@ -26,13 +24,10 @@ class JSRequestModifier: IRequestModifier { this._config = plugin.config; val config = plugin.config; - plugin.busy { - allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true; - - if(!modifier.has("modifyRequest")) - throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null); - } + allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true; + if(!modifier.has("modifyRequest")) + throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null); } override fun modifyRequest(url: String, headers: Map): IRequest { @@ -40,15 +35,13 @@ class JSRequestModifier: IRequestModifier { return Request(url, headers); } - return _plugin.busy { - val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") { - _modifier.invokeV8("modifyRequest", url, headers); - } as V8ValueObject; + val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") { + _modifier.invoke("modifyRequest", url, headers); + } as V8ValueObject; - val req = JSRequest(_plugin, result, url, headers); - result.close(); - return@busy req; - } + val req = JSRequest(_plugin, result, url, headers); + result.close(); + return req; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt index 74843d22..bb4650f6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSSubtitleSource.kt @@ -6,8 +6,6 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.getSourcePlugin -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -37,11 +35,8 @@ class JSSubtitleSource : ISubtitleSource { override fun getSubtitles(): String { if(!hasFetch) throw IllegalStateException("This subtitle doesn't support getSubtitles.."); - - return _obj.getSourcePlugin()?.busy { - val v8String = _obj.invokeV8("getSubtitles", arrayOf()); - return@busy v8String.value; - } ?: ""; + val v8String = _obj.invoke("getSubtitles", arrayOf()); + return v8String.value; } override suspend fun getSubtitlesURI(): Uri? { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVODEventPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVODEventPager.kt deleted file mode 100644 index 5361a2a4..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVODEventPager.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.futo.platformplayer.api.media.platforms.js.models - -import com.caoccao.javet.values.V8Value -import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent -import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager -import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.warnIfMainThread -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -class JSVODEventPager : JSPager, IPlatformLiveEventPager { - override var nextRequest: Int; - - constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) { - nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); - } - - fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") { - warnIfMainThread("VODEventPager.nextPage"); - - val pluginV8 = plugin.getUnderlyingPlugin(); - pluginV8.busy { - val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") { - pager.invokeV8("nextPage", ms); - }; - if(newPager is V8ValueObject) - pager = newPager; - _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; - _resultChanged = true; - } - nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); - } - - override fun nextPage() = nextPage(0); - - override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent { - return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager"); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt index 0b033af7..13c0aead 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideo.kt @@ -8,10 +8,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { final override val contentType: ContentType get() = ContentType.MEDIA; @@ -21,10 +17,6 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { final override val duration: Long; final override val viewCount: Long; - override var playbackTime: Long = -1; - @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) - override var playbackDate: OffsetDateTime? = null; - final override val isLive: Boolean; final override val isShort: Boolean; @@ -37,11 +29,5 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced { viewCount = _content.getOrThrow(config, "viewCount", contextName); isLive = _content.getOrThrow(config, "isLive", contextName); isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false; - playbackTime = _content.getOrDefault(config, "playbackTime", contextName, -1)?.toLong() ?: -1; - val playbackDateInt = _content.getOrDefault(config, "playbackDate", contextName, null)?.toLong(); - if(playbackDateInt == null || playbackDateInt == 0.toLong()) - playbackDate = null; - else - playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt index 4aca63aa..da495498 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSVideoDetails.kt @@ -7,7 +7,6 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes @@ -25,17 +24,12 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullable -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json class JSVideoDetails : JSVideo, IPlatformVideoDetails { - private val _plugin: JSClient; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; private val _hasGetPlaybackTracker: Boolean; - private val _hasGetVODEvents: Boolean; //Details override val description : String; @@ -51,9 +45,9 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { override val subtitles: List; + constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) { val contextName = "VideoDetails"; - _plugin = plugin; val config = plugin.config; description = _content.getOrThrow(config, "description", contextName); video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName)); @@ -75,7 +69,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { _hasGetComments = _content.has("getComments"); _hasGetPlaybackTracker = _content.has("getPlaybackTracker"); _hasGetContentRecommendations = _content.has("getContentRecommendations"); - _hasGetVODEvents = _content.has("getVODEvents"); } override fun getPlaybackTracker(): IPlaybackTracker? { @@ -89,16 +82,14 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { return getPlaybackTrackerJS(); } private fun getPlaybackTrackerJS(): IPlaybackTracker? { - return _plugin.busy { - V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") { - val tracker = _content.invokeV8("getPlaybackTracker", arrayOf()) - ?: return@catchScriptErrors null; - if(tracker is V8ValueObject) - return@catchScriptErrors JSPlaybackTracker(_plugin, tracker); - else - return@catchScriptErrors null; - } - } + return V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") { + val tracker = _content.invoke("getPlaybackTracker", arrayOf()) + ?: return@catchScriptErrors null; + if(tracker is V8ValueObject) + return@catchScriptErrors JSPlaybackTracker(_pluginConfig, tracker); + else + return@catchScriptErrors null; + }; } override fun getContentRecommendations(client: IPlatformClient): IPager? { @@ -115,10 +106,8 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { return null; } private fun getContentRecommendationsJS(client: JSClient): JSContentPager { - return _plugin.busy { - val contentPager = _content.invokeV8("getContentRecommendations", arrayOf()); - return@busy JSContentPager(_pluginConfig, client, contentPager); - } + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + return JSContentPager(_pluginConfig, client, contentPager); } override fun getComments(client: IPlatformClient): IPager? { @@ -134,23 +123,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { } private fun getCommentsJS(client: JSClient): IPager? { - return _plugin.busy { - val commentPager = _content.invokeV8("getComments", arrayOf()); - if (commentPager !is V8ValueObject) //TODO: Maybe handle this better? - return@busy null; + val commentPager = _content.invoke("getComments", arrayOf()); + if (commentPager !is V8ValueObject) //TODO: Maybe handle this better? + return null; - return@busy JSCommentPager(_pluginConfig, client, commentPager); - } - } - - fun hasVODEvents(): Boolean{ - return _hasGetVODEvents; - } - fun getVODEvents(url: String): IPager? = _plugin.busy { - if(!_hasGetVODEvents) - return@busy null; - - return@busy JSVODEventPager(_plugin.config, _plugin, - _content.invokeV8("getVODEvents", arrayOf())); + return JSCommentPager(_pluginConfig, client, commentPager); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt deleted file mode 100644 index 49f6fbeb..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWeb.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.futo.platformplayer.api.media.platforms.js.models - -import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.IPlatformClient -import com.futo.platformplayer.api.media.IPluginSourced -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.comments.IPlatformComment -import com.futo.platformplayer.api.media.models.contents.ContentType -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails -import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker -import com.futo.platformplayer.api.media.models.post.IPlatformPost -import com.futo.platformplayer.api.media.models.post.TextType -import com.futo.platformplayer.api.media.models.ratings.IRating -import com.futo.platformplayer.api.media.models.ratings.RatingLikes -import com.futo.platformplayer.api.media.platforms.js.DevJSClient -import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.getOrThrowNullableList -import com.futo.platformplayer.states.StateDeveloper - -open class JSWeb : JSContent, IPluginSourced { - final override val contentType: ContentType get() = ContentType.WEB; - - constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { - val contextName = "PlatformWeb"; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt deleted file mode 100644 index 02a274f4..00000000 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSWebDetails.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.futo.platformplayer.api.media.platforms.js.models - -import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.IPlatformClient -import com.futo.platformplayer.api.media.IPluginSourced -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.comments.IPlatformComment -import com.futo.platformplayer.api.media.models.contents.ContentType -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails -import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker -import com.futo.platformplayer.api.media.models.post.TextType -import com.futo.platformplayer.api.media.models.ratings.IRating -import com.futo.platformplayer.api.media.models.ratings.RatingLikes -import com.futo.platformplayer.api.media.platforms.js.DevJSClient -import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.getOrThrowNullableList -import com.futo.platformplayer.states.StateDeveloper - -open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails { - final override val contentType: ContentType get() = ContentType.WEB; - - val html: String?; - //TODO: Options? - - - constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { - val contextName = "PlatformWeb"; - - html = obj.getOrDefault(client.config, "html", contextName, null); - } - - override fun getComments(client: IPlatformClient): IPager? = null; - override fun getPlaybackTracker(): IPlaybackTracker? = null; - override fun getContentRecommendations(client: IPlatformClient): IPager? = null; - -} diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt index 7df120d5..516e07ff 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt @@ -6,8 +6,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.invokeV8Void class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource { override val licenseUri: String @@ -27,7 +25,7 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource { return null val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") { - _obj.invokeV8("getLicenseRequestExecutor", arrayOf()) + _obj.invoke("getLicenseRequestExecutor", arrayOf()) } if (result !is V8ValueObject) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index 7b6388cd..ae35207b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt @@ -1,8 +1,6 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources -import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.V8Deferred import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource @@ -15,13 +13,8 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.invokeV8Async import com.futo.platformplayer.others.Language import com.futo.platformplayer.states.StateDeveloper -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource { override val container : String; @@ -57,44 +50,6 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS hasGenerate = _obj.has("generate"); } - override fun generateAsync(scope: CoroutineScope): V8Deferred { - if(!hasGenerate) - return V8Deferred(CompletableDeferred(manifest)); - if(_obj.isClosed) - throw IllegalStateException("Source object already closed"); - - val plugin = _plugin.getUnderlyingPlugin(); - - var result: V8Deferred? = null; - if(_plugin is DevJSClient) - result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { - _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { - _plugin.isBusyWith("dashAudio.generate") { - _obj.invokeV8Async("generate"); - } - } - } - else - result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { - _plugin.isBusyWith("dashAudio.generate") { - _obj.invokeV8Async("generate"); - } - } - - return plugin.busy { - val initStart = _obj.getOrDefault(_config, "initStart", "JSDashManifestRawSource", null) ?: 0; - val initEnd = _obj.getOrDefault(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0; - val indexStart = _obj.getOrDefault(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0; - val indexEnd = _obj.getOrDefault(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0; - if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { - streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); - } - - return@busy result.convert { - it.value - }; - } - } override fun generate(): String? { if(!hasGenerate) return manifest; @@ -107,27 +62,21 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS if(_plugin is DevJSClient) result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { - _plugin.isBusyWith("dashAudio.generate") { - _obj.invokeV8("generate").value; - } + _obj.invokeString("generate"); } } else result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { - _plugin.isBusyWith("dashAudio.generate") { - _obj.invokeV8("generate").value; - } + _obj.invokeString("generate"); } if(result != null){ - plugin.busy { - val initStart = _obj.getOrDefault(_config, "initStart", "JSDashManifestRawSource", null) ?: 0; - val initEnd = _obj.getOrDefault(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0; - val indexStart = _obj.getOrDefault(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0; - val indexEnd = _obj.getOrDefault(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0; - if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { - streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); - } + val initStart = _obj.getOrDefault(_config, "initStart", "JSDashManifestRawSource", null) ?: 0; + val initEnd = _obj.getOrDefault(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0; + val indexStart = _obj.getOrDefault(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0; + val indexEnd = _obj.getOrDefault(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0; + if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { + streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); } } return result; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt index aebaab23..d6ff7455 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt @@ -3,7 +3,6 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.V8Deferred import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource @@ -16,18 +15,11 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.invokeV8Async import com.futo.platformplayer.states.StateDeveloper -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async interface IJSDashManifestRawSource { val hasGenerate: Boolean; var manifest: String?; - fun generateAsync(scope: CoroutineScope): Deferred; fun generate(): String?; } open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource { @@ -40,7 +32,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo override val duration: Long; override val priority: Boolean; - val url: String?; + var url: String?; override var manifest: String?; override val hasGenerate: Boolean; @@ -65,45 +57,6 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo hasGenerate = _obj.has("generate"); } - override fun generateAsync(scope: CoroutineScope): V8Deferred { - if(!hasGenerate) - return V8Deferred(CompletableDeferred(manifest)); - if(_obj.isClosed) - throw IllegalStateException("Source object already closed"); - - val plugin = _plugin.getUnderlyingPlugin(); - - var result: V8Deferred? = null; - if(_plugin is DevJSClient) { - result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { - _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { - _plugin.isBusyWith("dashVideo.generate") { - _obj.invokeV8Async("generate"); - } - }); - } - } - else - result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { - _plugin.isBusyWith("dashVideo.generate") { - _obj.invokeV8Async("generate"); - } - }); - - return plugin.busy { - val initStart = _obj.getOrDefault(_config, "initStart", "JSDashManifestRawSource", null) ?: 0; - val initEnd = _obj.getOrDefault(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0; - val indexStart = _obj.getOrDefault(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0; - val indexEnd = _obj.getOrDefault(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0; - if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { - streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); - } - - return@busy result.convert { - it.value - }; - } - } override open fun generate(): String? { if(!hasGenerate) return manifest; @@ -114,28 +67,22 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo if(_plugin is DevJSClient) { result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { - _plugin.isBusyWith("dashVideo.generate") { - _obj.invokeV8("generate").value; - } + _obj.invokeString("generate"); }); } } else result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { - _plugin.isBusyWith("dashVideo.generate") { - _obj.invokeV8("generate").value; - } + _obj.invokeString("generate"); }); if(result != null){ - _plugin.busy { - val initStart = _obj.getOrDefault(_config, "initStart", "JSDashManifestRawSource", null) ?: 0; - val initEnd = _obj.getOrDefault(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0; - val indexStart = _obj.getOrDefault(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0; - val indexEnd = _obj.getOrDefault(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0; - if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { - streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); - } + val initStart = _obj.getOrDefault(_config, "initStart", "JSDashManifestRawSource", null) ?: 0; + val initEnd = _obj.getOrDefault(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0; + val indexStart = _obj.getOrDefault(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0; + val indexEnd = _obj.getOrDefault(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0; + if(initEnd > 0 && indexStart > 0 && indexEnd > 0) { + streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd); } } return result; @@ -163,32 +110,6 @@ class JSDashManifestMergingRawSource( override val priority: Boolean get() = video.priority; - override fun generateAsync(scope: CoroutineScope): V8Deferred { - val videoDashDef = video.generateAsync(scope); - val audioDashDef = audio.generateAsync(scope); - - return V8Deferred.merge(scope, listOf(videoDashDef, audioDashDef)) { - val (videoDash: String?, audioDash: String?) = it; - - if (videoDash != null && audioDash == null) return@merge videoDash; - if (audioDash != null && videoDash == null) return@merge audioDash; - if (videoDash == null) return@merge null; - - //TODO: Temporary simple solution..make more reliable version - - var result: String? = null; - val audioAdaptationSet = adaptationSetRegex.find(audioDash!!); - if (audioAdaptationSet != null) { - result = videoDash.replace( - "", - "\n" + audioAdaptationSet.value - ) - } else - result = videoDash; - - return@merge result; - }; - } override fun generate(): String? { val videoDash = video.generate(); val audioDash = audio.generate(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt index 7700bd82..be72d3a0 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt @@ -9,8 +9,6 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 -import com.futo.platformplayer.invokeV8Void class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource, IDashManifestWidevineSource, JSSource { @@ -47,7 +45,7 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource, return null val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") { - _obj.invokeV8("getLicenseRequestExecutor", arrayOf()) + _obj.invoke("getLicenseRequestExecutor", arrayOf()) } if (result !is V8ValueObject) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index 18cd71fc..9e328df3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt @@ -7,7 +7,6 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.orNull @@ -39,13 +38,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { companion object { - fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? { - obj?.ensureIsBusy(); - return obj.orNull { fromV8HLS(plugin, it as V8ValueObject) } - }; - fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource { - obj.ensureIsBusy(); - return JSHLSManifestAudioSource(plugin, obj) - }; + fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; + fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(plugin, obj); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt index 4fe4307f..3c76e23d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt @@ -14,9 +14,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.orNull import com.futo.platformplayer.views.video.datasources.JSHttpDataSource @@ -55,39 +53,36 @@ abstract class JSSource { hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor"); } - fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") { + fun getRequestModifier(): IRequestModifier? { if(_requestModifier != null) - return@isBusyWith AdhocRequestModifier { url, headers -> + return AdhocRequestModifier { url, headers -> return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers); }; if (!hasRequestModifier || _obj.isClosed) - return@isBusyWith null; + return null; val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") { - _obj.invokeV8("getRequestModifier", arrayOf()); + _obj.invoke("getRequestModifier", arrayOf()); }; if (result !is V8ValueObject) - return@isBusyWith null; + return null; - return@isBusyWith JSRequestModifier(_plugin, result) + return JSRequestModifier(_plugin, result) } - open fun getRequestExecutor(): JSRequestExecutor? = _plugin.isBusyWith("getRequestExecutor") { + open fun getRequestExecutor(): JSRequestExecutor? { if (!hasRequestExecutor || _obj.isClosed) - return@isBusyWith null; + return null; - Logger.v("JSSource", "Request executor for [${type}] requesting"); val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") { - _obj.invokeV8("getRequestExecutor", arrayOf()); + _obj.invoke("getRequestExecutor", arrayOf()); }; - Logger.v("JSSource", "Request executor for [${type}] received"); - if (result !is V8ValueObject) - return@isBusyWith null; + return null; - return@isBusyWith JSRequestExecutor(_plugin, result) + return JSRequestExecutor(_plugin, result) } fun getUnderlyingPlugin(): JSClient? { @@ -110,12 +105,8 @@ abstract class JSSource { const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource" const val TYPE_VIDEOURL_WIDEVINE = "VideoUrlWidevineSource" - fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? { - obj?.ensureIsBusy(); - return obj.orNull { fromV8Video(plugin, it as V8ValueObject) } - }; + fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) }; fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? { - obj.ensureIsBusy() val type = obj.getString("plugin_type"); return when(type) { TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj); @@ -132,26 +123,13 @@ abstract class JSSource { } } fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) }; - fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource{ - obj.ensureIsBusy(); - return JSDashManifestSource(plugin, obj) - }; - fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource{ - obj.ensureIsBusy() - return JSDashManifestRawSource(plugin, obj); - } - fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource { - obj?.ensureIsBusy(); - return JSDashManifestRawAudioSource(plugin, obj) - }; + fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj); + fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj); + fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj); fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; - fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource { - obj.ensureIsBusy(); - return JSHLSManifestSource(plugin, obj) - }; + fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj); fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? { - obj.ensureIsBusy(); val type = obj.getString("plugin_type"); return when(type) { TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt index e7c0fe50..e68f0ae0 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt @@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor { @@ -32,7 +31,6 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor { fun fromV8(plugin: JSClient, obj: V8ValueObject) : IVideoSourceDescriptor { - obj.ensureIsBusy(); val type = obj.getString("plugin_type") return when(type) { TYPE_MUXED -> JSVideoSourceDescriptor(plugin, obj); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt index aff22c33..bcd6607d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt @@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrThrow -import com.futo.platformplayer.invokeV8 class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource { override val licenseUri: String @@ -26,7 +25,7 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource { return null val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") { - _obj.invokeV8("getLicenseRequestExecutor", arrayOf()) + _obj.invoke("getLicenseRequestExecutor", arrayOf()) } if (result !is V8ValueObject) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt index 7dff9cc5..1c169e64 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt @@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource @@ -18,7 +19,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.downloads.VideoLocal import java.io.File import java.time.Instant import java.time.OffsetDateTime @@ -52,10 +53,6 @@ class LocalVideoDetails: IPlatformVideoDetails { override val isLive: Boolean = false; override val isShort: Boolean = false; - override var playbackTime: Long = -1; - @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) - override var playbackDate: OffsetDateTime? = null; - constructor(file: File) { id = PlatformID("Local", file.path, "LOCAL") name = file.name; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt index 66554572..e86c9ba6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -7,12 +7,12 @@ import java.util.stream.IntStream * A Content MultiPager that returns results based on a specified distribution * TODO: Merge all basic distribution pagers */ -class MultiDistributionContentPager : MultiPager { +class MultiDistributionContentPager : MultiPager { - private val dist : HashMap, Float>; - private val distConsumed : HashMap, Float>; + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; - constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { val distTotal = pagers.values.sum(); dist = HashMap(); @@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager { } @Synchronized - override fun selectItemIndex(options: Array>): Int { + override fun selectItemIndex(options: Array>): Int { if(options.size == 0) return -1; var bestIndex = 0; @@ -42,4 +42,6 @@ class MultiDistributionContentPager : MultiPager { distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; return bestIndex; } + + } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 0cc1bebc..2bf6c1ce 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -149,7 +149,6 @@ class AirPlayCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) - delay(1000); } } diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index 69f74747..c6e046ef 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -108,7 +108,7 @@ abstract class CastingDevice { val expectedCurrentTime: Double get() { - val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0; + val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0; return time + diff; }; var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index f6582055..0b763675 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -10,9 +10,7 @@ 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 @@ -35,7 +33,7 @@ class ChromecastCastingDevice : CastingDevice { override var usedRemoteAddress: InetAddress? = null; override var localAddress: InetAddress? = null; override val canSetVolume: Boolean get() = true; - override val canSetSpeed: Boolean get() = true; + override val canSetSpeed: Boolean get() = false; //TODO: Implement var addresses: Array? = null; var port: Int = 0; @@ -58,11 +56,6 @@ 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 - private var _autoLaunchEnabled = true constructor(name: String, addresses: Array, port: Int) : super() { this.name = name; @@ -145,23 +138,6 @@ class ChromecastCastingDevice : CastingDevice { 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; @@ -253,7 +229,6 @@ 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() { @@ -293,7 +268,6 @@ class ChromecastCastingDevice : CastingDevice { _contentType = null; _streamType = null; _sessionId = null; - _launchRetries = 0 _transportId = null; } @@ -306,10 +280,8 @@ class ChromecastCastingDevice : CastingDevice { return; } - _autoLaunchEnabled = true _started = true; _sessionId = null; - _launchRetries = 0 _mediaSessionId = null; Logger.i(TAG, "Starting..."); @@ -350,7 +322,6 @@ class ChromecastCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) - Thread.sleep(1000); } } @@ -363,10 +334,6 @@ class ChromecastCastingDevice : CastingDevice { //Connection loop while (_scopeIO?.isActive == true) { - _sessionId = null; - _launchRetries = 0 - _mediaSessionId = null; - Logger.i(TAG, "Connecting to Chromecast."); connectionState = CastConnectionState.CONNECTING; @@ -425,7 +392,7 @@ class ChromecastCastingDevice : CastingDevice { try { val inputStream = _inputStream ?: break; - val message = synchronized(_inputStreamLock) + synchronized(_inputStreamLock) { Log.d(TAG, "Receiving next packet..."); val b1 = inputStream.readUnsignedByte(); @@ -437,7 +404,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 null + return@synchronized } Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); @@ -446,19 +413,15 @@ 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 msg = ChromeCast.CastMessage.parseFrom(messageBytes); - if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { - Logger.i(TAG, "Received message: $msg"); + val message = ChromeCast.CastMessage.parseFrom(messageBytes); + if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { + Logger.i(TAG, "Received message: $message"); } - 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) { @@ -522,10 +485,6 @@ class ChromecastCastingDevice : CastingDevice { } } 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; } } @@ -548,12 +507,10 @@ class ChromecastCastingDevice : CastingDevice { 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); @@ -561,48 +518,28 @@ class ChromecastCastingDevice : CastingDevice { _transportId = transportId; requestMediaStatus(); + playVideo(); } } } } if (!sessionIsRunning) { - if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) { - _sessionId = null - _mediaSessionId = null - _transportId = null + _sessionId = null; + _mediaSessionId = null; + setTime(0.0); + _transportId = null; + Logger.w(TAG, "Session not found."); - 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() - } + if (_launching) { + Logger.i(TAG, "Player not found, launching."); + launchPlayer(); } else { - if (_retryJob == null) { - Logger.i(TAG, "Scheduled retry job over 5 seconds") - _retryJob = _scopeIO?.launch(Dispatchers.IO) { - delay(5000) - getStatus() - _retryJob = null - } - } + Logger.i(TAG, "Player not found, disconnecting."); + stop(); } } else { - _launching = false - _launchRetries = 0 - _autoLaunchEnabled = false + _launching = false; } val volume = status.getJSONObject("volume"); @@ -629,7 +566,7 @@ class ChromecastCastingDevice : CastingDevice { } isPlaying = playerState == "PLAYING"; - if (isPlaying || playerState == "PAUSED") { + if (isPlaying) { setTime(currentTime); } @@ -640,18 +577,10 @@ class ChromecastCastingDevice : CastingDevice { 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.") + stop(); } } } else { @@ -686,13 +615,6 @@ class ChromecastCastingDevice : CastingDevice { localAddress = null; _started = false; - _contentId = null - _contentType = null - _streamType = null - - _retryJob?.cancel() - _retryJob = null - val socket = _socket; val scopeIO = _scopeIO; diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index be62f726..85b928c2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -25,7 +25,6 @@ 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 kotlinx.serialization.encodeToString @@ -93,7 +92,7 @@ class FCastCastingDevice : CastingDevice { private var _version: Long = 1; private var _thread: Thread? = null private var _pingThread: Thread? = null - @Volatile private var _lastPongTime = System.currentTimeMillis() + private var _lastPongTime = -1L private var _outputStreamLock = Object() constructor(name: String, addresses: Array, port: Int) : super() { @@ -290,7 +289,6 @@ class FCastCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) - Thread.sleep(1000); } } @@ -328,9 +326,9 @@ class FCastCastingDevice : CastingDevice { continue; } - localAddress = _socket?.localAddress - _lastPongTime = System.currentTimeMillis() - connectionState = CastConnectionState.CONNECTED + localAddress = _socket?.localAddress; + connectionState = CastConnectionState.CONNECTED; + _lastPongTime = -1L val buffer = ByteArray(4096); @@ -348,7 +346,7 @@ class FCastCastingDevice : CastingDevice { 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(); + val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt(); if (size > buffer.size) { Logger.w(TAG, "Packets larger than $size bytes are not supported.") break @@ -406,32 +404,36 @@ class FCastCastingDevice : CastingDevice { _pingThread = Thread { Logger.i(TAG, "Started ping loop.") + while (_scopeIO?.isActive == true) { - if (connectionState == CastConnectionState.CONNECTED) { + try { + send(Opcode.Ping) + } catch (e: Throwable) { + Log.w(TAG, "Failed to send ping.") + 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) - } - } + _socket?.close() + _inputStream?.close() + _outputStream?.close() } 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) - } + Log.w(TAG, "Failed to close socket.", e) } } - Thread.sleep(5000) + + /*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) { + Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.") + + try { + _socket?.close() + } catch (e: Throwable) { + Log.w(TAG, "Failed to close socket.", e) + } + }*/ + + Thread.sleep(2000) } - Logger.i(TAG, "Stopped ping loop.") + + Logger.i(TAG, "Stopped ping loop."); }.apply { start() } } else { Log.i(TAG, "Thread was still alive, not restarted") diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index f7802e86..90177050 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -4,14 +4,10 @@ import android.app.AlertDialog import android.content.ContentResolver import android.content.Context import android.net.Uri -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -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 android.util.Xml import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -39,13 +35,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource -import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.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.mdns.DnsService +import com.futo.platformplayer.mdns.ServiceDiscoverer import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp @@ -59,13 +55,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import java.net.Inet6Address +import java.io.ByteArrayInputStream import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder -import java.util.Collections import java.util.UUID -import java.util.concurrent.atomic.AtomicInteger class StateCasting { private val _scopeIO = CoroutineScope(Dispatchers.IO); @@ -76,6 +70,7 @@ class StateCasting { private var _started = false; var devices: HashMap = hashMapOf(); + var rememberedDevices: ArrayList = arrayListOf(); val onDeviceAdded = Event1(); val onDeviceChanged = Event1(); val onDeviceRemoved = Event1(); @@ -89,16 +84,48 @@ class StateCasting { private var _audioExecutor: JSRequestExecutor? = null private val _client = ManagedHttpClient(); var _resumeCastingDevice: CastingDeviceInfo? = null; - private var _nsdManager: NsdManager? = null - val isCasting: Boolean get() = activeDevice != null; - private val _castId = AtomicInteger(0) + val _serviceDiscoverer = ServiceDiscoverer(arrayOf( + "_googlecast._tcp.local", + "_airplay._tcp.local", + "_fastcast._tcp.local", + "_fcast._tcp.local" + )) { handleServiceUpdated(it) } - private val _discoveryListeners = mapOf( - "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), - "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), - "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), - "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) - ) + val isCasting: Boolean get() = activeDevice != null; + + private fun handleServiceUpdated(services: List) { + for (s in services) { + //TODO: Addresses IPv4 only? + val addresses = s.addresses.toTypedArray() + val port = s.port.toInt() + var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length) + if (s.name.endsWith("._googlecast._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length) + } + + addOrUpdateChromeCastDevice(name, addresses, port) + } else if (s.name.endsWith("._airplay._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length) + } + + addOrUpdateAirPlayDevice(name, addresses, port) + } else if (s.name.endsWith("._fastcast._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length) + } + + addOrUpdateFastCastDevice(name, addresses, port) + } else if (s.name.endsWith("._fcast._tcp.local")) { + if (name == null) { + name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length) + } + + addOrUpdateFastCastDevice(name, addresses, port) + } + } + } fun handleUrl(context: Context, url: String) { val uri = Uri.parse(url) @@ -163,34 +190,30 @@ class StateCasting { Logger.i(TAG, "CastingService starting..."); + rememberedDevices.clear(); + rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) }); + _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) - } + fun startDiscovering() { + try { + _serviceDiscoverer.start() + } catch (e: Throwable) { + Logger.i(TAG, "Failed to start ServiceDiscoverer", e) } } @Synchronized - private fun stopDiscovering() { - _nsdManager?.apply { - _discoveryListeners.forEach { - try { - stopServiceDiscovery(it.value) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } + fun stopDiscovering() { + try { + _serviceDiscoverer.stop() + } catch (e: Throwable) { + Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) } } @@ -216,85 +239,6 @@ class StateCasting { _castServer.removeAllHandlers(); Logger.i(TAG, "CastingService stopped.") - - _nsdManager = null - } - - private fun createDiscoveryListener(addOrUpdate: (String, Array, 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) - } - }) - } - } - } } private val _castingDialogLock = Any(); @@ -350,9 +294,7 @@ class StateCasting { UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { if(_currentDialog == null) { - _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, - "Connecting to [${device.name}]", - "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, UIDialogs.Action("Disconnect", { device.stop(); })); @@ -387,6 +329,9 @@ class StateCasting { invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; }; + addRememberedDevice(device); + Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.") + try { device.start(); } catch (e: Throwable) { @@ -408,22 +353,21 @@ class StateCasting { return addRememberedDevice(device); } - fun getRememberedCastingDevices(): List { - return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } - } - - fun getRememberedCastingDeviceNames(): List { - return _storage.getDeviceNames() - } - fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { val deviceInfo = device.getDeviceInfo() - return _storage.addDevice(deviceInfo) + val foundInfo = _storage.addDevice(deviceInfo) + if (foundInfo == deviceInfo) { + rememberedDevices.add(device); + return foundInfo; + } + + return foundInfo; } fun removeRememberedDevice(device: CastingDevice) { - val name = device.name ?: return - _storage.removeDevice(name) + val name = device.name ?: return; + _storage.removeDevice(name); + rememberedDevices.remove(device); } private fun invokeInMainScopeIfRequired(action: () -> Unit){ @@ -435,112 +379,129 @@ class StateCasting { action(); } - fun cancel() { - _castId.incrementAndGet() - } + fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean { + val ad = activeDevice ?: return false; + if (ad.connectionState != CastConnectionState.CONNECTED) { + return false; + } - suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean { - return withContext(Dispatchers.IO) { - val ad = activeDevice ?: return@withContext false; - if (ad.connectionState != CastConnectionState.CONNECTED) { - return@withContext false; - } + val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; - val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; - val castId = _castId.incrementAndGet() + var sourceCount = 0; + if (videoSource != null) sourceCount++; + if (audioSource != null) sourceCount++; + if (subtitleSource != null) sourceCount++; - var sourceCount = 0; - if (videoSource != null) sourceCount++; - if (audioSource != null) sourceCount++; - if (subtitleSource != null) sourceCount++; + if (sourceCount < 1) { + throw Exception("At least one source should be specified."); + } - if (sourceCount < 1) { - throw Exception("At least one source should be specified."); - } - - if (sourceCount > 1) { - if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - if (ad is AirPlayCastingDevice) { - Logger.i(TAG, "Casting as local HLS"); - castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); - } else { - Logger.i(TAG, "Casting as local DASH"); - castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); - } + if (sourceCount > 1) { + if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { + if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as local HLS"); + castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } else { - val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource - if (isRawDash) { - Logger.i(TAG, "Casting as raw DASH"); + Logger.i(TAG, "Casting as local DASH"); + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + } + } else { + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource + if (isRawDash) { + Logger.i(TAG, "Casting as raw DASH"); - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); - } else { - if (ad is FCastCastingDevice) { - Logger.i(TAG, "Casting as DASH direct"); - castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } else if (ad is AirPlayCastingDevice) { - Logger.i(TAG, "Casting as HLS indirect"); - castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + try { + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e); + } } else { - Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + if (ad is FCastCastingDevice) { + Logger.i(TAG, "Casting as DASH direct"); + castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + } else if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as HLS indirect"); + castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + } else { + Logger.i(TAG, "Casting as DASH indirect"); + castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + } } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e); + } + } + } + } else { + val proxyStreams = Settings.instance.casting.alwaysProxyRequests; + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; + val id = UUID.randomUUID(); + + if (videoSource is IVideoUrlSource) { + val videoPath = "/video-${id}" + val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); + Logger.i(TAG, "Casting as singular video"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + } else if (audioSource is IAudioUrlSource) { + val audioPath = "/audio-${id}" + val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); + Logger.i(TAG, "Casting as singular audio"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); + } else if(videoSource is IHLSManifestSource) { + if (proxyStreams || ad is ChromecastCastingDevice) { + Logger.i(TAG, "Casting as proxied HLS"); + castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); + } else { + Logger.i(TAG, "Casting as non-proxied HLS"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); + } + } else if(audioSource is IHLSManifestAudioSource) { + if (proxyStreams || ad is ChromecastCastingDevice) { + Logger.i(TAG, "Casting as proxied audio HLS"); + castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); + } else { + Logger.i(TAG, "Casting as non-proxied audio HLS"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); + } + } else if (videoSource is LocalVideoSource) { + Logger.i(TAG, "Casting as local video"); + castLocalVideo(video, videoSource, resumePosition, speed); + } else if (audioSource is LocalAudioSource) { + Logger.i(TAG, "Casting as local audio"); + castLocalAudio(video, audioSource, resumePosition, speed); + } else if (videoSource is JSDashManifestRawSource) { + Logger.i(TAG, "Casting as JSDashManifestRawSource video"); + + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e); + } + } + } else if (audioSource is JSDashManifestRawAudioSource) { + Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); + + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e); } } } else { - val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - if (videoSource is IVideoUrlSource) { - val videoPath = "/video-${id}" - val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); - Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); - } else if (audioSource is IAudioUrlSource) { - val audioPath = "/audio-${id}" - val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); - Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); - } else if(videoSource is IHLSManifestSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { - Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); - } else { - Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); - } - } else if(audioSource is IHLSManifestAudioSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { - Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); - } else { - Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); - } - } else if (videoSource is LocalVideoSource) { - Logger.i(TAG, "Casting as local video"); - castLocalVideo(video, videoSource, resumePosition, speed); - } else if (audioSource is LocalAudioSource) { - Logger.i(TAG, "Casting as local audio"); - castLocalAudio(video, audioSource, resumePosition, speed); - } else if (videoSource is JSDashManifestRawSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource video"); - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); - } else if (audioSource is JSDashManifestRawAudioSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); - castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); - } else { - var str = listOf( - if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, - if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, - if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null - ).filterNotNull().joinToString(", "); - throw UnsupportedCastException(str); - } + var str = listOf( + if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, + if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, + if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null + ).filterNotNull().joinToString(", "); + throw UnsupportedCastException(str); } - - return@withContext true; } + + return true; } fun resumeVideo(): Boolean { @@ -570,7 +531,7 @@ class StateCasting { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val videoPath = "/video-${id}" val videoUrl = url + videoPath; @@ -589,7 +550,7 @@ class StateCasting { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val audioPath = "/audio-${id}" val audioUrl = url + audioPath; @@ -608,7 +569,7 @@ class StateCasting { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { val ad = activeDevice ?: return listOf() - val url = getLocalUrl(ad) + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" val id = UUID.randomUUID() val hlsPath = "/hls-${id}" @@ -704,7 +665,7 @@ class StateCasting { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -752,9 +713,9 @@ class StateCasting { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) + val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val videoPath = "/video-${id}" @@ -819,7 +780,7 @@ class StateCasting { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -989,7 +950,7 @@ class StateCasting { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val hlsPath = "/hls-${id}" @@ -1115,16 +1076,11 @@ class StateCasting { return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } - private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { - val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true - return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier - } - private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) + val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -1210,30 +1166,14 @@ class StateCasting { } } - private fun getLocalUrl(ad: CastingDevice): String { - var address = ad.localAddress!! - if (Settings.instance.casting.allowLinkLocalIpv4) { - if (address.isLinkLocalAddress && address is Inet6Address) { - address = findPreferredAddress() ?: address - Logger.i(TAG, "Selected casting address: $address") - } - } else { - if (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?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List { + private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); cleanExecutors() _castServer.removeAllHandlers("castDashRaw") - val url = getLocalUrl(ad); + val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); val dashPath = "/dash-${id}" @@ -1274,48 +1214,20 @@ class StateCasting { } } - var dashContent: String = withContext(Dispatchers.IO) { - stopVideo() - + var dashContent = withContext(Dispatchers.IO) { //TODO: Include subtitlesURl in the future - val deferred = if (audioSource != null && videoSource != null) { - JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO) + return@withContext if (audioSource != null && videoSource != null) { + JSDashManifestMergingRawSource(videoSource, audioSource).generate() } else if (audioSource != null) { - audioSource.generateAsync(_scopeIO) + audioSource.generate() } else if (videoSource != null) { - videoSource.generateAsync(_scopeIO) + videoSource.generate() } else { Logger.e(TAG, "Expected at least audio or video to be set") null } - - if (deferred != null) { - try { - withContext(Dispatchers.Main) { - if (deferred.estDuration >= 0) { - onLoadingEstimate?.invoke(deferred.estDuration) - } else { - onLoading?.invoke(true) - } - } - deferred.await() - } finally { - if (castId == _castId.get()) { - withContext(Dispatchers.Main) { - onLoading?.invoke(false) - } - } - } - } else { - return@withContext null - } } ?: throw Exception("Dash is null") - if (castId != _castId.get()) { - Log.i(TAG, "Get DASH cancelled.") - return emptyList() - } - for (representation in representationRegex.findAll(dashContent)) { val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") dashContent = mediaInitializationRegex.replace(dashContent) { diff --git a/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt b/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt index d3457077..b590fe6e 100644 --- a/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/constructs/TaskHandler.kt @@ -82,11 +82,7 @@ class TaskHandler { handled = true; } catch (e: Throwable) { Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); - try { - onError.emit(e, parameter); - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in .exception handler 1", e) - } + onError.emit(e, parameter); handled = true; } } @@ -103,14 +99,10 @@ class TaskHandler { if (id != _idGenerator) return@withContext; - try { - if (!onError.emit(e, parameter)) { - Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); - } else { - //Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs) - } - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in .exception handler 2", e) + if (!onError.emit(e, parameter)) { + Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); + } else { + //Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs) } } } diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt index 4a7b8dd7..acb57b78 100644 --- a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt +++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt @@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) { private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin"); private val _testPluginVariables: HashMap = hashMapOf(); - private inline fun createRemoteObjectArray(objs: Iterable): List { - val remotes = mutableListOf(); + private inline fun createRemoteObjectArray(objs: Iterable): List { + val remotes = mutableListOf(); for(obj in objs) - remotes.add(createRemoteObject(obj)); + remotes.add(createRemoteObject(obj)!!); return remotes; } private inline fun createRemoteObject(obj: T): V8RemoteObject? { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt index 9eb71145..295e191a 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingAddDialog.kt @@ -106,7 +106,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { }; _buttonTutorial.setOnClickListener { - UIDialogs.showCastingTutorialDialog(context, ownerActivity) + UIDialogs.showCastingTutorialDialog(context) dismiss() } } @@ -130,7 +130,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) { private fun performDismiss(shouldShowCastingDialog: Boolean = true) { if (shouldShowCastingDialog) { - UIDialogs.showCastingDialog(context, ownerActivity); + UIDialogs.showCastingDialog(context); } dismiss(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt index 510b6965..9f305b18 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CastingHelpDialog.kt @@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) { findViewById(R.id.button_close).onClick.subscribe { dismiss() - UIDialogs.showCastingAddDialog(context, ownerActivity) + UIDialogs.showCastingAddDialog(context) } } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 2bb87111..8f3b836c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -9,9 +9,7 @@ import android.view.View import android.widget.Button import android.widget.ImageButton import android.widget.ImageView -import android.widget.LinearLayout import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R @@ -23,21 +21,22 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.DeviceAdapter -import com.futo.platformplayer.views.adapters.DeviceAdapterEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ConnectCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _imageLoader: ImageView; private lateinit var _buttonClose: Button; - private lateinit var _buttonAdd: LinearLayout; - private lateinit var _buttonScanQR: LinearLayout; + private lateinit var _buttonAdd: ImageButton; + private lateinit var _buttonScanQR: ImageButton; private lateinit var _textNoDevicesFound: TextView; + private lateinit var _textNoDevicesRemembered: TextView; private lateinit var _recyclerDevices: RecyclerView; + private lateinit var _recyclerRememberedDevices: RecyclerView; private lateinit var _adapter: DeviceAdapter; - private val _devices: MutableSet = mutableSetOf() - private val _rememberedDevices: MutableSet = mutableSetOf() - private val _unifiedDevices: MutableList = mutableListOf() + private lateinit var _rememberedAdapter: DeviceAdapter; + private val _devices: ArrayList = arrayListOf(); + private val _rememberedDevices: ArrayList = arrayListOf(); override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); @@ -46,44 +45,46 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _imageLoader = findViewById(R.id.image_loader); _buttonClose = findViewById(R.id.button_close); _buttonAdd = findViewById(R.id.button_add); - _buttonScanQR = findViewById(R.id.button_qr); + _buttonScanQR = findViewById(R.id.button_scan_qr); _recyclerDevices = findViewById(R.id.recycler_devices); + _recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices); _textNoDevicesFound = findViewById(R.id.text_no_devices_found); + _textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered); - _adapter = DeviceAdapter(_unifiedDevices) + _adapter = DeviceAdapter(_devices, false); _recyclerDevices.adapter = _adapter; _recyclerDevices.layoutManager = LinearLayoutManager(context); - _adapter.onPin.subscribe { d -> - val isRemembered = _rememberedDevices.contains(d.name) - val newIsRemembered = !isRemembered - if (newIsRemembered) { - StateCasting.instance.addRememberedDevice(d) - val name = d.name - if (name != null) { - _rememberedDevices.add(name) - } - } else { - StateCasting.instance.removeRememberedDevice(d) - _rememberedDevices.remove(d.name) + _rememberedAdapter = DeviceAdapter(_rememberedDevices, true); + _rememberedAdapter.onRemove.subscribe { d -> + if (StateCasting.instance.activeDevice == d) { + d.stopCasting(); } - updateUnifiedList() + + StateCasting.instance.removeRememberedDevice(d); + val index = _rememberedDevices.indexOf(d); + if (index != -1) { + _rememberedDevices.removeAt(index); + _rememberedAdapter.notifyItemRemoved(index); + } + + _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; + }; + _rememberedAdapter.onConnect.subscribe { _ -> + dismiss() + //UIDialogs.showCastingDialog(context) } - - //TODO: Integrate remembered into the main list - //TODO: Add green indicator to indicate a device is oneline - //TODO: Add pinning - //TODO: Implement QR code as an option in add manually - //TODO: Remove start button - _adapter.onConnect.subscribe { _ -> dismiss() //UIDialogs.showCastingDialog(context) } + _recyclerRememberedDevices.adapter = _rememberedAdapter; + _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _buttonClose.setOnClickListener { dismiss(); }; _buttonAdd.setOnClickListener { - UIDialogs.showCastingAddDialog(context, ownerActivity); + UIDialogs.showCastingAddDialog(context); dismiss(); }; @@ -103,108 +104,78 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { super.show(); Logger.i(TAG, "Dialog shown."); + StateCasting.instance.startDiscovering() + (_imageLoader.drawable as Animatable?)?.start(); - synchronized(StateCasting.instance.devices) { - _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) + _devices.clear(); + synchronized (StateCasting.instance.devices) { + _devices.addAll(StateCasting.instance.devices.values); } - _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) - updateUnifiedList() + _rememberedDevices.clear(); + synchronized (StateCasting.instance.rememberedDevices) { + _rememberedDevices.addAll(StateCasting.instance.rememberedDevices); + } + + _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; + _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; StateCasting.instance.onDeviceAdded.subscribe(this) { d -> - val name = d.name - if (name != null) - _devices.add(name) - updateUnifiedList() - } + _devices.add(d); + _adapter.notifyItemInserted(_devices.size - 1); + _textNoDevicesFound.visibility = View.GONE; + _recyclerDevices.visibility = View.VISIBLE; + }; StateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } - if (index != -1) { - _unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) - _adapter.notifyItemChanged(index) + val index = _devices.indexOf(d); + if (index == -1) { + return@subscribe; } - } + + _devices[index] = d; + _adapter.notifyItemChanged(index); + }; StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> - _devices.remove(d.name) - updateUnifiedList() - } + val index = _devices.indexOf(d); + if (index == -1) { + return@subscribe; + } + + _devices.removeAt(index); + _adapter.notifyItemRemoved(index); + _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; + _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; + }; StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - if (connectionState == CastConnectionState.CONNECTED) { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - dismiss() - } + if (connectionState != CastConnectionState.CONNECTED) { + return@subscribe; } - } + + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss(); + }; + }; + + _adapter.notifyDataSetChanged(); + _rememberedAdapter.notifyDataSetChanged(); } override fun dismiss() { - super.dismiss() - (_imageLoader.drawable as Animatable?)?.stop() - StateCasting.instance.onDeviceAdded.remove(this) - StateCasting.instance.onDeviceChanged.remove(this) - StateCasting.instance.onDeviceRemoved.remove(this) - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this) - } + super.dismiss(); - private fun updateUnifiedList() { - val oldList = ArrayList(_unifiedDevices) - val newList = buildUnifiedList() + (_imageLoader.drawable as Animatable?)?.stop(); - val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { - override fun getOldListSize() = oldList.size - override fun getNewListSize() = newList.size - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - return oldItem.castingDevice.name == newItem.castingDevice.name - && oldItem.castingDevice.isReady == newItem.castingDevice.isReady - && oldItem.isOnlineDevice == newItem.isOnlineDevice - && oldItem.isPinnedDevice == newItem.isPinnedDevice - } - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - return oldItem.castingDevice.name == newItem.castingDevice.name - && oldItem.castingDevice.isReady == newItem.castingDevice.isReady - && oldItem.isOnlineDevice == newItem.isOnlineDevice - && oldItem.isPinnedDevice == newItem.isPinnedDevice - } - }) - - _unifiedDevices.clear() - _unifiedDevices.addAll(newList) - diffResult.dispatchUpdatesTo(_adapter) - - _textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE - _recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE - } - - private fun buildUnifiedList(): List { - val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } - val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } - - val unifiedList = mutableListOf() - - val intersectionNames = _devices.intersect(_rememberedDevices) - for (name in intersectionNames) { - onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) } - } - - val onlineOnlyNames = _devices - _rememberedDevices - for (name in onlineOnlyNames) { - onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) } - } - - val rememberedOnlyNames = _rememberedDevices - _devices - for (name in rememberedOnlyNames) { - rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) } - } - - return unifiedList + StateCasting.instance.stopDiscovering() + StateCasting.instance.onDeviceAdded.remove(this); + StateCasting.instance.onDeviceChanged.remove(this); + StateCasting.instance.onDeviceRemoved.remove(this); + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt index 4c0ccb7a..2e79b0b4 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt @@ -6,16 +6,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.widget.Button import com.futo.platformplayer.R -import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.readBytes import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.views.buttons.BigButton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class ImportOptionsDialog: AlertDialog { private val _context: MainActivity; @@ -45,17 +41,8 @@ class ImportOptionsDialog: AlertDialog { _button_import_zip.onClick.subscribe { dismiss(); StateApp.instance.requestFileReadAccess(_context, null, "application/zip") { - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val zipBytes = it?.readBytes(context) ?: return@launch; - withContext(Dispatchers.Main) { - try { - StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes); - } - catch(ex: Throwable) { - UIDialogs.toast("Failed to import, invalid format?\n" + ex.message); - } - } - } + val zipBytes = it?.readBytes(context) ?: return@requestFileReadAccess; + StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes); }; } _button_import_ezip.setOnClickListener { @@ -64,35 +51,17 @@ class ImportOptionsDialog: AlertDialog { _button_import_txt.onClick.subscribe { dismiss(); StateApp.instance.requestFileReadAccess(_context, null, "text/plain") { - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val txtBytes = it?.readBytes(context) ?: return@launch; - val txt = String(txtBytes); - withContext(Dispatchers.Main) { - try { - StateBackup.importTxt(_context, txt); - } - catch(ex: Throwable) { - UIDialogs.toast("Failed to import, invalid format?\n" + ex.message); - } - } - } + val txtBytes = it?.readBytes(context) ?: return@requestFileReadAccess; + val txt = String(txtBytes); + StateBackup.importTxt(_context, txt); }; } _button_import_newpipe_subs.onClick.subscribe { dismiss(); StateApp.instance.requestFileReadAccess(_context, null, "application/json") { - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val jsonBytes = it?.readBytes(context) ?: return@launch; - val json = String(jsonBytes); - withContext(Dispatchers.Main) { - try { - StateBackup.importNewPipeSubs(_context, json); - } - catch(ex: Throwable) { - UIDialogs.toast("Failed to import, invalid format?\n" + ex.message); - } - } - } + val jsonBytes = it?.readBytes(context) ?: return@requestFileReadAccess; + val json = String(jsonBytes); + StateBackup.importNewPipeSubs(_context, json); }; }; _button_import_platform.onClick.subscribe { diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 23ace157..ede24707 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -47,7 +47,6 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBytesSpeed -import com.futo.polycentric.core.hexStringToByteArray import hasAnySource import isDownloadable import kotlinx.coroutines.CancellationException @@ -60,21 +59,16 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Contextual import kotlinx.serialization.Transient -import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.Thread.sleep -import java.nio.ByteBuffer import java.time.OffsetDateTime import java.util.UUID import java.util.concurrent.Executors import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinTask import java.util.concurrent.ThreadLocalRandom -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec import kotlin.coroutines.resumeWithException import kotlin.time.times @@ -303,10 +297,9 @@ class VideoDownload { try { val playlistResponse = client.get(source.url) if (playlistResponse.isOk) { - val resolvedPlaylistUrl = playlistResponse.url val playlistContent = playlistResponse.body?.string() if (playlistContent != null) { - videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl)) + videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url)) } } } catch (e: Throwable) { @@ -352,10 +345,9 @@ class VideoDownload { try { val playlistResponse = client.get(source.url) if (playlistResponse.isOk) { - val resolvedPlaylistUrl = playlistResponse.url val playlistContent = playlistResponse.body?.string() if (playlistContent != null) { - audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl)) + audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url)) } } } catch (e: Throwable) { @@ -572,14 +564,6 @@ class VideoDownload { } } - private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - val secretKey = SecretKeySpec(key, "AES") - val ivSpec = IvParameterSpec(iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) - return cipher.doFinal(encryptedSegment) - } - private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { if(targetFile.exists()) targetFile.delete(); @@ -595,14 +579,6 @@ class VideoDownload { ?: throw Exception("Variant playlist content is empty") val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) - val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) { - val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl) - check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" } - DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray()) - } else { - null - } - variantPlaylist.segments.forEachIndexed { index, segment -> if (segment !is HLS.MediaSegment) { return@forEachIndexed @@ -614,7 +590,7 @@ class VideoDownload { try { segmentFiles.add(segmentFile) - val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed -> + val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) @@ -654,8 +630,10 @@ class VideoDownload { private suspend fun combineSegments(context: Context, segmentFiles: List, targetFile: File) = withContext(Dispatchers.IO) { suspendCancellableCoroutine { continuation -> - val cmd = - "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\"" + val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") + fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) + + val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? @@ -665,6 +643,7 @@ class VideoDownload { val session = FFmpegKit.executeAsync(cmd, { session -> if (ReturnCode.isSuccess(session.returnCode)) { + fileList.delete() continuation.resumeWith(Result.success(Unit)) } else { val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { @@ -672,6 +651,7 @@ class VideoDownload { } else { "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" } + fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, @@ -726,7 +706,7 @@ class VideoDownload { val t = cue.groupValues[1]; val d = cue.groupValues[2]; - val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString()); + val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString()); val data = if(executor != null) executor.executeRequest("GET", url, null, mapOf()); @@ -791,7 +771,7 @@ class VideoDownload { else { Logger.i(TAG, "Download $name Sequential"); try { - sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress); + sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); } catch (e: Throwable) { Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") throw e @@ -818,31 +798,7 @@ class VideoDownload { } return sourceLength!!; } - - data class DecryptionInfo( - val key: ByteArray, - val iv: ByteArray? - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DecryptionInfo - - if (!key.contentEquals(other.key)) return false - if (!iv.contentEquals(other.iv)) return false - - return true - } - - override fun hashCode(): Int { - var result = key.contentHashCode() - result = 31 * result + iv.contentHashCode() - return result - } - } - - private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long { + private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long { val progressRate: Int = 4096 * 5; var lastProgressCount: Int = 0; val speedRate: Int = 4096 * 5; @@ -862,8 +818,6 @@ class VideoDownload { val sourceLength = result.body.contentLength(); val sourceStream = result.body.byteStream(); - val segmentBuffer = ByteArrayOutputStream() - var totalRead: Long = 0; try { var read: Int; @@ -874,7 +828,7 @@ class VideoDownload { if (read < 0) break; - segmentBuffer.write(buffer, 0, read); + fileStream.write(buffer, 0, read); totalRead += read; @@ -900,21 +854,6 @@ class VideoDownload { result.body.close() } - if (decryptionInfo != null) { - var iv = decryptionInfo.iv - if (iv == null) { - iv = ByteBuffer.allocate(16) - .putLong(0L) - .putLong(index.toLong()) - .array() - } - - val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!) - fileStream.write(decryptedData) - } else { - fileStream.write(segmentBuffer.toByteArray()) - } - onProgress(sourceLength, totalRead, 0); return sourceLength; } @@ -1221,8 +1160,6 @@ class VideoDownload { fun audioContainerToExtension(container: String): String { if (container.contains("audio/mp4")) return "mp4a"; - else if (container.contains("video/mp4")) - return "mp4"; else if (container.contains("audio/mpeg")) return "mpga"; else if (container.contains("audio/mp3")) @@ -1230,7 +1167,7 @@ class VideoDownload { else if (container.contains("audio/webm")) return "webm"; else if (container == "application/vnd.apple.mpegurl") - return "m4a"; + return "mp4a"; else return "audio";// throw IllegalStateException("Unknown container: " + container) } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 90be3150..a4615822 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -69,7 +69,7 @@ class VideoExport { outputFile = f; } else if (v != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); - val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName) + val f = downloadRoot.createFile(v.container, outputFileName) ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying video."); @@ -81,8 +81,8 @@ class VideoExport { outputFile = f; } else if (a != null) { val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); - val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName) - ?: throw Exception("Failed to create file in external directory."); + val f = downloadRoot.createFile(a.container, outputFileName) + ?: throw Exception("Failed to create file in external directory."); Logger.i(TAG, "Copying audio."); diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt index b3b877ab..06095058 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -73,10 +73,6 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override val isShort: Boolean get() = videoSerialized.isShort; - override var playbackTime: Long = -1; - @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) - override var playbackDate: OffsetDateTime? = null; - //TODO: Offline subtitles override val subtitles: List = listOf(); diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 9b888bff..1a9ba3f0 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -6,13 +6,13 @@ import com.caoccao.javet.exceptions.JavetException import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.interop.options.V8Flags +import com.caoccao.javet.interop.options.V8RuntimeOptions import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.V8ValueBoolean import com.caoccao.javet.values.primitive.V8ValueInteger 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.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.constructs.Event1 @@ -26,7 +26,6 @@ import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException -import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.engine.internal.V8Converter @@ -39,18 +38,8 @@ import com.futo.platformplayer.engine.packages.V8Package import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateAssets -import com.futo.platformplayer.toList -import com.futo.platformplayer.toV8ValueBlocking -import com.futo.platformplayer.toV8ValueAsync import com.futo.platformplayer.warnIfMainThread -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.cancel -import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock class V8Plugin { val config: IV8PluginConfig; @@ -58,14 +47,10 @@ class V8Plugin { private val _clientAuth: ManagedHttpClient; private val _clientOthers: ConcurrentHashMap = ConcurrentHashMap(); - private val _promises = ConcurrentHashMapUnit)?>(); - val httpClient: ManagedHttpClient get() = _client; val httpClientAuth: ManagedHttpClient get() = _clientAuth; val httpClientOthers: Map get() = _clientOthers; - var runtimeId: Int = 0; - fun registerHttpClient(client: JSHttpClient) { synchronized(_clientOthers) { _clientOthers.put(client.clientId, client); @@ -82,8 +67,10 @@ class V8Plugin { var isStopped = true; val onStopped = Event1(); - private val _busyLock = ReentrantLock() - val isBusy get() = _busyLock.isLocked; + //TODO: Implement a more universal isBusy system for plugins + JSClient + pooling? TBD if propagation would be beneficial + private val _busyCounterLock = Object(); + private var _busyCounter = 0; + val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 }; var allowDevSubmit: Boolean = false private set(value) { @@ -153,7 +140,6 @@ class V8Plugin { synchronized(_runtimeLock) { if (_runtime != null) return; - runtimeId = runtimeId + 1; //V8RuntimeOptions.V8_FLAGS.setUseStrict(true); val host = V8Host.getV8Instance(); val options = host.jsRuntimeType.getRuntimeOptions(); @@ -162,8 +148,6 @@ class V8Plugin { if (!host.isIsolateCreated) throw IllegalStateException("Isolate not created"); - _runtimeMap.put(_runtime!!, this); - //Setup bridge _runtime?.let { it.converter = V8Converter(); @@ -200,23 +184,11 @@ class V8Plugin { } fun stop(){ Logger.i(TAG, "Stopping plugin [${config.name}]"); - busy { - Logger.i(TAG, "Plugin stopping"); + isStopped = true; + whenNotBusy { synchronized(_runtimeLock) { - if(isStopped) - return@busy; isStopped = true; - runtimeId = runtimeId + 1; - - //Cleanup http - for(pack in _depsPackages) { - if(pack is PackageHttp) { - pack.cleanup(); - } - } - _runtime?.let { - _runtimeMap.remove(it); _runtime = null; if(!it.isClosed && !it.isDead) { try { @@ -231,146 +203,61 @@ class V8Plugin { Logger.i(TAG, "Stopped plugin [${config.name}]"); }; } - Logger.i(TAG, "Plugin stopped"); onStopped.emit(this); } - cancelAllPromises(); } - fun isThreadAlreadyBusy(): Boolean { - return _busyLock.isHeldByCurrentThread; - } - fun busy(handle: ()->T): T { - _busyLock.lock(); - try { - return handle(); - } - finally { - _busyLock.unlock(); - } - /* - _busyLock.withLock { - //Logger.i(TAG, "Entered busy: " + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()); - return handle(); - }*/ - } - fun unbusy(handle: ()->T): T { - val wasLocked = isThreadAlreadyBusy(); - if(!wasLocked) - return handle(); - val lockCount = _busyLock.holdCount; - for(i in 1..lockCount) - _busyLock.unlock(); - try { - Logger.w(TAG, "Unlocking V8 thread for [${config.name}] for a blocking resolve of a promise") - return handle(); - } - finally { - Logger.w(TAG, "Relocking V8 thread for [${config.name}] for a blocking resolve of a promise") - - for(i in 1..lockCount) - _busyLock.lock(); - } - } fun execute(js: String) : V8Value { return executeTyped(js); } - - suspend fun executeTypedAsync(js: String) : Deferred { - warnIfMainThread("V8Plugin.executeTypedAsync"); - if(isStopped) - throw PluginEngineStoppedException(config, "Instance is stopped", js); - - return withContext(IO) { - return@withContext busy { - try { - val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); - val result = catchScriptErrors("Plugin[${config.name}]", js) { - runtime.getExecutor(js).execute() - }; - - if (result is V8ValuePromise) { - return@busy result.toV8ValueAsync(this@V8Plugin); - } else - return@busy CompletableDeferred(result as T); - } - catch(ex: Throwable) { - val def = CompletableDeferred(); - def.completeExceptionally(ex); - return@busy def; - } - } - } - } fun executeTyped(js: String) : T { warnIfMainThread("V8Plugin.executeTyped"); if(isStopped) throw PluginEngineStoppedException(config, "Instance is stopped", js); - val result = busy { - val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); - return@busy catchScriptErrors("Plugin[${config.name}]", js) { + synchronized(_busyCounterLock) { + _busyCounter++; + } + + val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); + try { + return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() }; - }; - if(result is V8ValuePromise) { - return result.toV8ValueBlocking(this@V8Plugin); } - return result as T; - } - fun executeBoolean(js: String) : Boolean? = busy { catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value } } - fun executeString(js: String) : String? = busy { catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value } } - fun executeInteger(js: String) : Int? = busy { catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value } } - - - fun handlePromise(result: V8ValuePromise): CompletableDeferred { - val def = CompletableDeferred(); - result.register(object: IV8ValuePromise.IListener { - override fun onFulfilled(p0: V8Value?) { - resolvePromise(result); - def.complete(p0 as T); + finally { + synchronized(_busyCounterLock) { + //Free busy *after* afterBusy calls are done to prevent calls on dead runtimes + try { + afterBusy.emit(_busyCounter - 1); + } + catch(ex: Throwable) { + Logger.e(TAG, "Unhandled V8Plugin.afterBusy", ex); + } + _busyCounter--; } - override fun onRejected(p0: V8Value?) { - resolvePromise(result); - def.completeExceptionally(NotImplementedError("onRejected promise not implemented..")); - } - override fun onCatch(p0: V8Value?) { - resolvePromise(result); - def.completeExceptionally(NotImplementedError("onCatch promise not implemented..")); - } - }); - registerPromise(result) { - if(def.isActive) - def.cancel("Cancelled by system"); - } - return def; - } - fun registerPromise(promise: V8ValuePromise, onCancelled: ((V8ValuePromise)->Unit)? = null) { - Logger.v(TAG, "Promise registered for plugin [${config.name}]: ${promise.hashCode()}"); - if (onCancelled != null) { - _promises.put(promise, onCancelled) - }; - } - fun resolvePromise(promise: V8ValuePromise, cancelled: Boolean = false) { - Logger.v(TAG, "Promise resolved for plugin [${config.name}]: ${promise.hashCode()}"); - val found = synchronized(_promises) { - val found = _promises.getOrDefault(promise, null); - _promises.remove(promise); - return@synchronized found; - }; - if(found != null && cancelled) - found(promise); - } - fun cancelAllPromises(){ - val promises = _promises.keys().toList(); - for(key in promises) { - try { - resolvePromise(key, true); - } - catch(ex: Throwable) {} } } + fun executeBoolean(js: String) : Boolean? = catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value }; + fun executeString(js: String) : String? = catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value }; + fun executeInteger(js: String) : Int? = catchScriptErrors("Plugin[${config.name}]") { executeTyped(js).value }; + fun whenNotBusy(handler: (V8Plugin)->Unit) { + synchronized(_busyCounterLock) { + if(_busyCounter == 0) + handler(this); + else { + val tag = Object(); + afterBusy.subscribe(tag) { + if(it == 0) { + Logger.w(TAG, "V8Plugin afterBusy handled"); + afterBusy.remove(tag); + handler(this); + } + } + } + } + } private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? { //TODO: Auto get all package types? @@ -397,14 +284,8 @@ class V8Plugin { private val REGEX_EX_FALLBACK = Regex(".*throw.*?[\"](.*)[\"].*"); private val REGEX_EX_FALLBACK2 = Regex(".*throw.*?['](.*)['].*"); - private val _runtimeMap = ConcurrentHashMap(); - val TAG = "V8Plugin"; - fun getPluginFromRuntime(runtime: V8Runtime): V8Plugin? { - return _runtimeMap.getOrDefault(runtime, null); - } - fun catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T { var codeStripped = code; if(codeStripped != null) { //TODO: Improve code stripped @@ -438,23 +319,14 @@ class V8Plugin { throw ScriptCompilationException(config, "Compilation: [${context}]: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped); } catch(executeEx: JavetExecutionException) { - val obj = executeEx.scriptingError?.context - if(obj != null && obj.containsKey("plugin_type") == true) { - val pluginType = obj["plugin_type"].toString(); + if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) { + val pluginType = executeEx.scriptingError.context["plugin_type"].toString(); //Captcha if (pluginType == "CaptchaRequiredException") { throw ScriptCaptchaRequiredException(config, - obj["url"]?.toString(), - obj["body"]?.toString(), - executeEx, executeEx.scriptingError?.stack, codeStripped); - } - - //Reload Required - if (pluginType == "ReloadRequiredException") { - throw ScriptReloadRequiredException(config, - obj["msg"]?.toString(), - obj["reloadData"]?.toString(), + executeEx.scriptingError.context["url"]?.toString(), + executeEx.scriptingError.context["body"]?.toString(), executeEx, executeEx.scriptingError?.stack, codeStripped); } @@ -468,41 +340,6 @@ class V8Plugin { codeStripped ); } - /* //Required for newer V8 versions - if(executeEx.scriptingError?.context is IJavetEntityError) { - val obj = executeEx.scriptingError?.context as IJavetEntityError - if(obj.context.containsKey("plugin_type") == true) { - val pluginType = obj.context["plugin_type"].toString(); - - //Captcha - if (pluginType == "CaptchaRequiredException") { - throw ScriptCaptchaRequiredException(config, - obj.context["url"]?.toString(), - obj.context["body"]?.toString(), - executeEx, executeEx.scriptingError?.stack, codeStripped); - } - - //Reload Required - if (pluginType == "ReloadRequiredException") { - throw ScriptReloadRequiredException(config, - obj.context["msg"]?.toString(), - obj.context["reloadData"]?.toString(), - executeEx, executeEx.scriptingError?.stack, codeStripped); - } - - //Others - throwExceptionFromV8( - config, - pluginType, - (extractJSExceptionMessage(executeEx) ?: ""), - executeEx, - executeEx.scriptingError?.stack, - codeStripped - ); - } - - } - */ throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped); } catch(ex: Exception) { @@ -553,4 +390,9 @@ class V8Plugin { return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found"); } } + + + /** + * Methods available for scripts (bridge object) + */ } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt b/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt index 030c3646..9aa0c6cc 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/dev/V8RemoteObject.kt @@ -136,7 +136,7 @@ class V8RemoteObject { } - fun List.serialize() : String { + fun List.serialize() : String { return _gson.toJson(this); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt index 4011b0a8..bce39025 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow open class NoInternetException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { @@ -12,7 +11,6 @@ open class NoInternetException(config: IV8PluginConfig, error: String, ex: Excep companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : NoInternetException { - obj.ensureIsBusy(); return NoInternetException(config, obj.getOrThrow(config, "message", "NoInternetException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt index 48c3142f..ef1ca13f 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptAgeException.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow open class ScriptAgeException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { @@ -12,7 +11,6 @@ open class ScriptAgeException(config: IV8PluginConfig, error: String, ex: Except companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); return ScriptException(config, obj.getOrThrow(config, "message", "ScriptAgeException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt index 6bbf536b..8aa7f2c8 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCaptchaRequiredException.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -10,7 +9,6 @@ class ScriptCaptchaRequiredException(config: IV8PluginConfig, val url: String?, companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); val contextName = "ScriptCaptchaRequiredException"; return ScriptCaptchaRequiredException(config, obj.getOrDefault(config, "url", contextName, null), diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt index 26b2eebc..2db245d3 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCompilationException.kt @@ -2,14 +2,12 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class ScriptCompilationException(config: IV8PluginConfig, error: String, ex: Exception? = null, code: String? = null) : PluginException(config, error, ex, code) { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptCompilationException { - obj.ensureIsBusy(); return ScriptCompilationException(config, obj.getOrThrow(config, "message", "ScriptCompilationException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCriticalException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCriticalException.kt index d8eda509..6581ec25 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCriticalException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptCriticalException.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { @@ -12,7 +11,6 @@ open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: E companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); return ScriptCriticalException(config, obj.getOrThrow(config, "message", "ScriptCriticalException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt index de777a9f..cf038a23 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptException.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow open class ScriptException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptExecutionException(config, error, ex, stack, code) { @@ -12,7 +11,6 @@ open class ScriptException(config: IV8PluginConfig, error: String, ex: Exception companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); return ScriptException(config, obj.getOrThrow(config, "message", "ScriptException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt index 8bfd49d6..28b9b0e9 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptExecutionException.kt @@ -2,7 +2,6 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow open class ScriptExecutionException(config: IV8PluginConfig, error: String, ex: Exception? = null, val stack: String? = null, code: String? = null) : PluginException(config, error, ex, code) { @@ -12,7 +11,6 @@ open class ScriptExecutionException(config: IV8PluginConfig, error: String, ex: companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptExecutionException { - obj.ensureIsBusy(); return ScriptExecutionException(config, obj.getOrThrow(config, "message", "ScriptExecutionException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt index 943b4fe9..dd2aaf7a 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptImplementationException.kt @@ -2,14 +2,12 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class ScriptImplementationException(config: IV8PluginConfig, error: String, ex: Exception? = null, var pluginId: String? = null, code: String? = null) : PluginException(config, error, ex, code) { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptImplementationException { - obj.ensureIsBusy(); return ScriptImplementationException(config, obj.getOrThrow(config, "message", "ScriptImplementationException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptLoginRequiredException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptLoginRequiredException.kt index 4acf0c55..423d5786 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptLoginRequiredException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptLoginRequiredException.kt @@ -2,14 +2,12 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class ScriptLoginRequiredException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); return ScriptLoginRequiredException(config, obj.getOrThrow(config, "message", "ScriptLoginRequiredException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptReloadRequiredException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptReloadRequiredException.kt deleted file mode 100644 index 6c792a32..00000000 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptReloadRequiredException.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.futo.platformplayer.engine.exceptions - -import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.engine.V8PluginConfig -import com.futo.platformplayer.ensureIsBusy -import com.futo.platformplayer.getOrDefault -import com.futo.platformplayer.getOrThrow - -class ScriptReloadRequiredException(config: IV8PluginConfig, val msg: String?, val reloadData: String?, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, msg ?: "ReloadRequired", ex, stack, code) { - - companion object { - fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); - val contextName = "ScriptReloadRequiredException"; - return ScriptReloadRequiredException(config, - obj.getOrThrow(config, "message", contextName), - obj.getOrDefault(config, "reloadData", contextName, null)); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt index 17d02073..6f883854 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptTimeoutException.kt @@ -2,13 +2,11 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class ScriptTimeoutException(config: IV8PluginConfig, error: String, ex: Exception? = null) : ScriptException(config, error, ex) { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptTimeoutException { - obj.ensureIsBusy(); return ScriptTimeoutException(config, obj.getOrThrow(config, "message", "ScriptException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt index feb47c35..5d331b8b 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptUnavailableException.kt @@ -2,14 +2,12 @@ package com.futo.platformplayer.engine.exceptions import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.engine.IV8PluginConfig -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow class ScriptUnavailableException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException { - obj.ensureIsBusy(); return ScriptUnavailableException(config, obj.getOrThrow(config, "message", "ScriptUnavailableException")); } } diff --git a/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt b/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt index fd30af6f..4e861b72 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/internal/V8BindObject.kt @@ -13,8 +13,8 @@ open class V8BindObject : IV8Convertable { override fun toV8(runtime: V8Runtime): V8Value? { synchronized(this) { - //if(_runtimeObj != null) - // return _runtimeObj; + if(_runtimeObj != null) + return _runtimeObj; val v8Obj = runtime.createV8ValueObject(); v8Obj.bind(this); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index db44c1fc..1a77d82d 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -4,7 +4,6 @@ import android.media.MediaCodec import android.media.MediaCodecList import com.caoccao.javet.annotations.V8Function import com.caoccao.javet.annotations.V8Property -import com.caoccao.javet.interop.callback.JavetCallbackContext import com.caoccao.javet.utils.JavetResourceUtils import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.reference.V8ValueFunction @@ -13,7 +12,6 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -27,7 +25,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.util.concurrent.ConcurrentHashMap class PackageBridge : V8Package { @Transient @@ -75,35 +72,6 @@ class PackageBridge : V8Package { fun buildSpecVersion(): Int { return JSClientConstants.PLUGIN_SPEC_VERSION; } - @V8Property - fun buildPlatform(): String { - return "android"; - } - - @V8Property - fun supportedFeatures(): Array { - return arrayOf( - "ReloadRequiredException", - "HttpBatchClient", - "Async" - ); - } - - @V8Property - fun supportedContent(): Array { - return arrayOf( - ContentType.MEDIA.value, - ContentType.POST.value, - ContentType.PLAYLIST.value, - ContentType.WEB.value, - ContentType.URL.value, - ContentType.NESTED_VIDEO.value, - ContentType.CHANNEL.value, - ContentType.LOCKED.value, - ContentType.PLACEHOLDER.value, - ContentType.DEFERRED.value - ) - } @V8Function fun dispose(value: V8Value) { @@ -112,54 +80,45 @@ class PackageBridge : V8Package { } var timeoutCounter = 0; - var timeoutMap = ConcurrentHashMap(); + var timeoutMap = HashSet(); @V8Function fun setTimeout(func: V8ValueFunction, timeout: Long): Int { val id = timeoutCounter++; + val funcClone = func.toClone() StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { delay(timeout); - if (_plugin.isStopped) - return@launch; - if (!timeoutMap.containsKey(id)) { - _plugin.busy { - if (!_plugin.isStopped) - JavetResourceUtils.safeClose(funcClone); + synchronized(timeoutMap) { + if(!timeoutMap.contains(id)) { + JavetResourceUtils.safeClose(funcClone); + return@launch; } - return@launch; + timeoutMap.remove(id); } - timeoutMap.remove(id); try { - Logger.w(TAG, "setTimeout before busy (${timeout}): ${_plugin.isBusy}"); - _plugin.busy { - Logger.w(TAG, "setTimeout in busy"); - if (!_plugin.isStopped) - funcClone.callVoid(null, arrayOf()); - Logger.w(TAG, "setTimeout after"); + _plugin.whenNotBusy { + funcClone.callVoid(null, arrayOf()); } - } catch (ex: Throwable) { + } + catch(ex: Throwable) { Logger.e(TAG, "Failed timeout callback", ex); - } finally { - _plugin.busy { - if (!_plugin.isStopped) - JavetResourceUtils.safeClose(funcClone); - } - //_plugin.whenNotBusy { - //} + } + finally { + JavetResourceUtils.safeClose(funcClone); } }; - timeoutMap.put(id, true); + synchronized(timeoutMap) { + timeoutMap.add(id); + } return id; } @V8Function fun clearTimeout(id: Int) { - if (timeoutMap.containsKey(id)) - timeoutMap.remove(id); - } - @V8Function - fun sleep(length: Int) { - Thread.sleep(length.toLong()); + synchronized(timeoutMap) { + if(timeoutMap.contains(id)) + timeoutMap.remove(id); + } } @V8Function @@ -167,7 +126,7 @@ class PackageBridge : V8Package { Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}"); StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { try { - UIDialogs.appToast(str); + UIDialogs.toast(str); } catch (e: Throwable) { Logger.e(TAG, "Failed to show toast.", e); } diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index d7aa89b7..686318a1 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -8,7 +8,9 @@ import com.caoccao.javet.enums.V8ProxyMode import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueArrayBuffer import com.caoccao.javet.values.reference.V8ValueObject +import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer import com.caoccao.javet.values.reference.V8ValueTypedArray import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -17,11 +19,16 @@ import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.V8BindObject -import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.net.SocketTimeoutException import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinTask +import kotlin.concurrent.thread +import kotlin.streams.asSequence class PackageHttp: V8Package { @Transient @@ -42,20 +49,6 @@ class PackageHttp: V8Package { private var _batchPoolLock: Any = Any(); private var _batchPool: ForkJoinPool? = null; - private val aliveSockets = mutableListOf(); - private var _cleanedUp = false; - - private val _clients = mutableMapOf() - - fun getClient(id: String?): PackageHttpClient { - if(id == null) - throw IllegalArgumentException("Http client ${id} doesn't exist"); - if(_packageClient.clientId() == id) - return _packageClient; - if(_packageClientAuth.clientId() == id) - return _packageClientAuth; - return _clients.getOrDefault(id, null) ?: throw IllegalArgumentException("Http client ${id} doesn't exist"); - } constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { _config = config; @@ -65,27 +58,6 @@ class PackageHttp: V8Package { _packageClientAuth = PackageHttpClient(this, _clientAuth); } - fun cleanup(){ - Logger.w(TAG, "PackageHttp Cleaning up") - val sockets = synchronized(aliveSockets) { aliveSockets.toList() } - _cleanedUp = true; - for(socket in sockets){ - try { - Logger.w(TAG, "PackageHttp Socket Cleaned Up"); - socket.close(1001, "Cleanup"); - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed to close socket", ex); - } - } - if(sockets.size > 0) { - //Thread.sleep(100); //Give sockets a bit - } - synchronized(aliveSockets) { - aliveSockets.clear(); - } - } - /* Automatically adjusting threadpool dedicated per PackageHttp for batch requests. @@ -124,8 +96,6 @@ class PackageHttp: V8Package { _plugin.registerHttpClient(httpClient); val client = PackageHttpClient(this, httpClient); - _clients.put(client.clientId() ?: "", client); - return client; } @V8Function @@ -141,24 +111,24 @@ class PackageHttp: V8Package { @V8Function fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); + _packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); + _packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { @@ -166,15 +136,15 @@ class PackageHttp: V8Package { val client = if(useAuth) _packageClientAuth else _packageClient; if(body is V8ValueString) - return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is String) - return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is V8ValueTypedArray) - return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is ByteArray) - return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else if(body is ArrayList<*>) //Avoid this case, used purely for testing - return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); else throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); } @@ -260,18 +230,18 @@ class PackageHttp: V8Package { @V8Function fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder { - return clientRequest(_package.getDefaultClient(useAuth).clientId(), method, url, headers); + return clientRequest(_package.getDefaultClient(useAuth), method, url, headers); } @V8Function fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder { - return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers); + return clientRequestWithBody(_package.getDefaultClient(useAuth), method, url, body, headers); } @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder - = clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers); + = clientGET(_package.getDefaultClient(useAuth), url, headers); @V8Function fun POST(url: String, body: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder - = clientPOST(_package.getDefaultClient(useAuth).clientId(), url, body, headers); + = clientPOST(_package.getDefaultClient(useAuth), url, body, headers); @V8Function fun DUMMY(): BatchBuilder { @@ -282,21 +252,21 @@ class PackageHttp: V8Package { //Client-specific @V8Function - fun clientRequest(clientId: String?, method: String, url: String, headers: MutableMap = HashMap()) : BatchBuilder { - _reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers))); + fun clientRequest(client: PackageHttpClient, method: String, url: String, headers: MutableMap = HashMap()) : BatchBuilder { + _reqs.add(Pair(client, RequestDescriptor(method, url, headers))); return BatchBuilder(_package, _reqs); } @V8Function - fun clientRequestWithBody(clientId: String?, method: String, url: String, body:String, headers: MutableMap = HashMap()) : BatchBuilder { - _reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers, body))); + fun clientRequestWithBody(client: PackageHttpClient, method: String, url: String, body:String, headers: MutableMap = HashMap()) : BatchBuilder { + _reqs.add(Pair(client, RequestDescriptor(method, url, headers, body))); return BatchBuilder(_package, _reqs); } @V8Function - fun clientGET(clientId: String?, url: String, headers: MutableMap = HashMap()) : BatchBuilder - = clientRequest(clientId, "GET", url, headers); + fun clientGET(client: PackageHttpClient, url: String, headers: MutableMap = HashMap()) : BatchBuilder + = clientRequest(client, "GET", url, headers); @V8Function - fun clientPOST(clientId: String?, url: String, body: String, headers: MutableMap = HashMap()) : BatchBuilder - = clientRequestWithBody(clientId, "POST", url, body, headers); + fun clientPOST(client: PackageHttpClient, url: String, body: String, headers: MutableMap = HashMap()) : BatchBuilder + = clientRequestWithBody(client, "POST", url, body, headers); //Finalizer @@ -306,9 +276,9 @@ class PackageHttp: V8Package { if(it.second.method == "DUMMY") return@autoParallelPool null; if(it.second.body != null) - return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); + return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); else - return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType); + return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); }.map { if(it.second != null) throw it.second!!; @@ -335,7 +305,6 @@ class PackageHttp: V8Package { @Transient private val _clientId: String?; - @V8Property fun clientId(): String? { return _clientId; @@ -348,17 +317,6 @@ class PackageHttp: V8Package { _clientId = if(_client is JSHttpClient) _client.clientId else null; } - @V8Function - fun resetAuthCookies(){ - if(_client is JSHttpClient) - _client.resetAuthCookies(); - } - @V8Function - fun clearOtherCookies(){ - if(_client is JSHttpClient) - _client.clearOtherCookies(); - } - @V8Function fun setDefaultHeaders(defaultHeaders: Map) { for(pair in defaultHeaders) @@ -387,9 +345,7 @@ class PackageHttp: V8Package { } @V8Function - fun request(method: String, url: String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse - = requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); - fun requestInternal(method: String, url: String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { + fun request(method: String, url: String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { return@logExceptions catchHttp { @@ -408,9 +364,7 @@ class PackageHttp: V8Package { }; } @V8Function - fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse - = requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) - fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { + fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -431,9 +385,7 @@ class PackageHttp: V8Package { } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse - = GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) - fun GETInternal(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -455,24 +407,7 @@ class PackageHttp: V8Package { }; } @V8Function - fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse { - if(body is V8ValueString) - return POSTInternal(url, body.value, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); - else if(body is String) - return POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); - else if(body is V8ValueTypedArray) - return POSTInternal(url, body.toBytes(), headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); - else if(body is ByteArray) - return POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); - else if(body is ArrayList<*>) //Avoid this case, used purely for testing - return POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING); - else - throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); - } - - - // = POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) - fun POSTInternal(url: String, body: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun POST(url: String, body: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -493,7 +428,8 @@ class PackageHttp: V8Package { } }; } - fun POSTInternal(url: String, body: ByteArray, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + @V8Function + fun POST(url: String, body: ByteArray, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { @@ -517,16 +453,9 @@ class PackageHttp: V8Package { @V8Function fun socket(url: String, headers: Map? = null): SocketResult { - if(_package._cleanedUp) - throw IllegalStateException("Plugin shutdown"); val socketHeaders = headers?.toMutableMap() ?: HashMap(); applyDefaultHeaders(socketHeaders); - val socket = SocketResult(_package, this, _client, url, socketHeaders); - Logger.w(TAG, "PackageHttp Socket opened"); - synchronized(_package.aliveSockets) { - _package.aliveSockets.add(socket); - } - return socket; + return SocketResult(this, _client, url, socketHeaders); } private fun applyDefaultHeaders(headerMap: MutableMap) { @@ -632,15 +561,13 @@ class PackageHttp: V8Package { private var _listeners: V8ValueObject? = null; - private val _package: PackageHttp; private val _packageClient: PackageHttpClient; private val _client: ManagedHttpClient; private val _url: String; private val _headers: Map; - constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map) { + constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map) { _packageClient = pack; - _package = parent; _client = client; _url = url; _headers = headers; @@ -666,11 +593,9 @@ class PackageHttp: V8Package { override fun open() { Logger.i(TAG, "Websocket opened: " + _url); _isOpen = true; - if(hasOpen && _listeners?.isClosed != true) { + if(hasOpen) { try { - _package._plugin.busy { - _listeners?.invokeV8Void("open", arrayOf()); - } + _listeners?.invokeVoid("open", arrayOf()); } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex); @@ -678,22 +603,18 @@ class PackageHttp: V8Package { } } override fun message(msg: String) { - if(hasMessage && _listeners?.isClosed != true) { + if(hasMessage) { try { - _package._plugin.busy { - _listeners?.invokeV8Void("message", msg); - } + _listeners?.invokeVoid("message", msg); } catch(ex: Throwable) {} } } override fun closing(code: Int, reason: String) { - if(hasClosing && _listeners?.isClosed != true) + if(hasClosing) { try { - _package._plugin.busy { - _listeners?.invokeV8Void("closing", code, reason); - } + _listeners?.invokeVoid("closing", code, reason); } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex); @@ -702,29 +623,21 @@ class PackageHttp: V8Package { } override fun closed(code: Int, reason: String) { _isOpen = false; - if(hasClosed && _listeners?.isClosed != true) { + if(hasClosed) { try { - _package._plugin.busy { - _listeners?.invokeV8Void("closed", code, reason); - } + _listeners?.invokeVoid("closed", code, reason); } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); } } - Logger.w(TAG, "PackageHttp Socket removed"); - synchronized(_package.aliveSockets) { - _package.aliveSockets.remove(this@SocketResult); - } } override fun failure(exception: Throwable) { _isOpen = false; Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); - if(hasFailure && _listeners?.isClosed != true) { + if(hasFailure) { try { - _package._plugin.busy { - _listeners?.invokeV8Void("failure", exception.message); - } + _listeners?.invokeVoid("failure", exception.message); } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 8bfc80e0..b26c9b35 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -5,7 +5,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager @@ -25,9 +24,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.exceptions.ChannelException @@ -35,11 +32,9 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform -import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter @@ -59,10 +54,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private var _results: ArrayList = arrayListOf(); private var _adapterResults: InsertedViewAdapterWithLoader? = null; private var _lastPolycentricProfile: PolycentricProfile? = null; - private var _query: String? = null - private var _searchView: SearchView? = null - val onContentClicked = Event3, ArrayList>?>(); + val onContentClicked = Event2(); val onContentUrlClicked = Event2(); val onUrlClicked = Event1(); val onChannelClicked = Event1(); @@ -75,32 +68,16 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private fun getContentPager(channel: IPlatformChannel): IPager { Logger.i(TAG, "getContentPager"); - var pager: IPager? = null - val query = _query - if (!query.isNullOrBlank()) { - if(subType != null) { - Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})") - pager = StatePlatform.instance.searchChannel(channel.url, query, subType); - } else { - Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})") - pager = StatePlatform.instance.searchChannel(channel.url, query); - } - } else { - val lastPolycentricProfile = _lastPolycentricProfile; - if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) { - pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); - Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})") - } + val lastPolycentricProfile = _lastPolycentricProfile; + var pager: IPager? = null; + if (lastPolycentricProfile != null) + pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); - if(pager == null) { - if(subType != null) { - pager = StatePlatform.instance.getChannelContent(channel.url, subType); - Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})") - } else { - pager = StatePlatform.instance.getChannelContent(channel.url); - Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})") - } - } + if(pager == null) { + if(subType != null) + pager = StatePlatform.instance.getChannelContent(channel.url, subType); + else + pager = StatePlatform.instance.getChannelContent(channel.url); } return pager; } @@ -167,55 +144,22 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _taskLoadVideos.cancel(); - _query = null _channel = channel; - updateSearchViewVisibility() _results.clear(); _adapterResults?.notifyDataSetChanged(); loadInitial(); } - 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 - } - - fun setQuery(query: String) { - _query = query - _taskLoadVideos.cancel() - _results.clear() - _adapterResults?.notifyDataSetChanged() - loadInitial() - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); - _query = null _recyclerResults = view.findViewById(R.id.recycler_videos); - val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply { - onEnter.subscribe { - setQuery(it) - } - } - _searchView = searchView - updateSearchViewVisibility() - - _adapterResults = PreviewContentListAdapter(lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { + _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); - this.onContentClicked.subscribe { content, num -> - val results = ArrayList(_results) - this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results)) - } + this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); @@ -229,7 +173,6 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _recyclerResults?.layoutManager = _glmVideo; _recyclerResults?.addOnScrollListener(_scrollListener); - return view; } @@ -238,8 +181,6 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _recyclerResults?.removeOnScrollListener(_scrollListener); _recyclerResults = null; _pager = null; - _query = null - _searchView = null _taskLoadVideos.cancel(); _nextPageHandler.cancel(); @@ -362,7 +303,6 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), } private fun loadInitial() { - Logger.i(TAG, "loadInitial") val channel: IPlatformChannel = _channel ?: return; setLoading(true); _taskLoadVideos.run(channel); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt index c9509144..cd88e610 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelPlaylistsFragment.kt @@ -148,7 +148,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment { _recyclerResults = view.findViewById(R.id.recycler_videos) _adapterResults = PreviewContentListAdapter( - lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar + view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar ).apply { this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit) this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt index c9b89d82..6244004b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt @@ -23,7 +23,7 @@ open class MainActivityFragment : Fragment() { fun navigate(frag: MainFragment, parameter: Any? = null, withHistory: Boolean = true) { val a = activity if (a is MainActivity) - (activity as MainActivity).navigate(frag, parameter, withHistory, false) + (activity as MainActivity).navigate(frag, parameter, withHistory) else Log.e(TAG, "Failed to navigate due to activity not being a main activity.") } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index f6e57c26..c4afcf74 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -15,7 +15,6 @@ import android.view.ViewGroup import android.widget.* import androidx.core.animation.doOnEnd import androidx.lifecycle.lifecycleScope -import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -331,7 +330,7 @@ class MenuBottomBarFragment : MainActivityFragment() { } if (!StatePayment.instance.hasPaid) { - newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate(withHistory = true) })) + newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate() })) } //Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated @@ -376,7 +375,6 @@ class MenuBottomBarFragment : MainActivityFragment() { fun newInstance() = MenuBottomBarFragment().apply { } - @UnstableApi //Add configurable buttons here var buttonDefinitions = listOf( ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { @@ -385,19 +383,18 @@ class MenuBottomBarFragment : MainActivityFragment() { currentMain.scrollToTop(false) currentMain.reloadFeed() } else { - it.navigate(withHistory = false) + it.navigate() } }), - ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate(withHistory = false) }), - ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate(withHistory = false) }), - ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate() }), + ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate() }), + ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate() }), + ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate() }), + ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate() }), + ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate() }), + ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate() }), + ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate() }), + ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate() }), ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); @@ -419,7 +416,7 @@ class MenuBottomBarFragment : MainActivityFragment() { }, UIDialogs.ActionStyle.PRIMARY)); }), ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, { - it.navigate(Settings.URL_FAQ, withHistory = false); + it.navigate(Settings.URL_FAQ); }) //96 is reserved for privacy button //98 is reserved for buy button diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt deleted file mode 100644 index d052e0f1..00000000 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ArticleDetailFragment.kt +++ /dev/null @@ -1,816 +0,0 @@ -package com.futo.platformplayer.fragment.mainactivity.main - -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.Animatable -import android.os.Bundle -import android.text.Html -import android.text.method.ScrollingMovementMethod -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewPropertyAnimator -import android.widget.Button -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.children -import androidx.core.view.isVisible -import androidx.core.view.setPadding -import androidx.lifecycle.lifecycleScope -import com.bumptech.glide.Glide -import com.futo.platformplayer.R -import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.UISlideOverlays -import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.article.IPlatformArticle -import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails -import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent -import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent -import com.futo.platformplayer.api.media.models.post.IPlatformPost -import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails -import com.futo.platformplayer.api.media.models.post.TextType -import com.futo.platformplayer.api.media.models.ratings.IRating -import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes -import com.futo.platformplayer.api.media.models.ratings.RatingLikes -import com.futo.platformplayer.api.media.models.video.IPlatformVideo -import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo -import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails -import com.futo.platformplayer.api.media.platforms.js.models.JSHeaderSegment -import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment -import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment -import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment -import com.futo.platformplayer.api.media.platforms.js.models.SegmentType -import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.dp -import com.futo.platformplayer.fixHtmlWhitespace -import com.futo.platformplayer.images.GlideHelper.Companion.crossfade -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod -import com.futo.platformplayer.sp -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StatePlatform -import com.futo.platformplayer.states.StatePlayer -import com.futo.platformplayer.states.StatePlaylists -import com.futo.platformplayer.states.StatePolycentric -import com.futo.platformplayer.toHumanNowDiffString -import com.futo.platformplayer.toHumanNumber -import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView -import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView -import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView -import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView -import com.futo.platformplayer.views.comments.AddCommentView -import com.futo.platformplayer.views.others.CreatorThumbnail -import com.futo.platformplayer.views.overlays.RepliesOverlay -import com.futo.platformplayer.views.pills.PillRatingLikesDislikes -import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.segments.CommentsList -import com.futo.platformplayer.views.subscriptions.SubscribeButton -import com.futo.polycentric.core.ApiMethods -import com.futo.polycentric.core.ContentType -import com.futo.polycentric.core.Models -import com.futo.polycentric.core.Opinion -import com.futo.polycentric.core.PolycentricProfile -import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions -import com.google.android.flexbox.FlexboxLayout -import com.google.android.material.imageview.ShapeableImageView -import com.google.android.material.shape.CornerFamily -import com.google.android.material.shape.ShapeAppearanceModel -import com.google.protobuf.ByteString -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import userpackage.Protocol -import java.lang.Integer.min - -class ArticleDetailFragment : MainFragment { - override val isMainView: Boolean = true; - override val isTab: Boolean = true; - override val hasBottomBar: Boolean get() = true; - - private var _viewDetail: ArticleDetailView? = null; - - constructor() : super() { } - - override fun onBackPressed(): Boolean { - return false; - } - - override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = ArticleDetailView(inflater.context).applyFragment(this); - _viewDetail = view; - return view; - } - - override fun onDestroyMainView() { - super.onDestroyMainView(); - _viewDetail?.onDestroy(); - _viewDetail = null; - } - - override fun onShownWithView(parameter: Any?, isBack: Boolean) { - super.onShownWithView(parameter, isBack); - - if (parameter is IPlatformArticleDetails) { - _viewDetail?.clear(); - _viewDetail?.setArticleDetails(parameter); - } else if (parameter is IPlatformArticle) { - _viewDetail?.setArticleOverview(parameter); - } else if(parameter is String) { - _viewDetail?.setPostUrl(parameter); - } - } - - private class ArticleDetailView : ConstraintLayout { - private lateinit var _fragment: ArticleDetailFragment; - private var _url: String? = null; - private var _isLoading = false; - private var _article: IPlatformArticleDetails? = null; - private var _articleOverview: IPlatformArticle? = null; - private var _polycentricProfile: PolycentricProfile? = null; - private var _version = 0; - private var _isRepliesVisible: Boolean = false; - private var _repliesAnimator: ViewPropertyAnimator? = null; - - private val _creatorThumbnail: CreatorThumbnail; - private val _buttonSubscribe: SubscribeButton; - private val _channelName: TextView; - private val _channelMeta: TextView; - private val _textTitle: TextView; - private val _textMeta: TextView; - private val _textSummary: TextView; - private val _containerSegments: LinearLayout; - private val _platformIndicator: PlatformIndicator; - private val _buttonShare: ImageButton; - - private val _layoutRating: LinearLayout; - private val _imageLikeIcon: ImageView; - private val _textLikes: TextView; - private val _imageDislikeIcon: ImageView; - private val _textDislikes: TextView; - - private val _addCommentView: AddCommentView; - - private val _rating: PillRatingLikesDislikes; - - private val _layoutLoadingOverlay: FrameLayout; - private val _imageLoader: ImageView; - - private var _overlayContainer: FrameLayout - private val _repliesOverlay: RepliesOverlay; - - private val _commentsList: CommentsList; - - private var _commentType: Boolean? = null; - private val _buttonPolycentric: Button - private val _buttonPlatform: Button - - private val _taskLoadPost = if(!isInEditMode) TaskHandler( - StateApp.instance.scopeGetter, - { - val result = StatePlatform.instance.getContentDetails(it).await(); - if(result !is IPlatformArticleDetails) - throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); - return@TaskHandler result; - }) - .success { setArticleDetails(it) } - .exception { - Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it); - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); - } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; - - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { - if (!StatePolycentric.instance.enabled) - return@TaskHandler null - - ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) - }) - .success { it -> setPolycentricProfile(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load claims.", it); - }; - - constructor(context: Context) : super(context) { - inflate(context, R.layout.fragview_article_detail, this); - - val root = findViewById(R.id.root); - - _creatorThumbnail = findViewById(R.id.creator_thumbnail); - _buttonSubscribe = findViewById(R.id.button_subscribe); - _channelName = findViewById(R.id.text_channel_name); - _channelMeta = findViewById(R.id.text_channel_meta); - _textTitle = findViewById(R.id.text_title); - _textMeta = findViewById(R.id.text_meta); - _textSummary = findViewById(R.id.text_summary); - _containerSegments = findViewById(R.id.container_segments); - _platformIndicator = findViewById(R.id.platform_indicator); - _buttonShare = findViewById(R.id.button_share); - - _overlayContainer = findViewById(R.id.overlay_container); - - _layoutRating = findViewById(R.id.layout_rating); - _imageLikeIcon = findViewById(R.id.image_like_icon); - _textLikes = findViewById(R.id.text_likes); - _imageDislikeIcon = findViewById(R.id.image_dislike_icon); - _textDislikes = findViewById(R.id.text_dislikes); - - _commentsList = findViewById(R.id.comments_list); - _addCommentView = findViewById(R.id.add_comment_view); - - _rating = findViewById(R.id.rating); - - _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); - _imageLoader = findViewById(R.id.image_loader); - - - _repliesOverlay = findViewById(R.id.replies_overlay); - - _buttonPolycentric = findViewById(R.id.button_polycentric) - _buttonPlatform = findViewById(R.id.button_platform) - - _buttonSubscribe.onSubscribed.subscribe { - //TODO: add overlay to layout - //UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); - }; - - val layoutTop: LinearLayout = findViewById(R.id.layout_top); - root.removeView(layoutTop); - _commentsList.setPrependedView(layoutTop); - - /*TODO: Why is this here? - _commentsList.onCommentsLoaded.subscribe { - updateCommentType(false); - };*/ - - _commentsList.onRepliesClick.subscribe { c -> - val replyCount = c.replyCount ?: 0; - var metadata = ""; - if (replyCount > 0) { - metadata += "$replyCount " + context.getString(R.string.replies); - } - - if (c is PolycentricPlatformComment) { - var parentComment: PolycentricPlatformComment = c; - _repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c, - { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, - { - val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1); - _commentsList.replaceComment(parentComment, newComment); - parentComment = newComment; - }); - } else { - _repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }); - } - - setRepliesOverlayVisible(isVisible = true, animate = true); - }; - - if (StatePolycentric.instance.enabled) { - _buttonPolycentric.setOnClickListener { - updateCommentType(false) - } - } else { - _buttonPolycentric.visibility = View.GONE - } - - _buttonPlatform.setOnClickListener { - updateCommentType(true) - } - - _addCommentView.onCommentAdded.subscribe { - _commentsList.addComment(it); - }; - - _repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); }; - - _buttonShare.setOnClickListener { share() }; - - _creatorThumbnail.onClick.subscribe { openChannel() }; - _channelName.setOnClickListener { openChannel() }; - _channelMeta.setOnClickListener { openChannel() }; - } - - private fun openChannel() { - val author = _article?.author ?: _articleOverview?.author ?: return; - _fragment.navigate(author); - } - - private fun share() { - try { - Logger.i(PreviewPostView.TAG, "sharePost") - - val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url; - _fragment.startActivity(Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND; - putExtra(Intent.EXTRA_TEXT, url); - type = "text/plain"; //TODO: Determine alt types? - }, null)); - } catch (e: Throwable) { - //Ignored - Logger.e(PreviewPostView.TAG, "Failed to share.", e); - } - } - - private fun updatePolycentricRating() { - _rating.visibility = View.GONE; - - val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return) - val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } - val version = _version; - - _rating.onLikeDislikeUpdated.remove(this); - - if (!StatePolycentric.instance.enabled) - return - - _fragment.lifecycleScope.launch(Dispatchers.IO) { - if (version != _version) { - return@launch; - } - - try { - val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null, - arrayListOf( - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( - ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.like.data)).build(), - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( - ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.dislike.data)).build() - ), - extraByteReferences = listOfNotNull(extraBytesRef) - ); - - if (version != _version) { - return@launch; - } - - val likes = queryReferencesResponse.countsList[0]; - val dislikes = queryReferencesResponse.countsList[1]; - val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; - val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; - - withContext(Dispatchers.Main) { - if (version != _version) { - return@withContext; - } - - _rating.visibility = VISIBLE; - _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); - _rating.onLikeDislikeUpdated.subscribe(this) { args -> - if (args.hasLiked) { - args.processHandle.opinion(ref, Opinion.like); - } else if (args.hasDisliked) { - args.processHandle.opinion(ref, Opinion.dislike); - } else { - args.processHandle.opinion(ref, Opinion.neutral); - } - - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServersAnnounceExceptions(); - Logger.i(TAG, "Finished backfill"); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to backfill servers", e) - } - } - - StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked) - }; - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); - _rating.visibility = View.GONE; - } - } - } - - private fun setPlatformRating(rating: IRating?) { - if (rating == null) { - _layoutRating.visibility = View.GONE; - return; - } - - _layoutRating.visibility = View.VISIBLE; - - when (rating) { - is RatingLikeDislikes -> { - _textLikes.visibility = View.VISIBLE; - _imageLikeIcon.visibility = View.VISIBLE; - _textLikes.text = rating.likes.toHumanNumber(); - - _imageDislikeIcon.visibility = View.VISIBLE; - _textDislikes.visibility = View.VISIBLE; - _textDislikes.text = rating.dislikes.toHumanNumber(); - } - is RatingLikes -> { - _textLikes.visibility = View.VISIBLE; - _imageLikeIcon.visibility = View.VISIBLE; - _textLikes.text = rating.likes.toHumanNumber(); - - _imageDislikeIcon.visibility = View.GONE; - _textDislikes.visibility = View.GONE; - } - else -> { - _textLikes.visibility = View.GONE; - _imageLikeIcon.visibility = View.GONE; - _imageDislikeIcon.visibility = View.GONE; - _textDislikes.visibility = View.GONE; - } - } - } - - fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView { - _fragment = frag; - return this; - } - - fun clear() { - _commentsList.cancel(); - _taskLoadPost.cancel(); - _taskLoadPolycentricProfile.cancel(); - _version++; - - updateCommentType(null) - _url = null; - _article = null; - _articleOverview = null; - _creatorThumbnail.clear(); - //_buttonSubscribe.setSubscribeChannel(null); TODO: clear button - _channelName.text = ""; - setChannelMeta(null); - _textTitle.text = ""; - _textMeta.text = ""; - setPlatformRating(null); - _polycentricProfile = null; - _rating.visibility = View.GONE; - updatePolycentricRating(); - setRepliesOverlayVisible(isVisible = false, animate = false); - - _containerSegments.removeAllViews(); - - _addCommentView.setContext(null, null); - _platformIndicator.clearPlatform(); - } - - fun setArticleDetails(value: IPlatformArticleDetails) { - _url = value.url; - _article = value; - - _creatorThumbnail.setThumbnail(value.author.thumbnail, false); - _buttonSubscribe.setSubscribeChannel(value.author.url); - _channelName.text = value.author.name; - setChannelMeta(value); - _textTitle.text = value.name; - _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? - - _textSummary.text = value.summary - _textSummary.isVisible = !value.summary.isNullOrEmpty() - - _platformIndicator.setPlatformFromClientID(value.id.pluginId); - setPlatformRating(value.rating); - - for(seg in value.segments) { - when(seg.type) { - SegmentType.HEADER -> { - if(seg is JSHeaderSegment) { - _containerSegments.addView(ArticleHeaderBlock(context, seg.content, seg.level)) - } - } - SegmentType.TEXT -> { - if(seg is JSTextSegment) { - _containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType)) - } - } - SegmentType.IMAGES -> { - if(seg is JSImagesSegment) { - if(seg.images.size > 0) - _containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption)) - } - } - SegmentType.NESTED -> { - if(seg is JSNestedSegment) { - _containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer)); - } - } - else ->{} - } - } - - //Fetch only when not already called in setPostOverview - if (_articleOverview == null) { - fetchPolycentricProfile(); - updatePolycentricRating(); - _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); - } - - val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1 - updateCommentType(commentType, true); - setLoading(false); - } - - fun setArticleOverview(value: IPlatformArticle) { - clear(); - _url = value.url; - _articleOverview = value; - - _creatorThumbnail.setThumbnail(value.author.thumbnail, false); - _buttonSubscribe.setSubscribeChannel(value.author.url); - _channelName.text = value.author.name; - setChannelMeta(value); - _textTitle.text = value.name; - _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? - - _platformIndicator.setPlatformFromClientID(value.id.pluginId); - _addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray())); - - updatePolycentricRating(); - fetchPolycentricProfile(); - fetchPost(); - } - - - private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) { - if (_isRepliesVisible == isVisible) { - return; - } - - _isRepliesVisible = isVisible; - _repliesAnimator?.cancel(); - - if (isVisible) { - _repliesOverlay.visibility = View.VISIBLE; - - if (animate) { - _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); - - _repliesAnimator = _repliesOverlay.animate() - .setDuration(300) - .translationY(0f) - .withEndAction { - _repliesAnimator = null; - }.apply { start() }; - } - } else { - if (animate) { - _repliesOverlay.translationY = 0f; - - _repliesAnimator = _repliesOverlay.animate() - .setDuration(300) - .translationY(_repliesOverlay.height.toFloat()) - .withEndAction { - _repliesOverlay.visibility = GONE; - _repliesAnimator = null; - }.apply { start(); } - } else { - _repliesOverlay.visibility = View.GONE; - _repliesOverlay.translationY = _repliesOverlay.height.toFloat(); - } - } - } - - private fun fetchPolycentricProfile() { - val author = _article?.author ?: _articleOverview?.author ?: return; - setPolycentricProfile(null, animate = false); - _taskLoadPolycentricProfile.run(author.id); - } - - private fun setChannelMeta(value: IPlatformArticle?) { - val subscribers = value?.author?.subscribers; - if(subscribers != null && subscribers > 0) { - _channelMeta.visibility = View.VISIBLE; - _channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; - } else { - _channelMeta.visibility = View.GONE; - _channelMeta.text = ""; - } - } - - fun setPostUrl(url: String) { - clear(); - _url = url; - fetchPost(); - } - - fun onDestroy() { - _commentsList.cancel(); - _taskLoadPost.cancel(); - _repliesOverlay.cleanup(); - } - - private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { - _polycentricProfile = polycentricProfile; - - val pp = _polycentricProfile; - if (pp == null) { - _creatorThumbnail.setHarborAvailable(false, animate, null); - return; - } - - _creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto()); - } - - private fun fetchPost() { - Logger.i(TAG, "fetchVideo") - _article = null; - - val url = _url; - if (!url.isNullOrBlank()) { - setLoading(true); - _taskLoadPost.run(url); - } - } - - private fun fetchComments() { - Logger.i(TAG, "fetchComments") - _article?.let { - _commentsList.load(true) { StatePlatform.instance.getComments(it); }; - } - } - - private fun fetchPolycentricComments() { - Logger.i(TAG, "fetchPolycentricComments") - val post = _article; - val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) } - val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null } - - if (ref == null) { - Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null") - _commentsList.clear(); - return - } - - _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); }; - } - - private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) { - val changed = commentType != _commentType - _commentType = commentType - - if (commentType == null) { - _buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac)) - _buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac)) - } else { - _buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac)) - _buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac)) - - if (commentType) { - _addCommentView.visibility = View.GONE; - - if (forceReload || changed) { - fetchComments(); - } - } else { - _addCommentView.visibility = View.VISIBLE; - - if (forceReload || changed) { - fetchPolycentricComments() - } - } - } - } - - private fun setLoading(isLoading : Boolean) { - if (_isLoading == isLoading) { - return; - } - - _isLoading = isLoading; - - if(isLoading) { - (_imageLoader.drawable as Animatable?)?.start() - _layoutLoadingOverlay.visibility = View.VISIBLE; - } - else { - _layoutLoadingOverlay.visibility = View.GONE; - (_imageLoader.drawable as Animatable?)?.stop() - } - } - - class ArticleHeaderBlock : LinearLayout { - constructor(context: Context?, content: String, level: Int) : super(context){ - inflate(context, R.layout.view_segment_text, this); - - findViewById(R.id.text_content)?.let { - it.text = content; - - val sp = when(level) { - 1 -> 6.sp(resources); - 2 -> 8.sp(resources); - 3 -> 10.sp(resources); - 4 -> 12.sp(resources); - 5 -> 14.sp(resources); - else -> 6.sp(resources); - } - it.setTextColor(Color.WHITE); - it.setTypeface(Typeface.create(null, 600, false)); - it.textSize = sp.toFloat(); - } - } - } - class ArticleTextBlock : LinearLayout { - constructor(context: Context?, content: String, textType: TextType) : super(context){ - inflate(context, R.layout.view_segment_text, this); - - findViewById(R.id.text_content)?.let { - if(textType == TextType.HTML) - it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT); - else if(textType == TextType.CODE) { - it.text = content; - it.setPadding(15.dp(resources)); - it.setHorizontallyScrolling(true); - it.movementMethod = ScrollingMovementMethod(); - it.setTypeface(Typeface.MONOSPACE); - it.setBackgroundResource(R.drawable.background_videodetail_description) - } - else - it.text = content; - } - } - } - class ArticleImageBlock: LinearLayout { - constructor(context: Context?, image: String, caption: String? = null) : super(context){ - inflate(context, R.layout.view_segment_image, this); - - findViewById(R.id.image_content)?.let { - Glide.with(it) - .load(image) - .crossfade() - .into(it); - } - findViewById(R.id.text_content)?.let { - if(caption?.isNullOrEmpty() == true) - it.isVisible = false; - else - it.text = caption; - } - } - } - class ArticleContentBlock: LinearLayout { - constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) { - if(content != null) { - var view: View? = null; - if(content is IPlatformNestedContent) { - view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null); - view.bind(content); - view.onContentUrlClicked.subscribe { a,b -> } - } - else if(content is IPlatformVideo) { - view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true); - view.bind(content); - view.onVideoClicked.subscribe { a,b -> fragment?.navigate(a) } - view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } - if(overlayContainer != null) { - view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) }; - } - view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) } - view.onAddToWatchLaterClicked.subscribe { a -> - if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)) - UIDialogs.toast("Added to watch later\n[${content.name}]") - else - UIDialogs.toast(context.getString(R.string.already_in_watch_later)) - } - } - else if(content is IPlatformPost) { - view = PreviewPostView(context, FeedStyle.THUMBNAIL); - view.bind(content); - view.onContentClicked.subscribe { a -> fragment?.navigate(a) } - view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } - } - else if(content is IPlatformArticle) { - view = PreviewPostView(context, FeedStyle.THUMBNAIL); - view.bind(content); - view.onContentClicked.subscribe { a -> fragment?.navigate(a) } - view.onChannelClicked.subscribe { a -> fragment?.navigate(a) } - } - else if(content is IPlatformLockedContent) { - view = PreviewLockedView(context, FeedStyle.THUMBNAIL); - view.bind(content); - } - if(view != null) - addView(view); - } - } - } - - - companion object { - const val TAG = "PostDetailFragment" - } - } - - companion object { - fun newInstance() = ArticleDetailFragment().apply {} - } -} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt index eaa84e3e..e79e0c13 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/BuyFragment.kt @@ -1,8 +1,6 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -68,7 +66,8 @@ class BuyFragment : MainFragment() { _paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception -> if(success) { - UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY)); + UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, + UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY)); _fragment.close(true); } else { @@ -116,14 +115,11 @@ class BuyFragment : MainFragment() { val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license)); val productLicenseDialog = SlideUpMenuOverlay(context, findViewById(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput); productLicenseDialog.onOK.subscribe { - licenseInput.deactivate(); val licenseText = licenseInput.text; if (licenseText.isNullOrEmpty()) { UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key)); return@subscribe; } - licenseInput.clear(); - productLicenseDialog.hide(true); _fragment.lifecycleScope.launch(Dispatchers.IO) { @@ -131,18 +127,17 @@ class BuyFragment : MainFragment() { val activationResult = StatePayment.instance.setPaymentLicense(licenseText); withContext(Dispatchers.Main) { - try { - if(activationResult) { - UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)) { - _fragment.close(true) - } - } - else - { - UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key)); - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to update UI after buy complete", e) + if(activationResult) { + licenseInput.deactivate(); + licenseInput.clear(); + productLicenseDialog.hide(true); + + UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)); + _fragment.close(true); + } + else + { + UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key)); } } } @@ -163,6 +158,5 @@ class BuyFragment : MainFragment() { companion object { fun newInstance() = BuyFragment().apply {} - private val TAG = "BuyFragment" } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 91e6aaa3..6be65482 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -47,7 +47,6 @@ import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists -import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.views.adapters.ChannelTab @@ -136,8 +135,6 @@ class ChannelFragment : MainFragment() { inflater.inflate(R.layout.fragment_channel, this) _taskLoadPolycentricProfile = TaskHandler({ fragment.lifecycleScope }, { id -> - if (!StatePolycentric.instance.enabled) - return@TaskHandler null return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!) }).success { setPolycentricProfile(it, animate = true) }.exception { Logger.w(TAG, "Failed to load polycentric profile.", it) @@ -172,7 +169,7 @@ class ChannelFragment : MainFragment() { _buttonSubscribe = findViewById(R.id.button_subscribe) _buttonSubscriptionSettings = findViewById(R.id.button_sub_settings) _overlayLoading = findViewById(R.id.channel_loading_overlay) - _overlayLoadingSpinner = findViewById(R.id.channel_loader_frag) + _overlayLoadingSpinner = findViewById(R.id.channel_loader) _overlayContainer = findViewById(R.id.overlay_container) _buttonSubscribe.onSubscribed.subscribe { UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer) @@ -211,14 +208,6 @@ class ChannelFragment : MainFragment() { } } } - adapter.onShortClicked.subscribe { v, _, pagerPair -> - when (v) { - is IPlatformVideo -> { - StatePlayer.instance.clearQueue() - fragment.navigate(Triple(v, pagerPair!!.first, pagerPair.second)) - } - } - } adapter.onAddToClicked.subscribe { content -> _overlayContainer.let { if (content is IPlatformVideo) _slideUpOverlay = @@ -234,8 +223,6 @@ class ChannelFragment : MainFragment() { if (content is IPlatformVideo) { if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)) UIDialogs.toast("Added to watch later\n[${content.name}]") - else - UIDialogs.toast(context.getString(R.string.already_in_watch_later)) } } adapter.onUrlClicked.subscribe { url -> @@ -435,15 +422,17 @@ class ChannelFragment : MainFragment() { _fragment.lifecycleScope.launch(Dispatchers.IO) { val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) withContext(Dispatchers.Main) { - buttons.add(Pair(R.drawable.ic_search) { - _fragment.navigate( - SuggestionsFragmentData( - "", SearchType.VIDEO + if (plugin != null && plugin.capabilities.hasSearchChannelContents) { + buttons.add(Pair(R.drawable.ic_search) { + _fragment.navigate( + SuggestionsFragmentData( + "", SearchType.VIDEO, channel.url + ) ) - ) - }) - _fragment.topBar?.assume()?.setMenuItems(buttons) + }) + _fragment.topBar?.assume()?.setMenuItems(buttons) + } if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) { if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index cb7261e5..4390a80c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -4,23 +4,19 @@ import android.content.Context import android.util.Log import android.view.LayoutInflater import android.widget.LinearLayout -import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays -import com.futo.platformplayer.api.media.models.article.IPlatformArticle import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo -import com.futo.platformplayer.api.media.platforms.js.models.JSWeb import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlayer @@ -36,9 +32,6 @@ import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.withTimestamp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlin.math.floor import kotlin.math.max @@ -64,7 +57,7 @@ abstract class ContentFeedView : FeedView state.muted = true }; _exoPlayer = player; - return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply { + return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply { attachAdapterEvents(this); } } @@ -91,8 +84,6 @@ abstract class ContentFeedView : FeedView : FeedView(content); } else if (content is IPlatformPost) { fragment.navigate(content); - } else if(content is IPlatformArticle) { - fragment.navigate(content); } - else if(content is JSWeb) { - fragment.navigate(content); - } - else - UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]"); } protected open fun onContentUrlClicked(url: String, contentType: ContentType) { when(contentType) { ContentType.MEDIA -> { - StatePlayer.instance.clearQueue() - fragment.navigate(url).maximizeVideoDetail() - } - ContentType.PLAYLIST -> fragment.navigate(url) - ContentType.URL -> fragment.navigate(url) - ContentType.CHANNEL -> fragment.navigate(url) + StatePlayer.instance.clearQueue(); + fragment.navigate(url).maximizeVideoDetail(); + }; + ContentType.PLAYLIST -> fragment.navigate(url); + ContentType.URL -> fragment.navigate(url); else -> {}; } } @@ -251,15 +234,8 @@ abstract class ContentFeedView : FeedView> = hashMapOf(); private var _enabledClientIds: List? = null; - private var _searchType: SearchType? = null; + private var _channelUrl: String? = null; private val _taskSearch: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar @@ -97,12 +91,11 @@ class ContentSearchResultsFragment : MainFragment() { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { _taskSearch = TaskHandler>({fragment.lifecycleScope}, { query -> Logger.i(TAG, "Searching for: $query") - when (_searchType) - { - SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) - SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query) - SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query) - else -> throw Exception("Search type must be specified") + val channelUrl = _channelUrl; + if (channelUrl != null) { + StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) + } else { + StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) } }) .success { loadedResult(it); }.exception { } @@ -112,25 +105,6 @@ class ContentSearchResultsFragment : MainFragment() { } setPreviewsEnabled(Settings.instance.search.previewFeedItems); - - initializeToolbar(); - } - - fun initializeToolbar(){ - if(_toolbarContentView.allViews.any { it is RadioGroupView }) - _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView }); - - val radioGroup = RadioGroupView(context); - radioGroup.onSelectedChange.subscribe { - - if (it.size != 1) - setSearchType(SearchType.VIDEO); - else - setSearchType((it[0] ?: SearchType.VIDEO) as SearchType); - } - radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) - - _toolbarContentView.addView(radioGroup); } override fun cleanup() { @@ -141,7 +115,7 @@ class ContentSearchResultsFragment : MainFragment() { fun onShown(parameter: Any?) { if(parameter is SuggestionsFragmentData) { setQuery(parameter.query, false); - setSearchType(parameter.searchType, false) + setChannelUrl(parameter.channelUrl, false); fragment.topBar?.apply { if (this is SearchTopBarFragment) { @@ -157,7 +131,7 @@ class ContentSearchResultsFragment : MainFragment() { onFilterClick.subscribe(this) { _overlayContainer.let { val filterValuesCopy = HashMap(_filterValues); - val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy); + val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null); filtersOverlay.onOK.subscribe { enabledClientIds, changed -> if (changed) { setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); @@ -204,7 +178,11 @@ class ContentSearchResultsFragment : MainFragment() { fragment.lifecycleScope.launch(Dispatchers.IO) { try { - val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); + val commonCapabilities = + if(_channelUrl == null) + StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); + else + StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id }); val sorts = commonCapabilities?.sorts ?: listOf(); if (sorts.size > 1) { withContext(Dispatchers.Main) { @@ -271,8 +249,8 @@ class ContentSearchResultsFragment : MainFragment() { } } - private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) { - _searchType = searchType + private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) { + _channelUrl = channelUrl; if (updateResults) { clearResults(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt index 345d577e..54649ebf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt @@ -16,8 +16,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R import com.futo.platformplayer.UISlideOverlays -import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.adapters.SubscriptionAdapter class CreatorsFragment : MainFragment() { @@ -31,8 +29,6 @@ class CreatorsFragment : MainFragment() { private var _editSearch: EditText? = null; private var _textMeta: TextView? = null; private var _buttonClearSearch: ImageButton? = null - private var _ordering = FragmentedStorage.get("creators_ordering") - override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val view = inflater.inflate(R.layout.fragment_creators, container, false); @@ -48,7 +44,7 @@ class CreatorsFragment : MainFragment() { _buttonClearSearch?.visibility = View.INVISIBLE; } - val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription), _ordering?.value?.toIntOrNull() ?: 5) { subs -> + val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs -> _textMeta?.let { it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}"; } @@ -65,7 +61,6 @@ class CreatorsFragment : MainFragment() { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { adapter.sortBy = pos; - _ordering.setAndSave(pos.toString()) } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index 0e429430..217165ae 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -14,14 +14,10 @@ import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R -import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist -import com.futo.platformplayer.services.DownloadService -import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists @@ -58,15 +54,6 @@ class DownloadsFragment : MainFragment() { super.onResume() _view?.reloadUI(); - if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } && - !StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } && - Settings.instance.downloads.shouldDownload()) { - Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service"); - StateApp.withContext { - DownloadService.getOrCreateService(it); - } - } - StateDownloads.instance.onDownloadsChanged.subscribe(this) { lifecycleScope.launch(Dispatchers.Main) { try { @@ -150,7 +137,7 @@ class DownloadsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc"); + val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { @@ -160,8 +147,6 @@ class DownloadsFragment : MainFragment() { 3 -> ordering.setAndSave("downloadDateDesc") 4 -> ordering.setAndSave("releasedAsc") 5 -> ordering.setAndSave("releasedDesc") - 6 -> ordering.setAndSave("sizeAsc") - 7 -> ordering.setAndSave("sizeDesc") else -> ordering.setAndSave("") } updateContentFilters() @@ -259,8 +244,6 @@ class DownloadsFragment : MainFragment() { "nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() } "releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX } "releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN } - "sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } } - "sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } } else -> vidsToReturn } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index a9ea33b2..3c915ebe 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -136,6 +136,7 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) it.nextPageAsync(); else diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt index 80bd08cf..2da46620 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt @@ -15,8 +15,6 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* -import com.futo.platformplayer.api.media.models.video.IPlatformVideo -import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.TaskHandler @@ -24,14 +22,10 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateHistory -import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer -import com.futo.platformplayer.states.StatePlugins -import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.adapters.HistoryListViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.others.TagsView -import com.futo.platformplayer.views.others.Toggle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -74,8 +68,6 @@ class HistoryFragment : MainFragment() { private var _pager: IPager? = null; private val _results = arrayListOf(); private var _loading = false; - private val _toggleBar: ToggleBar - private var _togglePluginsDisabled = hashSetOf() private var _automaticNextPageCounter = 0; @@ -87,7 +79,6 @@ class HistoryFragment : MainFragment() { _clearSearch = findViewById(R.id.button_clear_search); _editSearch = findViewById(R.id.edit_search); _tagsView = findViewById(R.id.tags_text); - _toggleBar = findViewById(R.id.toggle_bar) _tagsView.setPairs(listOf( Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_24_hours), 24L * 60L), @@ -97,22 +88,6 @@ class HistoryFragment : MainFragment() { Pair(context.getString(R.string.all_time), -1L) )); - val toggles = StatePlatform.instance.getEnabledClients() - .filter { it is JSClient } - .map { plugin -> - val pluginName = plugin.name.lowercase() - ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active -> - if (active) { - _togglePluginsDisabled.remove(plugin.id) - } else { - _togglePluginsDisabled.add(plugin.id) - } - - filtersChanged() - }).withTag("plugins") - }.toTypedArray() - _toggleBar.setToggles(*toggles) - _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), { _results.size }, { view, _ -> @@ -187,15 +162,14 @@ class HistoryFragment : MainFragment() { else it.nextPage(); - return@TaskHandler filterResults(it.getResults()); + return@TaskHandler it.getResults(); }).success { setLoading(false); val posBefore = _results.size; - val res = filterResults(it) - _results.addAll(res); - _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size); - ensureEnoughContentVisible(res) + _results.addAll(it); + _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size); + ensureEnoughContentVisible(it) }.exception { Logger.w(TAG, "Failed to load next page.", it); UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { @@ -204,10 +178,6 @@ class HistoryFragment : MainFragment() { }; } - private fun filtersChanged() { - updatePager() - } - private fun updatePager() { val query = _editSearch.text.toString(); if (_editSearch.text.isNotEmpty()) { @@ -276,22 +246,11 @@ class HistoryFragment : MainFragment() { _adapter.setLoading(loading); } - private fun filterResults(a: List): List { - val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet() - val disabledPluginIds = _togglePluginsDisabled.toHashSet() - return a.filter { - val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false - if (!enabledPluginIds.contains(pluginId)) - return@filter false - return@filter !disabledPluginIds.contains(pluginId) - }; - } - private fun loadPagerInternal(pager: IPager) { Logger.i(TAG, "Setting new internal pager on feed"); _results.clear(); - val toAdd = filterResults(pager.getResults()) + val toAdd = pager.getResults(); _results.addAll(toAdd); _adapter.notifyDataSetChanged(); ensureEnoughContentVisible(toAdd) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index 1e90e36e..c56585b0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.* import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.constructs.TaskHandler @@ -164,22 +165,12 @@ class PlaylistFragment : MainFragment() { }; } - private fun savePlaylist(playlist: Playlist) { - StatePlaylists.instance.playlistStore.save(playlist) - UIDialogs.toast("Playlist saved") - } - private fun copyPlaylist(playlist: Playlist) { - var copyNumber = 1 - var newName = "${playlist.name} (Copy)" - val playlists = StatePlaylists.instance.playlistStore.getItems() - while (playlists.any { it.name == newName }) { - copyNumber += 1 - newName = "${playlist.name} (Copy $copyNumber)" - } - StatePlaylists.instance.playlistStore.save(playlist.makeCopy(newName)) - _fragment.navigate(withHistory = false) - UIDialogs.toast("Playlist copied") + StatePlaylists.instance.playlistStore.save(playlist) + _fragment.topBar?.assume()?.setMenuItems( + arrayListOf() + ) + UIDialogs.toast("Playlist saved") } fun onShown(parameter: Any?) { @@ -197,14 +188,12 @@ class PlaylistFragment : MainFragment() { setButtonExportVisible(false) setButtonEditVisible(true) - _fragment.topBar?.assume() - ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { - if (StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { + if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { + _fragment.topBar?.assume() + ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { copyPlaylist(parameter) - } else { - savePlaylist(parameter) - } - })) + })) + } } else { setName(null) setVideos(null, false) @@ -270,7 +259,7 @@ class PlaylistFragment : MainFragment() { val playlist = _playlist ?: return if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) { UIDialogs.showConfirmationDialog(context, "Playlist must be saved to download", { - savePlaylist(playlist) + copyPlaylist(playlist) download() }) return @@ -303,7 +292,7 @@ class PlaylistFragment : MainFragment() { val playlist = _playlist ?: return if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) { UIDialogs.showConfirmationDialog(context, "Playlist must be saved to edit the name", { - savePlaylist(playlist) + copyPlaylist(playlist) onEditClick() }) return diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index 5510ccbf..58caabe1 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() { var playlistsToReturn = pls; if(!_listPlaylistsSearch.text.isNullOrEmpty()) playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; - if(!_ordering.value.isNullOrEmpty()) { + if(!_ordering.value.isNullOrEmpty()){ playlistsToReturn = when(_ordering.value){ "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index 896e975e..d4dc1672 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -168,12 +168,7 @@ class PostDetailFragment : MainFragment { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { - if (!StatePolycentric.instance.enabled) - return@TaskHandler null - - ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) - }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); @@ -332,10 +327,6 @@ class PostDetailFragment : MainFragment { val version = _version; _rating.onLikeDislikeUpdated.remove(this); - - if (!StatePolycentric.instance.enabled) - return - _fragment.lifecycleScope.launch(Dispatchers.IO) { if (version != _version) { return@launch; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt deleted file mode 100644 index 478c2732..00000000 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt +++ /dev/null @@ -1,1283 +0,0 @@ -package com.futo.platformplayer.fragment.mainactivity.main - -import android.app.Dialog -import android.content.Context -import android.content.DialogInterface -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.drawable.Animatable -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.text.Spanned -import android.util.AttributeSet -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.SoundEffectConstants -import android.view.View -import android.view.animation.AccelerateInterpolator -import android.view.animation.OvershootInterpolator -import android.widget.Button -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.graphics.drawable.toDrawable -import androidx.core.net.toUri -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.util.UnstableApi -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition -import com.futo.platformplayer.R -import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException -import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException -import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink -import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment -import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes -import com.futo.platformplayer.api.media.models.ratings.RatingLikes -import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor -import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource -import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource -import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource -import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource -import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource -import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource -import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource -import com.futo.platformplayer.api.media.models.video.IPlatformVideo -import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails -import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.casting.CastConnectionState -import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.constructs.Event0 -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event3 -import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.downloads.VideoLocal -import com.futo.platformplayer.dp -import com.futo.platformplayer.engine.exceptions.ScriptAgeException -import com.futo.platformplayer.engine.exceptions.ScriptException -import com.futo.platformplayer.engine.exceptions.ScriptImplementationException -import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException -import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException -import com.futo.platformplayer.exceptions.UnsupportedCastException -import com.futo.platformplayer.fixHtmlLinks -import com.futo.platformplayer.getNowDiffSeconds -import com.futo.platformplayer.helpers.VideoHelper -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.selectBestImage -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateMeta -import com.futo.platformplayer.states.StatePlatform -import com.futo.platformplayer.states.StatePlugins -import com.futo.platformplayer.states.StatePolycentric -import com.futo.platformplayer.toHumanBitrate -import com.futo.platformplayer.toHumanBytesSize -import com.futo.platformplayer.toHumanNowDiffString -import com.futo.platformplayer.toHumanNumber -import com.futo.platformplayer.views.MonetizationView -import com.futo.platformplayer.views.comments.AddCommentView -import com.futo.platformplayer.views.others.CreatorThumbnail -import com.futo.platformplayer.views.overlays.DescriptionOverlay -import com.futo.platformplayer.views.overlays.RepliesOverlay -import com.futo.platformplayer.views.overlays.SupportOverlay -import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList -import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup -import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem -import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay -import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle -import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs -import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.segments.CommentsList -import com.futo.platformplayer.views.video.FutoShortPlayer -import com.futo.platformplayer.views.video.FutoVideoPlayerBase -import com.futo.polycentric.core.ApiMethods -import com.futo.polycentric.core.ContentType -import com.futo.polycentric.core.Models -import com.futo.polycentric.core.Opinion -import com.futo.polycentric.core.PolycentricProfile -import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions -import com.futo.polycentric.core.toURLInfoSystemLinkUrl -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.button.MaterialButton -import com.google.protobuf.ByteString -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import userpackage.Protocol - -@UnstableApi -class ShortView : FrameLayout { - private lateinit var mainFragment: MainFragment - private val player: FutoShortPlayer - - private val channelInfo: LinearLayout - private val creatorThumbnail: CreatorThumbnail - private val channelName: TextView - private val videoTitle: TextView - private val platformIndicator: PlatformIndicator - - private val backButton: MaterialButton - private val backButtonContainer: ConstraintLayout - - private val likeContainer: FrameLayout - private val dislikeContainer: FrameLayout - private val likeButton: MaterialButton - private val likeCount: TextView - private val dislikeButton: MaterialButton - private val dislikeCount: TextView - - private val commentsButton: MaterialButton - private val shareButton: MaterialButton - private val refreshButton: MaterialButton - private val refreshButtonContainer: View - private val qualityButton: MaterialButton - - private val playPauseOverlay: FrameLayout - private val playPauseIcon: ImageView - - private val overlayLoading: FrameLayout - private val overlayLoadingSpinner: ImageView - private lateinit var overlayQualityContainer: FrameLayout - - private var overlayQualitySelector: SlideUpMenuOverlay? = null - - private var video: IPlatformVideo? = null - set(value) { - field = value - onVideoUpdated.emit(value) - } - private var videoDetails: IPlatformVideoDetails? = null - - private var playWhenReady = false - - private var _lastVideoSource: IVideoSource? = null - private var _lastAudioSource: IAudioSource? = null - private var _lastSubtitleSource: ISubtitleSource? = null - - private var loadVideoTask: TaskHandler? = null - private var loadLikesTask: TaskHandler>? = - null - - val onResetTriggered = Event0() - private val onPlayingToggled = Event1() - private val onLikesLoaded = Event3() - private val onLikeDislikeUpdated = Event1() - private val onVideoUpdated = Event1() - - private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() - - var likes: Long = 0 - set(value) { - field = value - likeCount.text = value.toString() - } - - var dislikes: Long = 0 - set(value) { - field = value - dislikeCount.text = value.toString() - } - - constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) { - this.overlayQualityContainer = overlayQualityContainer - - layoutParams = LayoutParams( - LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT - ) - - this.mainFragment = fragment - bottomSheet.mainFragment = fragment - } - - // Required constructor for XML inflation - constructor(context: Context) : this(context, null, null) - - // Required constructor for XML inflation with attributes - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null) - - // Required constructor for XML inflation with attributes and style - constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super( - context, attrs, defStyleAttr ?: 0 - ) { - // Inflate the layout once here - inflate(context, R.layout.view_short, this) - - // Initialize all val properties using findViewById - player = findViewById(R.id.short_player) - channelInfo = findViewById(R.id.channel_info) - creatorThumbnail = findViewById(R.id.creator_thumbnail) - channelName = findViewById(R.id.channel_name) - videoTitle = findViewById(R.id.video_title) - platformIndicator = findViewById(R.id.short_platform_indicator) - backButton = findViewById(R.id.back_button) - backButtonContainer = findViewById(R.id.back_button_container) - likeContainer = findViewById(R.id.like_container) - dislikeContainer = findViewById(R.id.dislike_container) - likeButton = findViewById(R.id.like_button) - likeCount = findViewById(R.id.like_count) - dislikeButton = findViewById(R.id.dislike_button) - dislikeCount = findViewById(R.id.dislike_count) - commentsButton = findViewById(R.id.comments_button) - shareButton = findViewById(R.id.share_button) - refreshButton = findViewById(R.id.refresh_button) - refreshButtonContainer = findViewById(R.id.refresh_button_container) - qualityButton = findViewById(R.id.quality_button) - playPauseOverlay = findViewById(R.id.play_pause_overlay) - playPauseIcon = findViewById(R.id.play_pause_icon) - overlayLoading = findViewById(R.id.short_view_loading_overlay) - overlayLoadingSpinner = findViewById(R.id.short_view_loader) - - player.setOnClickListener { - if (player.activelyPlaying) { - player.pause() - onPlayingToggled.emit(false) - } else { - player.play() - onPlayingToggled.emit(true) - } - } - - onPlayingToggled.subscribe { playing -> - if (playing) { - playPauseIcon.setImageResource(R.drawable.ic_play) - playPauseIcon.contentDescription = context.getString(R.string.play) - } else { - playPauseIcon.setImageResource(R.drawable.ic_pause) - playPauseIcon.contentDescription = context.getString(R.string.pause) - } - showPlayPauseIcon() - } - - onVideoUpdated.subscribe { - videoTitle.text = it?.name - platformIndicator.setPlatformFromClientID(it?.id?.pluginId) - creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) - channelName.text = it?.author?.name - } - - backButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - mainFragment.closeSegment() - } - - channelInfo.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - mainFragment.navigate(video?.author) - } - - videoTitle.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - if (!bottomSheet.isAdded) { - bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) - } - } - - commentsButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - if (!bottomSheet.isAdded) { - bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) - } - } - - shareButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - val url = video?.shareUrl ?: video?.url - mainFragment.startActivity(Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, url) - type = "text/plain" - }, null)) - } - - refreshButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - onResetTriggered.emit() - } - - refreshButton.setOnLongClickListener { - UIDialogs.toast(context, "Reload all platform shorts pagers") - false - } - - qualityButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - showVideoSettings() - } - - likeButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - val checked = !likeButton.isChecked - StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { - if (checked) { - likes++ - } else { - likes-- - } - - likeButton.isChecked = checked - - if (dislikeButton.isChecked && checked) { - dislikeButton.isChecked = false - dislikes-- - } - - onLikeDislikeUpdated.emit( - OnLikeDislikeUpdatedArgs( - it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked - ) - ) - } - } - - dislikeButton.setOnClickListener { - playSoundEffect(SoundEffectConstants.CLICK) - val checked = !dislikeButton.isChecked - StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { - if (checked) { - dislikes++ - } else { - dislikes-- - } - - dislikeButton.isChecked = checked - - if (likeButton.isChecked && checked) { - likeButton.isChecked = false - likes-- - } - - onLikeDislikeUpdated.emit( - OnLikeDislikeUpdatedArgs( - it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked - ) - ) - } - } - - onLikesLoaded.subscribe(tag) { rating, liked, disliked -> - likes = rating.likes - dislikes = rating.dislikes - likeButton.isChecked = liked - dislikeButton.isChecked = disliked - - dislikeContainer.visibility = VISIBLE - likeContainer.visibility = VISIBLE - } - - player.onPlaybackStateChanged.subscribe { - val videoSource = _lastVideoSource - - if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { - val videoTracks = - player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } - val audioTracks = - player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO } - - val videoTrackFormats = mutableListOf() - val audioTrackFormats = mutableListOf() - - if (videoTracks != null) { - for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i)) - } - if (audioTracks != null) { - for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i)) - } - - updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height } - .sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate } - .sortedBy { it.bitrate }) - } else { - updateQualitySourcesOverlay(videoDetails, null) - } - } - } - - private fun showPlayPauseIcon() { - val overlay = playPauseOverlay - - overlay.alpha = 0f - overlay.scaleX = 0f - overlay.scaleY = 0f - overlay.visibility = VISIBLE - - overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400) - .setInterpolator(OvershootInterpolator(1.2f)).start() - - overlay.postDelayed({ - hidePlayPauseIcon() - }, 1500) - } - - private fun hidePlayPauseIcon() { - val overlay = playPauseOverlay - - overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300) - .setInterpolator(AccelerateInterpolator()).withEndAction { - overlay.visibility = GONE - }.start() - } - - // TODO merge this with the updateQualitySourcesOverlay for the normal video player - @androidx.annotation.OptIn(UnstableApi::class) - private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { - Logger.i(TAG, "updateQualitySourcesOverlay") - - val video: IPlatformVideoDetails? - val localVideoSources: List? - val localAudioSource: List? - val localSubtitleSources: List? - - val videoSources: List? - val audioSources: List? - - if (videoDetails is VideoLocal) { - video = videoLocal?.videoSerialized - localVideoSources = videoDetails.videoSource.toList() - localAudioSource = videoDetails.audioSource.toList() - localSubtitleSources = videoDetails.subtitlesSources.toList() - videoSources = null - audioSources = null - } else { - video = videoDetails - videoSources = video?.video?.videoSources?.toList() - audioSources = - if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() - else null - if (videoLocal != null) { - localVideoSources = videoLocal.videoSource.toList() - localAudioSource = videoLocal.audioSource.toList() - localSubtitleSources = videoLocal.subtitlesSources.toList() - } else { - localVideoSources = null - localAudioSource = null - localSubtitleSources = null - } - } - - val doDedup = Settings.instance.playback.simplifySources - - val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct() - ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } - ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct() - ?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf() - val bestAudioContainer = - audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container } - val bestAudioSources = - if (doDedup) audioSources?.filter { it.container == bestAudioContainer } - ?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource }) - ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf() - - val canSetSpeed = true - val currentPlaybackRate = player.getPlaybackRate() - overlayQualitySelector = - SlideUpMenuOverlay( - this.context, overlayQualityContainer, context.getString( - R.string.quality - ), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { - setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString()) - onClick.subscribe { v -> - - player.setPlaybackRate(v.toFloat()) - setSelected(v) - - } - } else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { - SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }) - }.toList().toTypedArray() - ) - else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { - SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }) - }.toList().toTypedArray() - ) - else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { - SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) - }.toList().toTypedArray() - ) - else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( - this.context, context.getString(R.string.stream_video), "video", (listOf( - SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) - ) + (liveStreamVideoFormats.map { - SlideUpMenuItem( - this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType - ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }) - })) - ) - else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( - this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { - SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }) - }.toList().toTypedArray() - ) - else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( - this.context, context.getString(R.string.video), "video", *bestVideoSources.map { - val estSize = VideoHelper.estimateSourceSize(it) - val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" - SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }) - }.toList().toTypedArray() - ) - else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( - this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { - val estSize = VideoHelper.estimateSourceSize(it) - val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" - SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }) - }.toList().toTypedArray() - ) - else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( - this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { - SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) - }.toList().toTypedArray() - ) - else null - ) - } - - private fun handleSelectVideoTrack(videoSource: IVideoSource) { - Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") - if (_lastVideoSource == videoSource) return - - _lastVideoSource = videoSource - - playVideo(player.position) - } - - private fun handleSelectAudioTrack(audioSource: IAudioSource) { - Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") - if (_lastAudioSource == audioSource) return - - _lastAudioSource = audioSource - - playVideo(player.position) - } - - private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { - Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") - var toSet: ISubtitleSource? = subtitleSource - if (_lastSubtitleSource == subtitleSource) toSet = null - - mainFragment.lifecycleScope.launch(Dispatchers.Main) { - try { - player.swapSubtitles(toSet) - } catch (e: Throwable) { - Logger.e(TAG, "handleSelectSubtitleTrack failed", e) - } - } - - _lastSubtitleSource = toSet - } - - private fun showVideoSettings() { - Logger.i(TAG, "showVideoSettings") - - overlayQualitySelector?.selectOption("video", _lastVideoSource) - overlayQualitySelector?.selectOption("audio", _lastAudioSource) - overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource) - - if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) { - val videoTracks = - player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } - - var selectedQuality: Format? = null - - if (videoTracks != null) { - for (i in 0 until videoTracks.mediaTrackGroup.length) { - if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) { - selectedQuality = videoTracks.mediaTrackGroup.getFormat(i) - } - } - } - - var videoMenuGroup: SlideUpMenuGroup? = null - for (view in overlayQualitySelector!!.groupItems) { - if (view is SlideUpMenuGroup && view.groupTag == "video") { - videoMenuGroup = view - } - } - - if (selectedQuality != null) { - videoMenuGroup?.getItem("auto")?.setSubText("") - overlayQualitySelector?.selectOption("video", selectedQuality) - } else { - videoMenuGroup?.getItem("auto") - ?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}") - overlayQualitySelector?.selectOption("video", "auto") - } - } - - val currentPlaybackRate = player.getPlaybackRate() - overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" } - ?.let { - (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) - } - - overlayQualitySelector?.show() - } - - @Suppress("unused") - fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { - this.mainFragment = fragment - this.bottomSheet.mainFragment = fragment - this.overlayQualityContainer = overlayQualityContainer - } - - fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) { - if (this.video?.url == video.url) { - return - } - this.video = video - - refreshButtonContainer.visibility = if (isChannelShortsMode) { - GONE - } else { - VISIBLE - } - backButtonContainer.visibility = if (isChannelShortsMode) { - VISIBLE - } else { - GONE - } - - loadVideo(video.url) - } - - @Suppress("unused") - fun changeVideo(videoDetails: IPlatformVideoDetails) { - if (video?.url == videoDetails.url) { - return - } - - this.video = videoDetails - this.videoDetails = videoDetails - } - - fun play() { - loadLikes(this.video!!) - player.clear() - player.attach() - player.clear() - playVideo() - } - - fun pause() { - player.pause() - } - - fun stop() { - playWhenReady = false - - player.clear() - player.detach() - } - - fun cancel() { - loadVideoTask?.cancel() - loadLikesTask?.cancel() - } - - private fun setLoading(isLoading: Boolean) { - if (isLoading) { - (overlayLoadingSpinner.drawable as Animatable?)?.start() - overlayLoading.visibility = VISIBLE - } else { - overlayLoading.visibility = GONE - (overlayLoadingSpinner.drawable as Animatable?)?.stop() - } - } - - private fun loadLikes(video: IPlatformVideo) { - likeContainer.visibility = GONE - dislikeContainer.visibility = GONE - - loadLikesTask?.cancel() - loadLikesTask = - TaskHandler>( - StateApp.instance.scopeGetter, { - val ref = Models.referenceFromBuffer(video.url.toByteArray()) - val extraBytesRef = - video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } - - val queryReferencesResponse = ApiMethods.getQueryReferences( - ApiMethods.SERVER, ref, null, null, arrayListOf( - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.like.data) - ) - .build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.dislike.data) - ).build() - ), extraByteReferences = listOfNotNull(extraBytesRef) - ) - - Pair(ref, queryReferencesResponse) - }).success { (ref, queryReferencesResponse) -> - val likes = queryReferencesResponse.countsList[0] - val dislikes = queryReferencesResponse.countsList[1] - val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) - val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) - onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) - onLikeDislikeUpdated.subscribe(this) { args -> - if (args.hasLiked) { - args.processHandle.opinion(ref, Opinion.like) - } else if (args.hasDisliked) { - args.processHandle.opinion(ref, Opinion.dislike) - } else { - args.processHandle.opinion(ref, Opinion.neutral) - } - - mainFragment.lifecycleScope.launch(Dispatchers.IO) { - try { - Logger.i(CommentsModalBottomSheet.TAG, "Started backfill") - args.processHandle.fullyBackfillServersAnnounceExceptions() - Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill") - } catch (e: Throwable) { - Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e) - } - } - - StatePolycentric.instance.updateLikeMap( - ref, args.hasLiked, args.hasDisliked - ) - } - } - - loadLikesTask?.run(video) - } - - private fun loadVideo(url: String) { - loadVideoTask?.cancel() - videoDetails = null - _lastVideoSource = null - _lastAudioSource = null - _lastSubtitleSource = null - - setLoading(true) - - loadVideoTask = TaskHandler( - StateApp.instance.scopeGetter, { - val result = StatePlatform.instance.getContentDetails(it).await() - if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") - return@TaskHandler result - }).success { result -> - videoDetails = result - video = result - - bottomSheet.video = result - - setLoading(false) - - if (playWhenReady) playVideo() - }.exception { - Logger.w(TAG, "exception", it) - UIDialogs.showDialog( - context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) - ) - }.exception { e -> - Logger.w(TAG, "exception", e) - UIDialogs.showDialog( - context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { - val id = e.config.let { if (it is SourcePluginConfig) it.id else null } - val didLogin = - if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { - loadVideo(url) - } - if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") - }, UIDialogs.ActionStyle.PRIMARY) - ) - }.exception { - Logger.w(TAG, "exception", it) - UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } - }.exception { - Logger.w(TAG, "exception", it) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) - }.exception { - Logger.w(TAG, "exception", it) - UIDialogs.showDialog( - context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) - ) - }.exception { - Logger.w(TAG, "exception", it) - UIDialogs.showDialog( - context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) - ) - }.exception { - Logger.w(TAG, "exception", it) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) - }.exception { - Logger.w(ChannelFragment.TAG, "Failed to load video.", it) - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) - } - - loadVideoTask?.run(url) - } - - private fun playVideo(resumePositionMs: Long = 0) { - val videoDetails = this@ShortView.videoDetails - - if (videoDetails === null) { - playWhenReady = true - return - } - - updateQualitySourcesOverlay(videoDetails, null) - - try { - val videoSource = _lastVideoSource - ?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) - val audioSource = _lastAudioSource - ?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context)) - val subtitleSource = _lastSubtitleSource - ?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null) - Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") - - if (videoSource == null && audioSource == null) { - UIDialogs.showDialog( - context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) - ) - StatePlatform.instance.clearContentDetailCache(videoDetails.url) - return - } - - val thumbnail = videoDetails.thumbnails.getHQThumbnail() - if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() - .load(thumbnail).into(object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - player.setArtwork(resource.toDrawable(resources)) - } - - override fun onLoadCleared(placeholder: Drawable?) { - player.setArtwork(null) - } - }) - else player.setArtwork(null) - - mainFragment.lifecycleScope.launch(Dispatchers.Main) { - try { - player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) - if (subtitleSource != null) player.swapSubtitles(subtitleSource) - player.seekTo(resumePositionMs) - } catch (e: Throwable) { - Logger.e(TAG, "playVideo failed", e) - } - } - - _lastVideoSource = videoSource - _lastAudioSource = audioSource - _lastSubtitleSource = subtitleSource - } catch (ex: UnsupportedCastException) { - Logger.e(TAG, "Failed to load cast media", ex) - UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex) - } catch (ex: Throwable) { - Logger.e(TAG, "Failed to load media", ex) - UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex) - } - } - - companion object { - const val TAG = "VideoDetailView" - } - - class CommentsModalBottomSheet : BottomSheetDialogFragment() { - var mainFragment: MainFragment? = null - - private lateinit var containerContent: FrameLayout - private lateinit var containerContentMain: LinearLayout - private lateinit var containerContentReplies: RepliesOverlay - private lateinit var containerContentDescription: DescriptionOverlay - private lateinit var containerContentSupport: SupportOverlay - - private lateinit var title: TextView - private lateinit var subTitle: TextView - private lateinit var channelName: TextView - private lateinit var channelMeta: TextView - private lateinit var creatorThumbnail: CreatorThumbnail - private lateinit var channelButton: LinearLayout - private lateinit var monetization: MonetizationView - private lateinit var platform: PlatformIndicator - private lateinit var textLikes: TextView - private lateinit var textDislikes: TextView - private lateinit var layoutRating: LinearLayout - private lateinit var imageDislikeIcon: ImageView - private lateinit var imageLikeIcon: ImageView - - private lateinit var description: TextView - private lateinit var descriptionContainer: LinearLayout - private lateinit var descriptionViewMore: TextView - - private lateinit var commentsList: CommentsList - private lateinit var addCommentView: AddCommentView - - private var polycentricProfile: PolycentricProfile? = null - - private lateinit var buttonPolycentric: Button - private lateinit var buttonPlatform: Button - - private var tabIndex: Int? = null - - private var contentOverlayView: View? = null - - lateinit var video: IPlatformVideoDetails - - private lateinit var behavior: BottomSheetBehavior - - private val _taskLoadPolycentricProfile = - TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load claims.", it) - } - - override fun onCreateDialog( - savedInstanceState: Bundle?, - ): Dialog { - val bottomSheetDialog = - BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) - bottomSheetDialog.setContentView(R.layout.modal_comments) - - behavior = bottomSheetDialog.behavior - - // TODO figure out how to not need all of these non null assertions - containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! - containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! - containerContentReplies = - bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! - containerContentDescription = - bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! - containerContentSupport = - bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! - - title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! - subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! - channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! - channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! - creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! - channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! - monetization = bottomSheetDialog.findViewById(R.id.monetization)!! - platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! - layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! - textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! - textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! - imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! - imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! - - description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! - descriptionContainer = - bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! - descriptionViewMore = - bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! - - addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! - commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! - buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! - buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! - - commentsList.onAuthorClick.subscribe { c -> - if (c !is PolycentricPlatformComment) { - return@subscribe - } - val id = c.author.id.value - - Logger.i(TAG, "onAuthorClick: $id") - if (id != null && id.startsWith("polycentric://")) { - val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) - mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri())) - } - } - commentsList.onRepliesClick.subscribe { c -> - val replyCount = c.replyCount ?: 0 - var metadata = "" - if (replyCount > 0) { - metadata += "$replyCount " + requireContext().getString(R.string.replies) - } - - if (c is PolycentricPlatformComment) { - var parentComment: PolycentricPlatformComment = c - containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { - val newComment = parentComment.cloneWithUpdatedReplyCount( - (parentComment.replyCount ?: 0) + 1 - ) - commentsList.replaceComment(parentComment, newComment) - parentComment = newComment - }) - } else { - containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) - } - animateOpenOverlayView(containerContentReplies) - } - - if (StatePolycentric.instance.enabled) { - buttonPolycentric.setOnClickListener { - setTabIndex(0) - StateMeta.instance.setLastCommentSection(0) - } - } else { - buttonPolycentric.visibility = GONE - } - - buttonPlatform.setOnClickListener { - setTabIndex(1) - StateMeta.instance.setLastCommentSection(1) - } - - val ref = Models.referenceFromBuffer(video.url.toByteArray()) - addCommentView.setContext(video.url, ref) - - if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { - setTabIndex(2, true) - } else { - when (Settings.instance.comments.defaultCommentSection) { - 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) - 1 -> setTabIndex(1, true) - 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) - } - } - - containerContentDescription.onClose.subscribe { animateCloseOverlayView() } - containerContentReplies.onClose.subscribe { animateCloseOverlayView() } - - descriptionViewMore.setOnClickListener { - animateOpenOverlayView(containerContentDescription) - } - - updateDescriptionUI(video.description.fixHtmlLinks()) - - val dp5 = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) - val dp2 = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) - - //UI - title.text = video.name - channelName.text = video.author.name - if (video.author.subscribers != null) { - channelMeta.text = if ((video.author.subscribers - ?: 0) > 0 - ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" - (channelName.layoutParams as MarginLayoutParams).setMargins( - 0, (dp5 * -1).toInt(), 0, 0 - ) - } else { - channelMeta.text = "" - (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0) - } - - video.author.let { - if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl) - else monetization.setPlatformMembership(null, null) - } - - val subTitleSegments: ArrayList = ArrayList() - if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}") - if (video.datetime != null) { - val diff = video.datetime?.getNowDiffSeconds() ?: 0 - val ago = video.datetime?.toHumanNowDiffString(true) - if (diff >= 0) subTitleSegments.add("$ago ago") - else subTitleSegments.add("available in $ago") - } - - platform.setPlatformFromClientID(video.id.pluginId) - subTitle.text = subTitleSegments.joinToString(" • ") - creatorThumbnail.setThumbnail(video.author.thumbnail, false) - - setPolycentricProfile(null, animate = false) - _taskLoadPolycentricProfile.run(video.author.id) - - when (video.rating) { - is RatingLikeDislikes -> { - val r = video.rating as RatingLikeDislikes - layoutRating.visibility = VISIBLE - - textLikes.visibility = VISIBLE - imageLikeIcon.visibility = VISIBLE - textLikes.text = r.likes.toHumanNumber() - - imageDislikeIcon.visibility = VISIBLE - textDislikes.visibility = VISIBLE - textDislikes.text = r.dislikes.toHumanNumber() - } - - is RatingLikes -> { - val r = video.rating as RatingLikes - layoutRating.visibility = VISIBLE - - textLikes.visibility = VISIBLE - imageLikeIcon.visibility = VISIBLE - textLikes.text = r.likes.toHumanNumber() - - imageDislikeIcon.visibility = GONE - textDislikes.visibility = GONE - } - - else -> { - layoutRating.visibility = GONE - } - } - - monetization.onSupportTap.subscribe { - containerContentSupport.setPolycentricProfile(polycentricProfile) - animateOpenOverlayView(containerContentSupport) - } - - monetization.onStoreTap.subscribe { - polycentricProfile?.systemState?.store?.let { - try { - val uri = it.toUri() - val intent = Intent(Intent.ACTION_VIEW) - intent.data = uri - requireContext().startActivity(intent) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to open URI: '${it}'.", e) - } - } - } - monetization.onUrlTap.subscribe { - mainFragment!!.navigate(it) - } - - addCommentView.onCommentAdded.subscribe { - commentsList.addComment(it) - } - - channelButton.setOnClickListener { - mainFragment!!.navigate(video.author) - } - - return bottomSheetDialog - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - animateCloseOverlayView() - } - - private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { - polycentricProfile = profile - - val dp35 = 35.dp(requireContext().resources) - val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } - - if (avatar != null) { - creatorThumbnail.setThumbnail(avatar, animate) - } else { - creatorThumbnail.setThumbnail(video.author.thumbnail, animate) - creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) - } - - val username = profile?.systemState?.username - if (username != null) { - channelName.text = username - } - - monetization.setPolycentricProfile(profile) - } - - private fun setTabIndex(index: Int?, forceReload: Boolean = false) { - Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") - val changed = tabIndex != index || forceReload - if (!changed) { - return - } - - tabIndex = index - buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) - buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) - - when (index) { - null -> { - addCommentView.visibility = GONE - commentsList.clear() - } - - 0 -> { - addCommentView.visibility = VISIBLE - fetchPolycentricComments() - } - - 1 -> { - addCommentView.visibility = GONE - fetchComments() - } - } - } - - private fun fetchComments() { - Logger.i(TAG, "fetchComments") - video.let { - commentsList.load(true) { StatePlatform.instance.getComments(it) } - } - } - - private fun fetchPolycentricComments() { - Logger.i(TAG, "fetchPolycentricComments") - val video = video - val idValue = video.id.value - if (video.url.isEmpty()) { - Logger.w(TAG, "Failed to fetch polycentric comments because url was null") - commentsList.clear() - return - } - - val ref = Models.referenceFromBuffer(video.url.toByteArray()) - val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } - commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } - } - - private fun updateDescriptionUI(text: Spanned) { - containerContentDescription.load(text) - description.text = text - - if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE - else descriptionContainer.visibility = GONE - } - - private fun animateOpenOverlayView(view: View) { - if (contentOverlayView != null) { - Logger.e(TAG, "Content overlay already open") - return - } - - behavior.isDraggable = false - behavior.state = BottomSheetBehavior.STATE_EXPANDED - - val animHeight = containerContentMain.height - - view.translationY = animHeight.toFloat() - view.visibility = VISIBLE - - view.animate().setDuration(300).translationY(0f).withEndAction { - contentOverlayView = view - }.start() - } - - private fun animateCloseOverlayView() { - val curView = contentOverlayView - if (curView == null) { - Logger.e(TAG, "No content overlay open") - return - } - - behavior.isDraggable = true - - val animHeight = contentOverlayView!!.height - - curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { - curView.visibility = GONE - contentOverlayView = null - }.start() - } - - companion object { - const val TAG = "ModalBottomSheet" - } - } -} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt deleted file mode 100644 index f082c91d..00000000 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt +++ /dev/null @@ -1,359 +0,0 @@ -package com.futo.platformplayer.fragment.mainactivity.main - -import android.annotation.SuppressLint -import android.graphics.drawable.Animatable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.SoundEffectConstants -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.LinearLayout -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import com.futo.platformplayer.R -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.api.media.models.video.IPlatformVideo -import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.constructs.Event0 -import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StatePlatform -import com.futo.platformplayer.views.buttons.BigButton - -@UnstableApi -class ShortsFragment : MainFragment() { - override val isMainView: Boolean = true - override val isTab: Boolean = true - override val hasBottomBar: Boolean get() = true - - private var loadPagerTask: TaskHandler>? = null - private var nextPageTask: TaskHandler>? = null - - private var mainShortsPager: IPager? = null - private val mainShorts: MutableList = mutableListOf() - - // the pager to call next on - private var currentShortsPager: IPager? = null - - // the shorts array bound to the ViewPager2 adapter - private val currentShorts: MutableList = mutableListOf() - - private var channelShortsPager: IPager? = null - private val channelShorts: MutableList = mutableListOf() - val isChannelShortsMode: Boolean - get() = channelShortsPager != null - - private var viewPager: ViewPager2? = null - private lateinit var zeroState: LinearLayout - private lateinit var sourcesButton: BigButton - private lateinit var overlayLoading: FrameLayout - private lateinit var overlayLoadingSpinner: ImageView - private lateinit var overlayQualityContainer: FrameLayout - private var customViewAdapter: CustomViewAdapter? = null - - // we just completely reset the data structure so we want to tell the adapter that - @SuppressLint("NotifyDataSetChanged") - override fun onShownWithView(parameter: Any?, isBack: Boolean) { - (activity as MainActivity?)?.getFragment()?.closeVideoDetails() - super.onShownWithView(parameter, isBack) - - if (parameter is Triple<*, *, *>) { - setLoading(false) - channelShorts.clear() - @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter - channelShorts.addAll(parameter.third as ArrayList) - @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter - channelShortsPager = parameter.second as IPager - - currentShorts.clear() - currentShorts.addAll(channelShorts) - currentShortsPager = channelShortsPager - - viewPager?.adapter?.notifyDataSetChanged() - - viewPager?.post { - viewPager?.currentItem = channelShorts.indexOfFirst { - return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id - } - } - } else if (isChannelShortsMode) { - channelShortsPager = null - channelShorts.clear() - currentShorts.clear() - - if (loadPagerTask == null) { - currentShorts.addAll(mainShorts) - currentShortsPager = mainShortsPager - } else { - setLoading(true) - } - - viewPager?.adapter?.notifyDataSetChanged() - viewPager?.currentItem = 0 - } - - updateZeroState() - } - - override fun onCreateMainView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_shorts, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewPager = view.findViewById(R.id.view_pager) - zeroState = view.findViewById(R.id.zero_state) - sourcesButton = view.findViewById(R.id.sources_button) - overlayLoading = view.findViewById(R.id.short_view_loading_overlay) - overlayLoadingSpinner = view.findViewById(R.id.short_view_loader) - overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) - - sourcesButton.onClick.subscribe { - sourcesButton.playSoundEffect(SoundEffectConstants.CLICK) - navigate() - } - - setLoading(true) - - Logger.i(TAG, "Creating adapter") - val customViewAdapter = - CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) { - if (!currentShortsPager!!.hasMorePages()) { - return@CustomViewAdapter - } - nextPage() - } - customViewAdapter.onResetTriggered.subscribe { - setLoading(true) - loadPager() - - loadPagerTask!!.success { - setLoading(false) - } - } - val viewPager = viewPager!! - viewPager.adapter = customViewAdapter - - this.customViewAdapter = customViewAdapter - - if (loadPagerTask == null && currentShorts.isEmpty()) { - loadPager() - - loadPagerTask!!.success { - setLoading(false) - updateZeroState() - } - } else { - setLoading(false) - updateZeroState() - } - - viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - fun play(adapter: CustomViewAdapter, position: Int) { - val recycler = (viewPager.getChildAt(0) as RecyclerView) - val viewHolder = - recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? - - if (viewHolder == null) { - adapter.needToPlay = position - } else { - val focusedView = viewHolder.shortView - focusedView.play() - adapter.previousShownView = focusedView - } - } - - override fun onPageSelected(position: Int) { - val adapter = (viewPager.adapter as CustomViewAdapter) - if (adapter.previousShownView == null) { - // play if this page selection didn't trigger by a swipe from another page - play(adapter, position) - } else { - adapter.previousShownView?.stop() - adapter.previousShownView = null - adapter.newPosition = position - } - } - - // wait for the state to idle to prevent UI lag - override fun onPageScrollStateChanged(state: Int) { - super.onPageScrollStateChanged(state) - if (state == ViewPager2.SCROLL_STATE_IDLE) { - val adapter = (viewPager.adapter as CustomViewAdapter) - val position = adapter.newPosition ?: return - adapter.newPosition = null - - play(adapter, position) - } - } - }) - } - - private fun updateZeroState() { - if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) { - zeroState.visibility = View.VISIBLE - } else { - zeroState.visibility = View.GONE - } - } - - private fun nextPage() { - nextPageTask?.cancel() - - val nextPageTask = - TaskHandler>(StateApp.instance.scopeGetter, { - currentShortsPager!!.nextPage() - - return@TaskHandler currentShortsPager!!.getResults() - }).success { newVideos -> - val prevCount = customViewAdapter!!.itemCount - currentShorts.addAll(newVideos) - if (isChannelShortsMode) { - channelShorts.addAll(newVideos) - } else { - mainShorts.addAll(newVideos) - } - customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) - nextPageTask = null - } - - nextPageTask.run(this) - - this.nextPageTask = nextPageTask - } - - // we just completely reset the data structure so we want to tell the adapter that - @SuppressLint("NotifyDataSetChanged") - private fun loadPager() { - loadPagerTask?.cancel() - - val loadPagerTask = - TaskHandler>(StateApp.instance.scopeGetter, { - val pager = StatePlatform.instance.getShorts() - - return@TaskHandler pager - }).success { pager -> - mainShorts.clear() - mainShorts.addAll(pager.getResults()) - mainShortsPager = pager - - if (!isChannelShortsMode) { - currentShorts.clear() - currentShorts.addAll(mainShorts) - currentShortsPager = pager - - // if the view pager exists go back to the beginning - viewPager?.adapter?.notifyDataSetChanged() - viewPager?.currentItem = 0 - } - - loadPagerTask = null - }.exception { err -> - val message = "Unable to load shorts $err" - Logger.i(TAG, message) - if (context != null) { - UIDialogs.showDialog( - requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action( - "Close", { }, UIDialogs.ActionStyle.PRIMARY - ) - ) - } - return@exception - } - - this.loadPagerTask = loadPagerTask - - loadPagerTask.run(this) - } - - private fun setLoading(isLoading: Boolean) { - if (isLoading) { - (overlayLoadingSpinner.drawable as Animatable?)?.start() - overlayLoading.visibility = View.VISIBLE - } else { - overlayLoading.visibility = View.GONE - (overlayLoadingSpinner.drawable as Animatable?)?.stop() - } - } - - override fun onPause() { - super.onPause() - customViewAdapter?.previousShownView?.pause() - } - - override fun onDestroy() { - super.onDestroy() - loadPagerTask?.cancel() - loadPagerTask = null - nextPageTask?.cancel() - nextPageTask = null - customViewAdapter?.previousShownView?.stop() - } - - companion object { - private const val TAG = "ShortsFragment" - - fun newInstance() = ShortsFragment() - } - - class CustomViewAdapter( - private val videos: MutableList, - private val inflater: LayoutInflater, - private val fragment: MainFragment, - private val overlayQualityContainer: FrameLayout, - private val isChannelShortsMode: () -> Boolean, - private val onNearEnd: () -> Unit, - ) : RecyclerView.Adapter() { - val onResetTriggered = Event0() - var previousShownView: ShortView? = null - var newPosition: Int? = null - var needToPlay: Int? = null - - @OptIn(UnstableApi::class) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { - val shortView = ShortView(inflater, fragment, overlayQualityContainer) - shortView.onResetTriggered.subscribe { - onResetTriggered.emit() - } - return CustomViewHolder(shortView) - } - - @OptIn(UnstableApi::class) - override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { - holder.shortView.changeVideo(videos[position], isChannelShortsMode()) - - if (position == itemCount - 1) { - onNearEnd() - } - } - - override fun onViewRecycled(holder: CustomViewHolder) { - super.onViewRecycled(holder) - holder.shortView.cancel() - } - - override fun onViewAttachedToWindow(holder: CustomViewHolder) { - super.onViewAttachedToWindow(holder) - - if (holder.absoluteAdapterPosition == needToPlay) { - holder.shortView.play() - needToPlay = null - previousShownView = holder.shortView - } - } - - override fun getItemCount(): Int = videos.size - } - - @OptIn(UnstableApi::class) - class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView) -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt index 5c2a8085..1b621c03 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt @@ -152,11 +152,6 @@ class SourceDetailFragment : MainFragment() { if(field is View) field.isVisible = false; } - if(!source.capabilities.hasGetUserHistory) { - val field = _settingsAppForm.findField("sync"); - if(field is View) - field.isVisible = false; - } _settingsAppForm.onChanged.clear(); _settingsAppForm.onChanged.subscribe { field, value -> _settingsAppChanged = true; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt index 08169bed..39c87028 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt @@ -18,7 +18,6 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment -import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.stores.FragmentedStorage @@ -45,7 +44,6 @@ class SourcesFragment : MainFragment() { if(topBar is AddTopBarFragment) { (topBar as AddTopBarFragment).onAdd.clear(); (topBar as AddTopBarFragment).onAdd.subscribe { - StateApp.instance.preventPictureInPicture.emit(); startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java)); }; } @@ -95,7 +93,6 @@ class SourcesFragment : MainFragment() { findViewById(R.id.plugin_disclaimer).isVisible = false; } findViewById(R.id.button_add_sources).onClick.subscribe { - StateApp.instance.preventPictureInPicture.emit(); fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java)); }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt index 2953c84f..a2875647 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt @@ -290,8 +290,8 @@ class SubscriptionGroupFragment : MainFragment() { image.setImageView(_imageGroup); } else { - _imageGroupBackground.setImageDrawable(null); - _imageGroup.setImageDrawable(null); + _imageGroupBackground.setImageResource(0); + _imageGroup.setImageResource(0); } updateMeta(); reloadCreators(group); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index 4a3fdf91..83e39a88 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -191,7 +191,7 @@ class SubscriptionsFeedFragment : MainFragment() { private var _bypassRateLimit = false; private val _lastExceptions: List? = null; - private val _taskGetPager = TaskHandler>({fragment.lifecycleScope}, { withRefresh -> + private val _taskGetPager = TaskHandler>({StateApp.instance.scope}, { withRefresh -> val group = subGroup; if(!_bypassRateLimit) { val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(group); @@ -202,7 +202,7 @@ class SubscriptionsFeedFragment : MainFragment() { throw RateLimitException(rateLimitPlugins.map { it.key.id }); } _bypassRateLimit = false; - val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(fragment.lifecycleScope, withRefresh, group); + val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh, group); val feed = StateSubscriptions.instance.getFeed(group?.id); val currentExs = feed?.exceptions ?: listOf(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index 6c2c5b04..a07de94e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -18,22 +18,20 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.SearchHistoryStorage import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter -import com.futo.platformplayer.views.others.RadioGroupView -import com.futo.platformplayer.views.others.TagsView -data class SuggestionsFragmentData(val query: String, val searchType: SearchType); +data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); class SuggestionsFragment : MainFragment { override val isMainView : Boolean = true; - override val hasBottomBar: Boolean = true; + override val hasBottomBar: Boolean = false; override val isHistory: Boolean = false; - private var _recyclerSuggestions: RecyclerView? = null; + private var _recyclerSuggestions: RecyclerView? = null; private var _llmSuggestions: LinearLayoutManager? = null; - private var _radioGroupView: RadioGroupView? = null; private val _suggestions: ArrayList = ArrayList(); private var _query: String? = null; private var _searchType: SearchType = SearchType.VIDEO; + private var _channelUrl: String? = null; private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions); @@ -51,7 +49,14 @@ class SuggestionsFragment : MainFragment { _adapterSuggestions.onClicked.subscribe { suggestion -> val storage = FragmentedStorage.get(); storage.add(suggestion); - navigate(SuggestionsFragmentData(suggestion, _searchType)); + + if (_searchType == SearchType.CREATOR) { + navigate(suggestion); + } else if (_searchType == SearchType.PLAYLIST) { + navigate(suggestion); + } else { + navigate(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl)); + } } _adapterSuggestions.onRemove.subscribe { suggestion -> val index = _suggestions.indexOf(suggestion); @@ -75,15 +80,6 @@ class SuggestionsFragment : MainFragment { recyclerSuggestions.adapter = _adapterSuggestions; _recyclerSuggestions = recyclerSuggestions; - _radioGroupView = view.findViewById(R.id.radio_group).apply { - onSelectedChange.subscribe { - if (it.size != 1) - _searchType = SearchType.VIDEO - else - _searchType = (it[0] ?: SearchType.VIDEO) as SearchType - } - } - loadSuggestions(); return view; } @@ -108,31 +104,37 @@ class SuggestionsFragment : MainFragment { if (parameter is SuggestionsFragmentData) { _searchType = parameter.searchType; + _channelUrl = parameter.channelUrl; } else if (parameter is SearchType) { _searchType = parameter; + _channelUrl = null; } - _radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) - topBar?.apply { if (this is SearchTopBarFragment) { onSearch.subscribe(this) { - if(it.isHttpUrl()) { - if(StatePlatform.instance.hasEnabledPlaylistClient(it)) - navigate(it); - else if(StatePlatform.instance.hasEnabledChannelClient(it)) - navigate(it); - else { - val url = it; - activity?.let { - close() - if(it is MainActivity) - it.navigate(it.getFragment(), url); + if (_searchType == SearchType.CREATOR) { + navigate(it); + } else if (_searchType == SearchType.PLAYLIST) { + navigate(it); + } else { + if(it.isHttpUrl()) { + if(StatePlatform.instance.hasEnabledPlaylistClient(it)) + navigate(it); + else if(StatePlatform.instance.hasEnabledChannelClient(it)) + navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); + } } } + else + navigate(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); } - else - navigate(SuggestionsFragmentData(it, _searchType)); }; onTextChange.subscribe(this) { @@ -194,7 +196,6 @@ class SuggestionsFragment : MainFragment { super.onDestroyMainView(); _getSuggestions.onError.clear(); _recyclerSuggestions = null; - _radioGroupView = null } override fun onDestroy() { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt index 73ee0295..ba1c60d6 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/TutorialFragment.kt @@ -32,7 +32,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment -import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.views.pills.WidePillButton import java.time.OffsetDateTime @@ -153,9 +152,6 @@ class TutorialFragment : MainFragment() { override val viewCount: Long = -1 override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height) override val isShort: Boolean = false; - override var playbackTime: Long = -1; - @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) - override var playbackDate: OffsetDateTime? = null; override fun getComments(client: IPlatformClient): IPager { return EmptyPager() } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 304f52b2..6477fa1f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -101,7 +101,7 @@ class VideoDetailFragment() : MainFragment() { } private fun isSmallWindow(): Boolean { - return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.smallest_width_dp) + return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.column_width_dp) * 2 } private fun isAutoRotateEnabled(): Boolean { @@ -337,6 +337,13 @@ class VideoDetailFragment() : MainFragment() { closeVideoDetails(); }; it.onMaximize.subscribe { maximizeVideoDetail(it) }; + it.onPlayChanged.subscribe { + if(isInPictureInPicture) { + val params = _viewDetail?.getPictureInPictureParams(); + if (params != null) + activity?.setPictureInPictureParams(params); + } + }; it.onEnterPictureInPicture.subscribe { Logger.i(TAG, "onEnterPictureInPicture") isInPictureInPicture = true; @@ -439,24 +446,15 @@ class VideoDetailFragment() : MainFragment() { val viewDetail = _viewDetail; Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}"); - if (viewDetail === null) { - return - } + if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { + _leavingPiP = false; - if (viewDetail.shouldEnterPictureInPicture) { - _leavingPiP = false - } - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") activity?.enterPictureInPictureMode(params); } } - - if (isFullscreen) { - viewDetail.restoreBrightness() - } } fun forcePictureInPicture() { @@ -465,14 +463,10 @@ class VideoDetailFragment() : MainFragment() { activity?.enterPictureInPictureMode(params); } fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) { - try { - if (isInPictureInPictureMode) { - _viewDetail?.startPictureInPicture(); - } else if (isInPictureInPicture) { - leavePictureInPictureMode(isStop); - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to handle onPictureInPictureModeChanged", e) + if (isInPictureInPictureMode) { + _viewDetail?.startPictureInPicture(); + } else if (isInPictureInPicture) { + leavePictureInPictureMode(isStop); } } fun leavePictureInPictureMode(isStop: Boolean) { @@ -493,10 +487,6 @@ class VideoDetailFragment() : MainFragment() { _isActive = true; _leavingPiP = false; - if (isFullscreen) { - _viewDetail?.saveBrightness() - } - _viewDetail?.let { Logger.v(TAG, "onResume preventPictureInPicture=false"); it.preventPictureInPicture = false; @@ -625,6 +615,11 @@ class VideoDetailFragment() : MainFragment() { showSystemUI() } + // temporarily force the device to portrait if auto-rotate is disabled to prevent landscape when exiting full screen on a small device +// @SuppressLint("SourceLockedOrientationActivity") +// if (!isFullscreen && isSmallWindow() && !isAutoRotateEnabled() && !isMinimizingFromFullScreen) { +// activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT +// } updateOrientation(); _view?.allowMotion = !fullscreen; } 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 5382ef13..7176d125 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 @@ -2,9 +2,6 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.app.PictureInPictureParams import android.app.RemoteAction -import android.content.ClipData -import android.content.ClipboardManager -import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.res.Configuration @@ -16,7 +13,6 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.net.Uri -import android.os.Build import android.support.v4.media.session.PlaybackStateCompat import android.text.Spanned import android.util.AttributeSet @@ -50,8 +46,6 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays -import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -82,9 +76,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig -import com.futo.platformplayer.api.media.platforms.js.models.JSVideo import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails -import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting @@ -98,7 +90,6 @@ import com.futo.platformplayer.engine.exceptions.ScriptAgeException import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException -import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.fixHtmlLinks @@ -157,6 +148,7 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.ChaptersList import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer @@ -180,7 +172,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import userpackage.Protocol import java.time.OffsetDateTime -import java.util.Locale import kotlin.math.abs import kotlin.math.roundToLong @@ -249,13 +240,7 @@ class VideoDetailView : ConstraintLayout { private val _buttonPins: RoundButtonGroup; //private val _buttonMore: RoundButton; - var preventPictureInPicture: Boolean = false - set(value) { - if (field != value) { - field = value - onShouldEnterPictureInPictureChanged.emit() - } - } + var preventPictureInPicture: Boolean = false; private val _addCommentView: AddCommentView; private var _tabIndex: Int? = null; @@ -324,24 +309,11 @@ class VideoDetailView : ConstraintLayout { val onClose = Event0(); val onFullscreenChanged = Event1(); val onEnterPictureInPicture = Event0(); + val onPlayChanged = Event1(); val onVideoChanged = Event2() - var allowBackground: Boolean = false - private set(value) { - if (field != value) { - field = value - onShouldEnterPictureInPictureChanged.emit() - } - } - - val shouldEnterPictureInPicture: Boolean - get() = !preventPictureInPicture && - !StateCasting.instance.isCasting && - Settings.instance.playback.isBackgroundPictureInPicture() && - !allowBackground && - isPlaying - - val onShouldEnterPictureInPictureChanged = Event0(); + var allowBackground : Boolean = false + private set; val onTouchCancel = Event0(); private var _lastPositionSaveTime: Long = -1; @@ -436,14 +408,6 @@ class VideoDetailView : ConstraintLayout { showChaptersUI(); }; - _title.setOnLongClickListener { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager; - val clip = ClipData.newPlainText("Video Title", (it as TextView).text); - clipboard.setPrimaryClip(clip); - UIDialogs.toast(context, "Copied", false) - // let other interactions happen based on the touch - false - } _buttonSubscribe.onSubscribed.subscribe { _slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); @@ -454,29 +418,6 @@ class VideoDetailView : ConstraintLayout { fragment.navigate(it.targetUrl); }; - _container_content_liveChat.onUrlClick.subscribe { uri -> - val c = context - if (c is MainActivity) { - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - if (!c.handleUrl(uri.toString())) { - Intent(Intent.ACTION_VIEW, uri).apply { - addCategory(Intent.CATEGORY_BROWSABLE) - context.startActivity(this) - } - } - } catch (e: Throwable) { - Log.e(TAG, "Failed to handle live chat URL") - } - } - } else { - Intent(Intent.ACTION_VIEW, uri).apply { - addCategory(Intent.CATEGORY_BROWSABLE) - context.startActivity(this) - } - } - } - _monetization.onSupportTap.subscribe { _container_content_support.setPolycentricProfile(_polycentricProfile); switchContentView(_container_content_support); @@ -501,6 +442,11 @@ class VideoDetailView : ConstraintLayout { _player.attachPlayer(); + _container_content_liveChat.onRaidNow.subscribe { + StatePlayer.instance.clearQueue(); + fragment.navigate(it.targetUrl); + }; + StateApp.instance.preventPictureInPicture.subscribe(this) { Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true"); preventPictureInPicture = true; @@ -625,7 +571,7 @@ class VideoDetailView : ConstraintLayout { _player.setIsReplay(true); val searchVideo = StatePlayer.instance.getCurrentQueueItem(); - if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) { + if (searchVideo is SerializedPlatformVideo?) { searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) }; } @@ -651,23 +597,14 @@ class VideoDetailView : ConstraintLayout { } } - _player.onReloadRequired.subscribe { - fetchVideo(); - } - _player.onPlayChanged.subscribe { if (StateCasting.instance.activeDevice == null) { handlePlayChanged(it); } }; - onShouldEnterPictureInPictureChanged.subscribe { - val params = getPictureInPictureParams() - fragment.activity?.setPictureInPictureParams(params) - } - if (!isInEditMode) { - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState -> + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> if (_onPauseCalled) { return@subscribe; } @@ -679,10 +616,9 @@ class VideoDetailView : ConstraintLayout { setCastEnabled(true); } CastConnectionState.DISCONNECTED -> { - loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying); + loadCurrentVideo(lastPositionMilliseconds); updatePillButtonVisibilities(); setCastEnabled(false); - } else -> {} } @@ -711,15 +647,6 @@ class VideoDetailView : ConstraintLayout { _timeBar.setDuration(video?.duration ?: 0); } }; - - _cast.onTimeJobTimeChanged_s.subscribe { - if (_isCasting) { - setLastPositionMilliseconds((it * 1000.0).toLong(), true); - _timeBar.setPosition(it); - _timeBar.setBufferedPosition(0); - _timeBar.setDuration(video?.duration ?: 0); - } - } } _playerProgress.player = _player.exoPlayer?.player; @@ -761,20 +688,6 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "MediaControlReceiver.onCloseReceived") onClose.emit() }; - MediaControlReceiver.onBackgroundReceived.subscribe(this) { - Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") - _player.switchToAudioMode(video); - allowBackground = true; - StateApp.instance.contextOrNull?.let { - try { - if (it is MainActivity) { - it.moveTaskToBack(true) - } - } catch (e: Throwable) { - Logger.i(TAG, "Failed to move task to back", e) - } - } - }; MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); }; _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; @@ -853,10 +766,7 @@ class VideoDetailView : ConstraintLayout { _lastVideoSource = null; _lastAudioSource = null; _lastSubtitleSource = null; - _cast.cancel() - StateCasting.instance.cancel() video = null; - _container_content_liveChat?.close(); _player.clear(); cleanupPlaybackTracker(); Logger.i(TAG, "Keep screen on unset onClose") @@ -982,7 +892,6 @@ class VideoDetailView : ConstraintLayout { return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD else false; } ?: false; - val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) { (video ?: _searchVideo)?.let { _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) { @@ -1011,7 +920,7 @@ class VideoDetailView : ConstraintLayout { } else null, if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) { if (!allowBackground) { - _player.switchToAudioMode(video); + _player.switchToAudioMode(); allowBackground = true; it.text.text = resources.getString(R.string.background_revert); } else { @@ -1158,7 +1067,6 @@ class VideoDetailView : ConstraintLayout { //Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert? if(!allowBackground) { _player.switchToVideoMode(); - allowBackground = false; _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background); } } @@ -1182,18 +1090,12 @@ class VideoDetailView : ConstraintLayout { when (Settings.instance.playback.backgroundPlay) { 0 -> handlePause(); 1 -> { - if(!(video?.isLive ?: false)) { - _player.switchToAudioMode(video); - allowBackground = true; - } + if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio) + _player.switchToAudioMode(); StatePlayer.instance.startOrUpdateMediaSession(context, video); } } } - - if (_player.isFullScreen) { - restoreBrightness() - } } fun onStop() { Logger.i(TAG, "onStop"); @@ -1239,7 +1141,6 @@ class VideoDetailView : ConstraintLayout { MediaControlReceiver.onNextReceived.remove(this); MediaControlReceiver.onPreviousReceived.remove(this); MediaControlReceiver.onCloseReceived.remove(this); - MediaControlReceiver.onBackgroundReceived.remove(this); MediaControlReceiver.onSeekToReceived.remove(this); val job = _jobHideResume; @@ -1472,8 +1373,8 @@ class VideoDetailView : ConstraintLayout { onVideoChanged.emit(0, 0) } - val me = this; if (video is JSVideoDetails) { + val me = this; fragment.lifecycleScope.launch(Dispatchers.IO) { try { //TODO: Implement video.getContentChapters() @@ -1530,32 +1431,6 @@ class VideoDetailView : ConstraintLayout { } }; } - else { - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - if (!StateApp.instance.privateMode) { - val stopwatch = com.futo.platformplayer.debug.Stopwatch() - var tracker = video.getPlaybackTracker() - Logger.i(TAG, "video.getPlaybackTracker took ${stopwatch.elapsedMs}ms") - - if (tracker == null) { - stopwatch.reset() - tracker = StatePlatform.instance.getPlaybackTracker(video.url); - Logger.i( - TAG, - "StatePlatform.instance.getPlaybackTracker took ${stopwatch.elapsedMs}ms" - ) - } - - if (me.video?.url == video.url && !video.url.isNullOrBlank()) - me._playbackTracker = tracker; - } else if (me.video == video) - me._playbackTracker = null; - } catch (ex: Throwable) { - Logger.e(TAG, "Playback tracker failed", ex); - } - } - } val ref = Models.referenceFromBuffer(video.url.toByteArray()) val extraBytesRef = video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } @@ -1635,68 +1510,60 @@ class VideoDetailView : ConstraintLayout { _rating.visibility = View.GONE; - if (StatePolycentric.instance.enabled) { - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - val queryReferencesResponse = ApiMethods.getQueryReferences( - ApiMethods.SERVER, ref, null, null, - arrayListOf( - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.like.data) - ).build(), - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.dislike.data) - ).build() - ), - extraByteReferences = listOfNotNull(extraBytesRef) - ); + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val queryReferencesResponse = ApiMethods.getQueryReferences( + ApiMethods.SERVER, ref, null, null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data) + ).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data) + ).build() + ), + extraByteReferences = listOfNotNull(extraBytesRef) + ); - val likes = queryReferencesResponse.countsList[0]; - val dislikes = queryReferencesResponse.countsList[1]; - val hasLiked = - StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; - val hasDisliked = - StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; - withContext(Dispatchers.Main) { - _rating.visibility = View.VISIBLE; - _rating.setRating( - RatingLikeDislikes(likes, dislikes), - hasLiked, - hasDisliked - ); - _rating.onLikeDislikeUpdated.subscribe(this) { args -> - if (args.hasLiked) { - args.processHandle.opinion(ref, Opinion.like); - } else if (args.hasDisliked) { - args.processHandle.opinion(ref, Opinion.dislike); - } else { - args.processHandle.opinion(ref, Opinion.neutral); + withContext(Dispatchers.Main) { + _rating.visibility = View.VISIBLE; + _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); + _rating.onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); + } + + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(TAG, "Started backfill"); + args.processHandle.fullyBackfillServersAnnounceExceptions(); + Logger.i(TAG, "Finished backfill"); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) } + } - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServersAnnounceExceptions(); - Logger.i(TAG, "Finished backfill"); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to backfill servers", e) - } - } - - StatePolycentric.instance.updateLikeMap( - ref, - args.hasLiked, - args.hasDisliked - ) - }; - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); - _rating.visibility = View.GONE; + StatePolycentric.instance.updateLikeMap( + ref, + args.hasLiked, + args.hasDisliked + ) + }; } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; } } @@ -1795,19 +1662,12 @@ class VideoDetailView : ConstraintLayout { _liveChat?.stop(); _liveChat = null; - var gotLive = false; if (video.isLive && video.live != null) { loadLiveChat(video); - gotLive = true; } - if (video.isLive && video.live == null && !video.video.videoSources.any()) { + if (video.isLive && video.live == null && !video.video.videoSources.any()) startLiveTry(video); - gotLive = true; - } - if(!gotLive && video is JSVideoDetails && video.hasVODEvents()) { - Logger.i(TAG, "Loading VOD chat"); - loadVODChat(video); - } + _player.updateNextPrevious(); updateMoreButtons(); @@ -1831,43 +1691,6 @@ class VideoDetailView : ConstraintLayout { _taskLoadRecommendations.run(videoDetail.url) } } - fun loadVODChat(video: IPlatformVideoDetails) { - _liveChat?.stop(); - _container_content_liveChat.cancel(); - _liveChat = null; - if(video !is JSVideoDetails) - return; - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - var livePager: IPager?; - try { - //TODO: Create video.getLiveEvents shortcut/optimalization - livePager = video.getVODEvents(video.url); - } catch (ex: Throwable) { - Logger.e(TAG, "Failed to obtain VODEvents pager", ex); - livePager = null; - } - val liveChat = livePager?.let { - val liveChatManager = LiveChatManager(fragment.lifecycleScope, livePager, video.viewCount); - liveChatManager.start(); - return@let liveChatManager; - } - _liveChat = liveChat; - - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - _container_content_liveChat.load(fragment.lifecycleScope, liveChat, null, if(liveChat != null) video.viewCount else null); - switchContentView(_container_content_liveChat); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to switch content view to vod chat.", e); - } - } - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed to load vod chat", ex); - } - } - } fun loadLiveChat(video: IPlatformVideoDetails) { _liveChat?.stop(); _container_content_liveChat.cancel(); @@ -1942,7 +1765,7 @@ class VideoDetailView : ConstraintLayout { } //Source Loads - private fun loadCurrentVideo(resumePositionMs: Long = 0, playWhenReady: Boolean = true) { + private fun loadCurrentVideo(resumePositionMs: Long = 0) { _didStop = false; val video = (videoLocal ?: video) ?: return; @@ -1963,52 +1786,26 @@ class VideoDetailView : ConstraintLayout { if (!isCasting) { setCastEnabled(false); - val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let { - if (it is JSClient) - return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD - else false; - } ?: false; - - if (isLimitedVersion && _player.isAudioMode) { - _player.switchToVideoMode() - allowBackground = false; - } else { - val thumbnail = video.thumbnails.getHQThumbnail(); - if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank()) - Glide.with(context).asBitmap().load(thumbnail) - .into(object: CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - _player.setArtwork(BitmapDrawable(resources, resource)); - } - override fun onLoadCleared(placeholder: Drawable?) { - _player.setArtwork(null); - } - }); - else - _player.setArtwork(null); - } - - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - _player.setSource(videoSource, audioSource, _playWhenReady && playWhenReady, false, resume = resumePositionMs > 0); - if(subtitleSource != null) - _player.swapSubtitles(subtitleSource); - _player.seekTo(resumePositionMs); - } catch (e: Throwable) { - Logger.e(TAG, "loadCurrentVideo failed", e) - } - } + val thumbnail = video.thumbnails.getHQThumbnail(); + if (videoSource == null && !thumbnail.isNullOrBlank()) + Glide.with(context).asBitmap().load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + _player.setArtwork(BitmapDrawable(resources, resource)); + } + override fun onLoadCleared(placeholder: Drawable?) { + _player.setArtwork(null); + } + }); + else + _player.setArtwork(null); + _player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0); + if(subtitleSource != null) + _player.swapSubtitles(fragment.lifecycleScope, subtitleSource); + _player.seekTo(resumePositionMs); } - else { - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble()); - } catch (e: Throwable) { - Logger.e(TAG, "loadCurrentVideo failed (casting)", e) - } - } - } - + else + loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble()); _lastVideoSource = videoSource; _lastAudioSource = audioSource; @@ -2023,46 +1820,13 @@ class VideoDetailView : ConstraintLayout { UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex); } } - private suspend fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { + private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") - castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed) - } - private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { - try { - val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin() - else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() - else null - - val startId = plugin?.getUnderlyingPlugin()?.runtimeId - try { - val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { - _cast.setLoading(it) - }, onLoadingEstimate = { - _cast.setLoading(it) - }) - - if (castingSucceeded) { - withContext(Dispatchers.Main) { - _cast.setVideoDetails(video, resumePositionMs / 1000); - setCastEnabled(true); - } - } - } catch (e: ScriptReloadRequiredException) { - Log.i(TAG, "Reload required exception", e) - if (plugin == null) - throw e - - if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId) - throw e - - StatePlatform.instance.handleReloadRequired(e, { - fetchVideo() - }); - } - } catch (e: Throwable) { - Logger.e(TAG, "loadCurrentVideoCast", e) - } + if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) { + _cast.setVideoDetails(video, resumePositionMs / 1000); + setCastEnabled(true); + } else throw IllegalStateException("Disconnected cast during loading"); } //Events @@ -2099,12 +1863,8 @@ class VideoDetailView : ConstraintLayout { } updateQualityFormatsOverlay( - videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height }, - audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate }); - } - - _layoutPlayerContainer.post { - onShouldEnterPictureInPictureChanged.emit() + videoTrackFormats.distinctBy { it.height }.sortedBy { it.height }, + audioTrackFormats.distinctBy { it.bitrate }.sortedBy { it.bitrate }); } } @@ -2355,40 +2115,23 @@ class VideoDetailView : ConstraintLayout { val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true 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, - qualityPlaybackSpeedTitle, + if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { - val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); - val format = if(playbackSpeeds.size < 20) "%.2f" else "%.1f"; - val playbackLabels = playbackSpeeds.map { String.format(Locale.US, format, it) }.toMutableList(); - playbackLabels.add("+"); - playbackLabels.add(0, "-"); - - setButtons(playbackLabels, String.format(Locale.US, format, currentPlaybackRate)); + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate!!.toString()); onClick.subscribe { v -> - val currentPlaybackSpeed = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate(); - var playbackSpeedString = v; - val stepSpeed = Settings.instance.playback.getPlaybackSpeedStep(); - if(v == "+") - playbackSpeedString = String.format(Locale.US, "%.2f", Math.min((currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed, 5.0)).toString(); - else if(v == "-") - playbackSpeedString = String.format(Locale.US, "%.2f", Math.max(0.1, (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed)).toString(); - val newPlaybackSpeed = playbackSpeedString.toDouble(); if (_isCasting) { val ad = StateCasting.instance.activeDevice ?: return@subscribe if (!ad.canSetSpeed) { return@subscribe } - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - ad.changeSpeed(newPlaybackSpeed) - setSelected(playbackSpeedString); + ad.changeSpeed(v.toDouble()) + setSelected(v); } else { - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); - _player.setPlaybackRate(playbackSpeedString.toFloat()); - setSelected(playbackSpeedString); + _player.setPlaybackRate(v.toFloat()); + setSelected(v); } }; } else null, @@ -2566,6 +2309,7 @@ class VideoDetailView : ConstraintLayout { } isPlaying = playing; + onPlayChanged.emit(playing); updateTracker(lastPositionMilliseconds, playing, true); } @@ -2576,17 +2320,11 @@ class VideoDetailView : ConstraintLayout { if(_lastVideoSource == videoSource) return; - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? - } catch (e: Throwable) { - Logger.e(TAG, "handleSelectVideoTrack failed", e) - } - } + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? _lastVideoSource = videoSource; } @@ -2597,17 +2335,11 @@ class VideoDetailView : ConstraintLayout { if(_lastAudioSource == audioSource) return; - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) - else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) - _player.hideControls(false); //TODO: Disable player? - } catch (e: Throwable) { - Logger.e(TAG, "handleSelectAudioTrack failed", e) - } - } + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) + _player.hideControls(false); //TODO: Disable player? _lastAudioSource = audioSource; } @@ -2619,18 +2351,12 @@ class VideoDetailView : ConstraintLayout { if(_lastSubtitleSource == subtitleSource) toSet = null; - fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - val d = StateCasting.instance.activeDevice; - if (d != null && d.connectionState == CastConnectionState.CONNECTED) - castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); - else { - _player.swapSubtitles(toSet); - } - } catch (e: Throwable) { - Logger.e(TAG, "handleSelectSubtitleTrack failed", e) - } - } + val d = StateCasting.instance.activeDevice; + if (d != null && d.connectionState == CastConnectionState.CONNECTED) + StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + else + _player.swapSubtitles(fragment.lifecycleScope, toSet); + _lastSubtitleSource = toSet; } @@ -2678,9 +2404,7 @@ class VideoDetailView : ConstraintLayout { val url = _url; if (!url.isNullOrBlank()) { - fragment.lifecycleScope.launch(Dispatchers.Main) { - setLoading(true); - } + setLoading(true); _taskLoadVideo.run(url); } } @@ -2689,7 +2413,6 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)") if(fullscreen) { - _container_content.visibility = GONE _layoutPlayerContainer.setPadding(0, 0, 0, 0); val lp = _container_content.layoutParams as LayoutParams; @@ -2703,7 +2426,6 @@ class VideoDetailView : ConstraintLayout { setProgressBarOverlayed(null); } else { - _container_content.visibility = VISIBLE _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); val lp = _container_content.layoutParams as LayoutParams; @@ -2717,9 +2439,6 @@ class VideoDetailView : ConstraintLayout { setProgressBarOverlayed(false); } onFullscreenChanged.emit(fullscreen); - _layoutPlayerContainer.post { - onShouldEnterPictureInPictureChanged.emit() - } } private fun setCastEnabled(isCasting: Boolean) { @@ -2737,7 +2456,8 @@ class VideoDetailView : ConstraintLayout { _cast.visibility = View.VISIBLE; } else { StateCasting.instance.stopVideo(); - _cast.cancel() + _cast.stopTimeJob(); + _cast.visibility = View.GONE; if (video?.isLive == false) { _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); @@ -2747,8 +2467,6 @@ class VideoDetailView : ConstraintLayout { if (changed) { stopAllGestures(); } - - onShouldEnterPictureInPictureChanged.emit() } fun isLandscapeVideo(): Boolean? { @@ -2767,15 +2485,6 @@ class VideoDetailView : ConstraintLayout { } } - fun saveBrightness() { - if (Settings.instance.gestureControls.useSystemBrightness) { - _player.gestureControl.saveBrightness() - } - } - fun restoreBrightness() { - _player.gestureControl.restoreBrightness() - } - fun setFullscreen(fullscreen : Boolean) { Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)") _player.setFullScreen(fullscreen) @@ -2939,10 +2648,9 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { - if(it.url.isNotBlank()) { - fragment.minimizeVideoDetail() + if(it.url.isNotBlank()) fragment.navigate(it) - } else + else UIDialogs.appToast("No author url present"); } @@ -2950,8 +2658,6 @@ class VideoDetailView : ConstraintLayout { if(it is IPlatformVideo) { if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(it), true)) UIDialogs.toast("Added to watch later\n[${it.name}]"); - else - UIDialogs.toast(context.getString(R.string.already_in_watch_later)) } } onAddToQueueClicked.subscribe(this) { @@ -2979,7 +2685,6 @@ class VideoDetailView : ConstraintLayout { _overlayContainer.removeAllViews(); _overlay_quality_selector?.hide(); - _container_content.visibility = GONE _player.fillHeight(false) _layoutPlayerContainer.setPadding(0, 0, 0, 0); @@ -2988,7 +2693,6 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "handleLeavePictureInPicture") if(!_player.isFullScreen) { - _container_content.visibility = VISIBLE _player.fitHeight(); _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); } else { @@ -3004,40 +2708,28 @@ class VideoDetailView : ConstraintLayout { videoSourceHeight = 9; } val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight; - val r = _player.getVideoRect() if(aspectRatio > 2.38) { videoSourceWidth = 16; videoSourceHeight = 9; - - // shrink the left and right equally to get the rect to be 16 by 9 aspect ratio - // we don't want a picture in picture mode that's more squashed than 16 by 9 - val targetWidth = r.height() * 16 / 9 - val shrinkAmount = (r.width() - targetWidth) / 2 - r.left += shrinkAmount - r.right -= shrinkAmount } else if(aspectRatio < 0.43) { videoSourceHeight = 16; videoSourceWidth = 9; } + val r = Rect(); + _player.getGlobalVisibleRect(r); + r.right = r.right - _player.paddingEnd; val playpauseAction = if(_player.playing) RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5)); else RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6)); - val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7)); - - val params = PictureInPictureParams.Builder() + return PictureInPictureParams.Builder() .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setSourceRectHint(r) - .setActions(listOf(toBackgroundAction, playpauseAction)) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - params.setAutoEnterEnabled(shouldEnterPictureInPicture) - } - - return params.build() + .setActions(listOf(playpauseAction)) + .build(); } //Other @@ -3055,8 +2747,6 @@ class VideoDetailView : ConstraintLayout { private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) { lastPositionMilliseconds = positionMilliseconds; - _liveChat?.setVideoPosition(lastPositionMilliseconds); - val v = video ?: return; val currentTime = System.currentTimeMillis(); if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { @@ -3234,11 +2924,6 @@ class VideoDetailView : ConstraintLayout { return@TaskHandler result; }) .success { setVideoDetails(it, true) } - .exception { - StatePlatform.instance.handleReloadRequired(it, { - fetchVideo(); - }); - } .exception { Logger.w(TAG, "exception", it) @@ -3356,12 +3041,7 @@ class VideoDetailView : ConstraintLayout { Logger.w(TAG, "Failed to load recommendations.", it); }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { - if (!StatePolycentric.instance.enabled) - return@TaskHandler null - - ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) - }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); @@ -3433,6 +3113,10 @@ class VideoDetailView : ConstraintLayout { fun applyFragment(frag: VideoDetailFragment) { fragment = frag; + fragment.onMinimize.subscribe { + _liveChat?.stop(); + _container_content_liveChat.close(); + } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index 10deee40..c0383b89 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -1,11 +1,9 @@ package com.futo.platformplayer.fragment.mainactivity.main -import android.content.Context import android.graphics.drawable.Animatable import android.util.TypedValue import android.view.LayoutInflater import android.view.View -import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -50,11 +48,6 @@ abstract class VideoListEditorView : LinearLayout { private var _loadedVideos: List? = null; private var _loadedVideosCanEdit: Boolean = false; - fun hideSearchKeyboard() { - (context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0) - _search.textSearch.clearFocus(); - } - constructor(inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_video_list_editor, this); @@ -86,7 +79,6 @@ abstract class VideoListEditorView : LinearLayout { _search.textSearch.text = ""; updateVideoFilters(); _buttonSearch.setImageResource(R.drawable.ic_search); - hideSearchKeyboard(); } else { _search.visibility = View.VISIBLE; @@ -97,23 +89,23 @@ abstract class VideoListEditorView : LinearLayout { _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; if(onShare != null) { - _buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() }; + _buttonShare.setOnClickListener { onShare.invoke() }; _buttonShare.visibility = View.VISIBLE; } else _buttonShare.visibility = View.GONE; - buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); }; - buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); }; + buttonPlayAll.setOnClickListener { onPlayAllClick(); }; + buttonShuffle.setOnClickListener { onShuffleClick(); }; - _buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); }; + _buttonEdit.setOnClickListener { onEditClick(); }; setButtonExportVisible(false); setButtonDownloadVisible(canEdit()); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); - videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)}; + videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); _videoListEditorView = videoListEditorView; } @@ -121,7 +113,6 @@ abstract class VideoListEditorView : LinearLayout { fun setOnShare(onShare: (()-> Unit)? = null) { _onShare = onShare; _buttonShare.setOnClickListener { - hideSearchKeyboard(); onShare?.invoke(); }; _buttonShare.visibility = View.VISIBLE; @@ -154,7 +145,7 @@ abstract class VideoListEditorView : LinearLayout { setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.drawable.assume { it.start() }; - _buttonDownload.setOnClickListener { hideSearchKeyboard(); + _buttonDownload.setOnClickListener { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { StateDownloads.instance.deleteCachedPlaylist(playlistId); }); @@ -163,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout { else if(isDownloaded) { setButtonExportVisible(true) _buttonDownload.setImageResource(R.drawable.ic_download_off); - _buttonDownload.setOnClickListener { hideSearchKeyboard(); + _buttonDownload.setOnClickListener { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { StateDownloads.instance.deleteCachedPlaylist(playlistId); }); @@ -172,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout { else { setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_download); - _buttonDownload.setOnClickListener { hideSearchKeyboard(); + _buttonDownload.setOnClickListener { onDownload(); //UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer); } @@ -224,8 +215,7 @@ abstract class VideoListEditorView : LinearLayout { fun updateVideoFilters() { val videos = _loadedVideos ?: return; - val filteredVideos = filterVideos(videos) - _videoListEditorView.setVideos(filteredVideos, _loadedVideosCanEdit && filteredVideos.size == videos.size); + _videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit); } protected fun setButtonDownloadVisible(isVisible: Boolean) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt deleted file mode 100644 index 8aa1eec2..00000000 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WebDetailFragment.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.futo.platformplayer.fragment.mainactivity.main - -import android.content.Context -import android.content.Intent -import android.graphics.drawable.Animatable -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewPropertyAnimator -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Button -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.children -import androidx.lifecycle.lifecycleScope -import com.bumptech.glide.Glide -import com.futo.platformplayer.R -import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.models.Thumbnails -import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails -import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment -import com.futo.platformplayer.api.media.models.post.IPlatformPost -import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails -import com.futo.platformplayer.api.media.models.ratings.IRating -import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes -import com.futo.platformplayer.api.media.models.ratings.RatingLikes -import com.futo.platformplayer.api.media.platforms.js.models.JSWeb -import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails -import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.dp -import com.futo.platformplayer.fixHtmlWhitespace -import com.futo.platformplayer.images.GlideHelper.Companion.crossfade -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StatePlatform -import com.futo.platformplayer.states.StatePolycentric -import com.futo.platformplayer.toHumanNowDiffString -import com.futo.platformplayer.toHumanNumber -import com.futo.platformplayer.views.adapters.ChannelTab -import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView -import com.futo.platformplayer.views.comments.AddCommentView -import com.futo.platformplayer.views.others.CreatorThumbnail -import com.futo.platformplayer.views.overlays.RepliesOverlay -import com.futo.platformplayer.views.pills.PillRatingLikesDislikes -import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.segments.CommentsList -import com.futo.platformplayer.views.subscriptions.SubscribeButton -import com.futo.polycentric.core.ApiMethods -import com.futo.polycentric.core.ContentType -import com.futo.polycentric.core.Models -import com.futo.polycentric.core.Opinion -import com.futo.polycentric.core.PolycentricProfile -import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions -import com.google.android.flexbox.FlexboxLayout -import com.google.android.material.imageview.ShapeableImageView -import com.google.android.material.shape.CornerFamily -import com.google.android.material.shape.ShapeAppearanceModel -import com.google.protobuf.ByteString -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import userpackage.Protocol -import java.lang.Integer.min - -class WebDetailFragment : MainFragment { - override val isMainView: Boolean = true; - override val isTab: Boolean = true; - override val hasBottomBar: Boolean get() = true; - - private var _viewDetail: WebDetailView? = null; - - constructor() : super() { } - - override fun onBackPressed(): Boolean { - return false; - } - - override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = WebDetailView(inflater.context).applyFragment(this); - _viewDetail = view; - return view; - } - - override fun onDestroyMainView() { - super.onDestroyMainView(); - _viewDetail?.onDestroy(); - _viewDetail = null; - } - - override fun onShownWithView(parameter: Any?, isBack: Boolean) { - super.onShownWithView(parameter, isBack); - - if (parameter is JSWeb) { - _viewDetail?.clear(); - _viewDetail?.setWeb(parameter); - } - if (parameter is JSWebDetails) { - _viewDetail?.clear(); - _viewDetail?.setWebDetails(parameter); - } - } - - private class WebDetailView : ConstraintLayout { - private lateinit var _fragment: WebDetailFragment; - private var _url: String? = null; - private var _isLoading = false; - private var _web: JSWebDetails? = null; - - private val _layoutLoadingOverlay: FrameLayout; - private val _imageLoader: ImageView; - - private val _webview: WebView; - - private val _taskLoadPost = if(!isInEditMode) TaskHandler( - StateApp.instance.scopeGetter, - { - val result = StatePlatform.instance.getContentDetails(it).await(); - if(result !is JSWebDetails) - throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}"); - return@TaskHandler result; - }) - .success { setWebDetails(it) } - .exception { - Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it); - UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); - } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; - - - constructor(context: Context) : super(context) { - inflate(context, R.layout.fragview_web_detail, this); - - val root = findViewById(R.id.root); - - _layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay); - _imageLoader = findViewById(R.id.image_loader); - - _webview = findViewById(R.id.webview); - _webview.webViewClient = object: WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url); - if(url != "about:blank") - setLoading(false); - } - } - } - - - fun applyFragment(frag: WebDetailFragment): WebDetailView { - _fragment = frag; - return this; - } - - fun clear() { - _webview.loadUrl("about:blank"); - } - - fun setWeb(value: JSWeb) { - _url = value.url; - setLoading(true); - clear(); - fetchPost(); - } - fun setWebDetails(value: JSWebDetails) { - _web = value; - setLoading(true); - _webview.loadUrl("about:blank"); - if(!value.html.isNullOrEmpty()) - _webview.loadData(value.html, "text/html", null); - else - _webview.loadUrl(value.url ?: "about:blank"); - } - - private fun fetchPost() { - Logger.i(WebDetailView.TAG, "fetchWeb") - _web = null; - - val url = _url; - if (!url.isNullOrBlank()) { - setLoading(true); - _taskLoadPost.run(url); - } - } - - fun onDestroy() { - _webview.loadUrl("about:blank"); - } - - private fun setLoading(isLoading : Boolean) { - if (_isLoading == isLoading) { - return; - } - - _isLoading = isLoading; - - if(isLoading) { - (_imageLoader.drawable as Animatable?)?.start() - _layoutLoadingOverlay.visibility = View.VISIBLE; - } - else { - _layoutLoadingOverlay.visibility = View.GONE; - (_imageLoader.drawable as Animatable?)?.stop() - } - } - - companion object { - const val TAG = "WebDetailFragment" - } - } - - companion object { - fun newInstance() = WebDetailFragment().apply {} - } -} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt index 15952d0a..44d8a9ad 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/SearchTopBarFragment.kt @@ -88,6 +88,7 @@ class SearchTopBarFragment : TopFragment() { } else if (parameter is SuggestionsFragmentData) { this.setText(parameter.query); _searchType = parameter.searchType; + _channelUrl = parameter.channelUrl; } if(currentMain is SuggestionsFragment) @@ -113,7 +114,7 @@ class SearchTopBarFragment : TopFragment() { fun clear() { _editSearch?.text?.clear(); if (currentMain !is SuggestionsFragment) { - navigate(SuggestionsFragmentData("", _searchType), false); + navigate(SuggestionsFragmentData("", _searchType, _channelUrl), false); } else { onSearch.emit(""); } diff --git a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt new file mode 100644 index 00000000..ac3c61e0 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.mdns + +data class BroadcastService( + val deviceName: String, + val serviceName: String, + val port: UShort, + val ttl: UInt, + val weight: UShort, + val priority: UShort, + val texts: List? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt new file mode 100644 index 00000000..2c27edf8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt @@ -0,0 +1,93 @@ +package com.futo.platformplayer.mdns + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +enum class QueryResponse(val value: Byte) { + Query(0), + Response(1) +} + +enum class DnsOpcode(val value: Byte) { + StandardQuery(0), + InverseQuery(1), + ServerStatusRequest(2) +} + +enum class DnsResponseCode(val value: Byte) { + NoError(0), + FormatError(1), + ServerFailure(2), + NameError(3), + NotImplemented(4), + Refused(5) +} + +data class DnsPacketHeader( + val identifier: UShort, + val queryResponse: Int, + val opcode: Int, + val authoritativeAnswer: Boolean, + val truncated: Boolean, + val recursionDesired: Boolean, + val recursionAvailable: Boolean, + val answerAuthenticated: Boolean, + val nonAuthenticatedData: Boolean, + val responseCode: DnsResponseCode +) + +data class DnsPacket( + val header: DnsPacketHeader, + val questions: List, + val answers: List, + val authorities: List, + val additionals: List +) { + companion object { + fun parse(data: ByteArray): DnsPacket { + val span = data.asUByteArray() + val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort() + val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort() + val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort() + val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort() + val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort() + + var position = 12 + + val questions = List(questionCount.toInt()) { + DnsQuestion.parse(data, position).also { position = it.second } + }.map { it.first } + + val answers = List(answerCount.toInt()) { + DnsResourceRecord.parse(data, position).also { position = it.second } + }.map { it.first } + + val authorities = List(authorityCount.toInt()) { + DnsResourceRecord.parse(data, position).also { position = it.second } + }.map { it.first } + + val additionals = List(additionalCount.toInt()) { + DnsResourceRecord.parse(data, position).also { position = it.second } + }.map { it.first } + + return DnsPacket( + header = DnsPacketHeader( + identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(), + queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(), + opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(), + authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0, + truncated = (flags.toInt() shr 9) and 0b1 != 0, + recursionDesired = (flags.toInt() shr 8) and 0b1 != 0, + recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0, + answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0, + nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0, + responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111] + ), + questions = questions, + answers = answers, + authorities = authorities, + additionals = additionals + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt new file mode 100644 index 00000000..01a7bd77 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt @@ -0,0 +1,110 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.mdns.Extensions.readDomainName +import java.nio.ByteBuffer +import java.nio.ByteOrder + + +enum class QuestionType(val value: UShort) { + A(1u), + NS(2u), + MD(3u), + MF(4u), + CNAME(5u), + SOA(6u), + MB(7u), + MG(8u), + MR(9u), + NULL(10u), + WKS(11u), + PTR(12u), + HINFO(13u), + MINFO(14u), + MX(15u), + TXT(16u), + RP(17u), + AFSDB(18u), + SIG(24u), + KEY(25u), + AAAA(28u), + LOC(29u), + SRV(33u), + NAPTR(35u), + KX(36u), + CERT(37u), + DNAME(39u), + APL(42u), + DS(43u), + SSHFP(44u), + IPSECKEY(45u), + RRSIG(46u), + NSEC(47u), + DNSKEY(48u), + DHCID(49u), + NSEC3(50u), + NSEC3PARAM(51u), + TSLA(52u), + SMIMEA(53u), + HIP(55u), + CDS(59u), + CDNSKEY(60u), + OPENPGPKEY(61u), + CSYNC(62u), + ZONEMD(63u), + SVCB(64u), + HTTPS(65u), + EUI48(108u), + EUI64(109u), + TKEY(249u), + TSIG(250u), + URI(256u), + CAA(257u), + TA(32768u), + DLV(32769u), + AXFR(252u), + IXFR(251u), + OPT(41u), + MAILB(253u), + MALA(254u), + All(252u) +} + +enum class QuestionClass(val value: UShort) { + IN(1u), + CS(2u), + CH(3u), + HS(4u), + All(255u) +} + +data class DnsQuestion( + override val name: String, + override val type: Int, + override val clazz: Int, + val queryUnicast: Boolean +) : DnsResourceRecordBase(name, type, clazz) { + companion object { + fun parse(data: ByteArray, startPosition: Int): Pair { + val span = data.asUByteArray() + var position = startPosition + val qname = span.readDomainName(position).also { position = it.second } + val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + + return DnsQuestion( + name = qname.first, + type = qtype.toInt(), + queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0, + clazz = qclass.toInt() and 0b111111111111111 + ) to position + } + } +} + +open class DnsResourceRecordBase( + open val name: String, + open val type: Int, + open val clazz: Int +) diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt new file mode 100644 index 00000000..83c329ff --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt @@ -0,0 +1,514 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.mdns.Extensions.readDomainName +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets +import kotlin.math.pow +import java.net.InetAddress + +data class PTRRecord(val domainName: String) + +data class ARecord(val address: InetAddress) + +data class AAAARecord(val address: InetAddress) + +data class MXRecord(val preference: UShort, val exchange: String) + +data class CNAMERecord(val cname: String) + +data class TXTRecord(val texts: List) + +data class SOARecord( + val primaryNameServer: String, + val responsibleAuthorityMailbox: String, + val serialNumber: Int, + val refreshInterval: Int, + val retryInterval: Int, + val expiryLimit: Int, + val minimumTTL: Int +) + +data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String) + +data class NSRecord(val nameServer: String) + +data class CAARecord(val flags: Byte, val tag: String, val value: String) + +data class HINFORecord(val cpu: String, val os: String) + +data class RPRecord(val mailbox: String, val txtDomainName: String) + + +data class AFSDBRecord(val subtype: UShort, val hostname: String) +data class LOCRecord( + val version: Byte, + val size: Double, + val horizontalPrecision: Double, + val verticalPrecision: Double, + val latitude: Double, + val longitude: Double, + val altitude: Double +) { + companion object { + fun decodeSizeOrPrecision(coded: Byte): Double { + val baseValue = (coded.toInt() shr 4) and 0x0F + val exponent = coded.toInt() and 0x0F + return baseValue * 10.0.pow(exponent.toDouble()) + } + + fun decodeLatitudeOrLongitude(coded: Int): Double { + val arcSeconds = coded / 1E3 + return arcSeconds / 3600.0 + } + + fun decodeAltitude(coded: Int): Double { + return (coded / 100.0) - 100000.0 + } + } +} + +data class NAPTRRecord( + val order: UShort, + val preference: UShort, + val flags: String, + val services: String, + val regexp: String, + val replacement: String +) + +data class RRSIGRecord( + val typeCovered: UShort, + val algorithm: Byte, + val labels: Byte, + val originalTTL: UInt, + val signatureExpiration: UInt, + val signatureInception: UInt, + val keyTag: UShort, + val signersName: String, + val signature: ByteArray +) + +data class KXRecord(val preference: UShort, val exchanger: String) + +data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray) + + + +data class DNAMERecord(val target: String) + +data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray) + +data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray) + +data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) + +data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) + +data class URIRecord(val priority: UShort, val weight: UShort, val target: String) + +data class NSECRecord(val ownerName: String, val typeBitMaps: List>) +data class NSEC3Record( + val hashAlgorithm: Byte, + val flags: Byte, + val iterations: UShort, + val salt: ByteArray, + val nextHashedOwnerName: ByteArray, + val typeBitMaps: List +) + +data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray) +data class SPFRecord(val texts: List) +data class TKEYRecord( + val algorithm: String, + val inception: UInt, + val expiration: UInt, + val mode: UShort, + val error: UShort, + val keyData: ByteArray, + val otherData: ByteArray +) + +data class TSIGRecord( + val algorithmName: String, + val timeSigned: UInt, + val fudge: UShort, + val mac: ByteArray, + val originalID: UShort, + val error: UShort, + val otherData: ByteArray +) + +data class OPTRecordOption(val code: UShort, val data: ByteArray) +data class OPTRecord(val options: List) + +class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) { + + private val endPosition: Int = position + length + + fun readDomainName(): String { + return data.asUByteArray().readDomainName(position).also { position = it.second }.first + } + + fun readDouble(): Double { + checkRemainingBytes(Double.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double + position += Double.SIZE_BYTES + return result + } + + fun readInt16(): Short { + checkRemainingBytes(Short.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short + position += Short.SIZE_BYTES + return result + } + + fun readInt32(): Int { + checkRemainingBytes(Int.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int + position += Int.SIZE_BYTES + return result + } + + fun readInt64(): Long { + checkRemainingBytes(Long.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long + position += Long.SIZE_BYTES + return result + } + + fun readSingle(): Float { + checkRemainingBytes(Float.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float + position += Float.SIZE_BYTES + return result + } + + fun readByte(): Byte { + checkRemainingBytes(Byte.SIZE_BYTES) + return data[position++] + } + + fun readBytes(length: Int): ByteArray { + checkRemainingBytes(length) + return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) } + .also { position += length } + } + + fun readUInt16(): UShort { + checkRemainingBytes(Short.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort() + position += Short.SIZE_BYTES + return result + } + + fun readUInt32(): UInt { + checkRemainingBytes(Int.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt() + position += Int.SIZE_BYTES + return result + } + + fun readUInt64(): ULong { + checkRemainingBytes(Long.SIZE_BYTES) + val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong() + position += Long.SIZE_BYTES + return result + } + + fun readString(): String { + val length = data[position++].toInt() + checkRemainingBytes(length) + return String(data, position, length, StandardCharsets.UTF_8).also { position += length } + } + + private fun checkRemainingBytes(requiredBytes: Int) { + if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException() + } + + fun readRPRecord(): RPRecord { + return RPRecord(readDomainName(), readDomainName()) + } + + fun readKXRecord(): KXRecord { + val preference = readUInt16() + val exchanger = readDomainName() + return KXRecord(preference, exchanger) + } + + fun readCERTRecord(): CERTRecord { + val type = readUInt16() + val keyTag = readUInt16() + val algorithm = readByte() + val certificateLength = readUInt16().toInt() - 5 + val certificate = readBytes(certificateLength) + return CERTRecord(type, keyTag, algorithm, certificate) + } + + fun readPTRRecord(): PTRRecord { + return PTRRecord(readDomainName()) + } + + fun readARecord(): ARecord { + val address = readBytes(4) + return ARecord(InetAddress.getByAddress(address)) + } + + fun readAAAARecord(): AAAARecord { + val address = readBytes(16) + return AAAARecord(InetAddress.getByAddress(address)) + } + + fun readMXRecord(): MXRecord { + val preference = readUInt16() + val exchange = readDomainName() + return MXRecord(preference, exchange) + } + + fun readCNAMERecord(): CNAMERecord { + return CNAMERecord(readDomainName()) + } + + fun readTXTRecord(): TXTRecord { + val texts = mutableListOf() + while (position < endPosition) { + val textLength = data[position++].toInt() + checkRemainingBytes(textLength) + val text = String(data, position, textLength, StandardCharsets.UTF_8) + texts.add(text) + position += textLength + } + return TXTRecord(texts) + } + + fun readSOARecord(): SOARecord { + val primaryNameServer = readDomainName() + val responsibleAuthorityMailbox = readDomainName() + val serialNumber = readInt32() + val refreshInterval = readInt32() + val retryInterval = readInt32() + val expiryLimit = readInt32() + val minimumTTL = readInt32() + return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL) + } + + fun readSRVRecord(): SRVRecord { + val priority = readUInt16() + val weight = readUInt16() + val port = readUInt16() + val target = readDomainName() + return SRVRecord(priority, weight, port, target) + } + + fun readNSRecord(): NSRecord { + return NSRecord(readDomainName()) + } + + fun readCAARecord(): CAARecord { + val length = readUInt16().toInt() + val flags = readByte() + val tagLength = readByte().toInt() + val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength } + val valueLength = length - 1 - 1 - tagLength + val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength } + return CAARecord(flags, tag, value) + } + + fun readHINFORecord(): HINFORecord { + val cpuLength = readByte().toInt() + val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength } + val osLength = readByte().toInt() + val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength } + return HINFORecord(cpu, os) + } + + fun readAFSDBRecord(): AFSDBRecord { + return AFSDBRecord(readUInt16(), readDomainName()) + } + + fun readLOCRecord(): LOCRecord { + val version = readByte() + val size = LOCRecord.decodeSizeOrPrecision(readByte()) + val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) + val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) + val latitudeCoded = readInt32() + val longitudeCoded = readInt32() + val altitudeCoded = readInt32() + val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded) + val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded) + val altitude = LOCRecord.decodeAltitude(altitudeCoded) + return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude) + } + + fun readNAPTRRecord(): NAPTRRecord { + val order = readUInt16() + val preference = readUInt16() + val flags = readString() + val services = readString() + val regexp = readString() + val replacement = readDomainName() + return NAPTRRecord(order, preference, flags, services, regexp, replacement) + } + + fun readDNAMERecord(): DNAMERecord { + return DNAMERecord(readDomainName()) + } + + fun readDSRecord(): DSRecord { + val keyTag = readUInt16() + val algorithm = readByte() + val digestType = readByte() + val digestLength = readUInt16().toInt() - 4 + val digest = readBytes(digestLength) + return DSRecord(keyTag, algorithm, digestType, digest) + } + + fun readSSHFPRecord(): SSHFPRecord { + val algorithm = readByte() + val fingerprintType = readByte() + val fingerprintLength = readUInt16().toInt() - 2 + val fingerprint = readBytes(fingerprintLength) + return SSHFPRecord(algorithm, fingerprintType, fingerprint) + } + + fun readTLSARecord(): TLSARecord { + val usage = readByte() + val selector = readByte() + val matchingType = readByte() + val dataLength = readUInt16().toInt() - 3 + val certificateAssociationData = readBytes(dataLength) + return TLSARecord(usage, selector, matchingType, certificateAssociationData) + } + + fun readSMIMEARecord(): SMIMEARecord { + val usage = readByte() + val selector = readByte() + val matchingType = readByte() + val dataLength = readUInt16().toInt() - 3 + val certificateAssociationData = readBytes(dataLength) + return SMIMEARecord(usage, selector, matchingType, certificateAssociationData) + } + + fun readURIRecord(): URIRecord { + val priority = readUInt16() + val weight = readUInt16() + val length = readUInt16().toInt() + val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length } + return URIRecord(priority, weight, target) + } + + fun readRRSIGRecord(): RRSIGRecord { + val typeCovered = readUInt16() + val algorithm = readByte() + val labels = readByte() + val originalTTL = readUInt32() + val signatureExpiration = readUInt32() + val signatureInception = readUInt32() + val keyTag = readUInt16() + val signersName = readDomainName() + val signatureLength = readUInt16().toInt() + val signature = readBytes(signatureLength) + return RRSIGRecord( + typeCovered, + algorithm, + labels, + originalTTL, + signatureExpiration, + signatureInception, + keyTag, + signersName, + signature + ) + } + + fun readNSECRecord(): NSECRecord { + val ownerName = readDomainName() + val typeBitMaps = mutableListOf>() + while (position < endPosition) { + val windowBlock = readByte() + val bitmapLength = readByte().toInt() + val bitmap = readBytes(bitmapLength) + typeBitMaps.add(windowBlock to bitmap) + } + return NSECRecord(ownerName, typeBitMaps) + } + + fun readNSEC3Record(): NSEC3Record { + val hashAlgorithm = readByte() + val flags = readByte() + val iterations = readUInt16() + val saltLength = readByte().toInt() + val salt = readBytes(saltLength) + val hashLength = readByte().toInt() + val nextHashedOwnerName = readBytes(hashLength) + val bitMapLength = readUInt16().toInt() + val typeBitMaps = mutableListOf() + val endPos = position + bitMapLength + while (position < endPos) { + typeBitMaps.add(readUInt16()) + } + return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps) + } + + fun readNSEC3PARAMRecord(): NSEC3PARAMRecord { + val hashAlgorithm = readByte() + val flags = readByte() + val iterations = readUInt16() + val saltLength = readByte().toInt() + val salt = readBytes(saltLength) + return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt) + } + + + fun readSPFRecord(): SPFRecord { + val length = readUInt16().toInt() + val texts = mutableListOf() + val endPos = position + length + while (position < endPos) { + val textLength = readByte().toInt() + val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength } + texts.add(text) + } + return SPFRecord(texts) + } + + fun readTKEYRecord(): TKEYRecord { + val algorithm = readDomainName() + val inception = readUInt32() + val expiration = readUInt32() + val mode = readUInt16() + val error = readUInt16() + val keySize = readUInt16().toInt() + val keyData = readBytes(keySize) + val otherSize = readUInt16().toInt() + val otherData = readBytes(otherSize) + return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData) + } + + fun readTSIGRecord(): TSIGRecord { + val algorithmName = readDomainName() + val timeSigned = readUInt32() + val fudge = readUInt16() + val macSize = readUInt16().toInt() + val mac = readBytes(macSize) + val originalID = readUInt16() + val error = readUInt16() + val otherSize = readUInt16().toInt() + val otherData = readBytes(otherSize) + return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData) + } + + + + fun readOPTRecord(): OPTRecord { + val options = mutableListOf() + while (position < endPosition) { + val optionCode = readUInt16() + val optionLength = readUInt16().toInt() + val optionData = readBytes(optionLength) + options.add(OPTRecordOption(optionCode, optionData)) + } + return OPTRecord(options) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt new file mode 100644 index 00000000..87ec0e5f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt @@ -0,0 +1,117 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.mdns.Extensions.readDomainName + +enum class ResourceRecordType(val value: UShort) { + None(0u), + A(1u), + NS(2u), + MD(3u), + MF(4u), + CNAME(5u), + SOA(6u), + MB(7u), + MG(8u), + MR(9u), + NULL(10u), + WKS(11u), + PTR(12u), + HINFO(13u), + MINFO(14u), + MX(15u), + TXT(16u), + RP(17u), + AFSDB(18u), + SIG(24u), + KEY(25u), + AAAA(28u), + LOC(29u), + SRV(33u), + NAPTR(35u), + KX(36u), + CERT(37u), + DNAME(39u), + APL(42u), + DS(43u), + SSHFP(44u), + IPSECKEY(45u), + RRSIG(46u), + NSEC(47u), + DNSKEY(48u), + DHCID(49u), + NSEC3(50u), + NSEC3PARAM(51u), + TSLA(52u), + SMIMEA(53u), + HIP(55u), + CDS(59u), + CDNSKEY(60u), + OPENPGPKEY(61u), + CSYNC(62u), + ZONEMD(63u), + SVCB(64u), + HTTPS(65u), + EUI48(108u), + EUI64(109u), + TKEY(249u), + TSIG(250u), + URI(256u), + CAA(257u), + TA(32768u), + DLV(32769u), + AXFR(252u), + IXFR(251u), + OPT(41u) +} + +enum class ResourceRecordClass(val value: UShort) { + IN(1u), + CS(2u), + CH(3u), + HS(4u) +} + +data class DnsResourceRecord( + override val name: String, + override val type: Int, + override val clazz: Int, + val timeToLive: UInt, + val cacheFlush: Boolean, + val dataPosition: Int = -1, + val dataLength: Int = -1, + private val data: ByteArray? = null +) : DnsResourceRecordBase(name, type, clazz) { + + companion object { + fun parse(data: ByteArray, startPosition: Int): Pair { + val span = data.asUByteArray() + var position = startPosition + val name = span.readDomainName(position).also { position = it.second } + val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + position += 2 + val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or + (span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt() + position += 4 + val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() + val rdposition = position + 2 + position += 2 + rdlength.toInt() + + return DnsResourceRecord( + name = name.first, + type = type.toInt(), + clazz = clazz.toInt() and 0b1111111_11111111, + timeToLive = ttl, + cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0, + dataPosition = rdposition, + dataLength = rdlength.toInt(), + data = data + ) to position + } + } + + fun getDataReader(): DnsReader { + return DnsReader(data!!, dataPosition, dataLength) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt new file mode 100644 index 00000000..48a04580 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt @@ -0,0 +1,208 @@ +package com.futo.platformplayer.mdns + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets + +class DnsWriter { + private val data = mutableListOf() + private val namePositions = mutableMapOf() + + fun toByteArray(): ByteArray = data.toByteArray() + + fun writePacket( + header: DnsPacketHeader, + questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null, + answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null, + authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null, + additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null + ) { + if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null) + throw Exception("When question count is given, question writer should also be given.") + if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null) + throw Exception("When answer count is given, answer writer should also be given.") + if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null) + throw Exception("When authority count is given, authority writer should also be given.") + if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null) + throw Exception("When additionals count is given, additional writer should also be given.") + + writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0) + + repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) } + repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) } + repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) } + repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) } + } + + fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) { + write(header.identifier) + + var flags: UShort = 0u + flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort() + flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort() + flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort() + flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort() + flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort() + flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort() + flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort() + flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort() + flags = flags or header.responseCode.value.toUShort() + write(flags) + + write(questionCount.toUShort()) + write(answerCount.toUShort()) + write(authorityCount.toUShort()) + write(additionalsCount.toUShort()) + } + + fun writeDomainName(name: String) { + synchronized(namePositions) { + val labels = name.split('.') + for (label in labels) { + val nameAtOffset = name.substring(name.indexOf(label)) + if (namePositions.containsKey(nameAtOffset)) { + val position = namePositions[nameAtOffset]!! + val pointer = (0b11000000_00000000 or position).toUShort() + write(pointer) + return + } + if (label.isNotEmpty()) { + val labelBytes = label.toByteArray(StandardCharsets.UTF_8) + val nameStartPos = data.size + write(labelBytes.size.toByte()) + write(labelBytes) + namePositions[nameAtOffset] = nameStartPos + } + } + write(0.toByte()) + } + } + + fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) { + writeDomainName(value.name) + write(value.type.toUShort()) + val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort() + write(cls) + write(value.timeToLive) + + val lengthOffset = data.size + write(0.toUShort()) + dataWriter(this) + val rdLength = data.size - lengthOffset - 2 + val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array() + data[lengthOffset] = rdLengthBytes[0] + data[lengthOffset + 1] = rdLengthBytes[1] + } + + fun write(value: DnsQuestion) { + writeDomainName(value.name) + write(value.type.toUShort()) + write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort())) + } + + fun write(value: Double) { + val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array() + write(bytes) + } + + fun write(value: Short) { + val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array() + write(bytes) + } + + fun write(value: Int) { + val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array() + write(bytes) + } + + fun write(value: Long) { + val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array() + write(bytes) + } + + fun write(value: Float) { + val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array() + write(bytes) + } + + fun write(value: Byte) { + data.add(value) + } + + fun write(value: ByteArray) { + data.addAll(value.asIterable()) + } + + fun write(value: ByteArray, offset: Int, length: Int) { + data.addAll(value.slice(offset until offset + length)) + } + + fun write(value: UShort) { + val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array() + write(bytes) + } + + fun write(value: UInt) { + val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array() + write(bytes) + } + + fun write(value: ULong) { + val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array() + write(bytes) + } + + fun write(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + write(bytes.size.toByte()) + write(bytes) + } + + fun write(value: PTRRecord) { + writeDomainName(value.domainName) + } + + fun write(value: ARecord) { + val bytes = value.address.address + if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.") + write(bytes) + } + + fun write(value: AAAARecord) { + val bytes = value.address.address + if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.") + write(bytes) + } + + fun write(value: TXTRecord) { + value.texts.forEach { + val bytes = it.toByteArray(StandardCharsets.UTF_8) + write(bytes.size.toByte()) + write(bytes) + } + } + + fun write(value: SRVRecord) { + write(value.priority) + write(value.weight) + write(value.port) + writeDomainName(value.target) + } + + fun write(value: NSECRecord) { + writeDomainName(value.ownerName) + value.typeBitMaps.forEach { (windowBlock, bitmap) -> + write(windowBlock) + write(bitmap.size.toByte()) + write(bitmap) + } + } + + fun write(value: OPTRecord) { + value.options.forEach { option -> + write(option.code) + write(option.data.size.toUShort()) + write(option.data) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt new file mode 100644 index 00000000..48bb4c6a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt @@ -0,0 +1,63 @@ +package com.futo.platformplayer.mdns + +import android.util.Log + +object Extensions { + fun ByteArray.toByteDump(): String { + val result = StringBuilder() + for (i in indices) { + result.append(String.format("%02X ", this[i])) + + if ((i + 1) % 16 == 0 || i == size - 1) { + val padding = 3 * (16 - (i % 16 + 1)) + if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding)) + + result.append("; ") + val start = i - (i % 16) + val end = minOf(i, size - 1) + for (j in start..end) { + val ch = if (this[j] in 32..127) this[j].toChar() else '.' + result.append(ch) + } + if (i != size - 1) result.appendLine() + } + } + return result.toString() + } + + fun UByteArray.readDomainName(startPosition: Int): Pair { + var position = startPosition + return readDomainName(position, 0) + } + + private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair { + if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.") + + val domainParts = mutableListOf() + var newPosition = position + + while (true) { + if (newPosition < 0) + println() + + val length = this[newPosition].toUByte() + if ((length and 0b11000000u).toUInt() == 0b11000000u) { + val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt() + val (part, _) = this.readDomainName(offset.toInt(), depth + 1) + domainParts.add(part) + newPosition += 2 + break + } else if (length.toUInt() == 0u) { + newPosition++ + break + } else { + newPosition++ + val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8) + domainParts.add(part) + newPosition += length.toInt() + } + } + + return domainParts.joinToString(".") to newPosition + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt new file mode 100644 index 00000000..2b972d87 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt @@ -0,0 +1,495 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.* +import java.net.* +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +class MDNSListener { + companion object { + private val TAG = "MDNSListener" + const val MulticastPort = 5353 + val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251") + val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB") + val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort) + val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort) + } + + private val _lockObject = ReentrantLock() + private var _receiver4: MulticastSocket? = null + private var _receiver6: MulticastSocket? = null + private val _senders = mutableListOf() + private val _nicMonitor = NICMonitor() + private val _serviceRecordAggregator = ServiceRecordAggregator() + private var _started = false + private var _threadReceiver4: Thread? = null + private var _threadReceiver6: Thread? = null + private var _scope: CoroutineScope? = null + + var onPacket: ((DnsPacket) -> Unit)? = null + var onServicesUpdated: ((List) -> Unit)? = null + + private val _recordLockObject = ReentrantLock() + private val _recordsA = mutableListOf>() + private val _recordsAAAA = mutableListOf>() + private val _recordsPTR = mutableListOf>() + private val _recordsTXT = mutableListOf>() + private val _recordsSRV = mutableListOf>() + private val _services = mutableListOf() + + init { + _nicMonitor.added = { onNicsAdded(it) } + _nicMonitor.removed = { onNicsRemoved(it) } + _serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) } + } + + fun start() { + if (_started) { + Logger.i(TAG, "Already started.") + return + } + _started = true + + _scope = CoroutineScope(Dispatchers.IO); + + Logger.i(TAG, "Starting") + _lockObject.withLock { + val receiver4 = MulticastSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort)) + } + _receiver4 = receiver4 + + val receiver6 = MulticastSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort)) + } + _receiver6 = receiver6 + + _nicMonitor.start() + _serviceRecordAggregator.start() + onNicsAdded(_nicMonitor.current) + + _threadReceiver4 = Thread { + receiveLoop(receiver4) + }.apply { start() } + + _threadReceiver6 = Thread { + receiveLoop(receiver6) + }.apply { start() } + } + } + + fun queryServices(names: Array) { + if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") + + val writer = DnsWriter() + writer.writePacket( + DnsPacketHeader( + identifier = 0u, + queryResponse = QueryResponse.Query.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + truncated = false, + nonAuthenticatedData = false, + recursionDesired = false, + answerAuthenticated = false, + authoritativeAnswer = false, + recursionAvailable = false, + responseCode = DnsResponseCode.NoError + ), + questionCount = names.size, + questionWriter = { w, i -> + w.write( + DnsQuestion( + name = names[i], + type = QuestionType.PTR.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ) + ) + } + ) + + send(writer.toByteArray()) + } + + private fun send(data: ByteArray) { + _lockObject.withLock { + for (sender in _senders) { + try { + val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6 + sender.send(DatagramPacket(data, data.size, endPoint)) + } catch (e: Exception) { + Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.") + } + } + } + } + + fun queryAllQuestions(names: Array) { + if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") + + val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) } + questions.groupBy { it.name }.forEach { (_, questionsForHost) -> + val writer = DnsWriter() + writer.writePacket( + DnsPacketHeader( + identifier = 0u, + queryResponse = QueryResponse.Query.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + truncated = false, + nonAuthenticatedData = false, + recursionDesired = false, + answerAuthenticated = false, + authoritativeAnswer = false, + recursionAvailable = false, + responseCode = DnsResponseCode.NoError + ), + questionCount = questionsForHost.size, + questionWriter = { w, i -> w.write(questionsForHost[i]) } + ) + send(writer.toByteArray()) + } + } + + private fun onNicsAdded(nics: List) { + _lockObject.withLock { + if (!_started) return + + val addresses = nics.flatMap { nic -> + nic.interfaceAddresses.map { it.address } + .filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) } + } + + addresses.forEach { address -> + Logger.i(TAG, "New address discovered $address") + + try { + when (address) { + is Inet4Address -> { + _receiver4?.let { receiver4 -> + //receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) + receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) + } + + val sender = MulticastSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(address, MulticastPort)) + joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) + } + _senders.add(sender) + } + + is Inet6Address -> { + _receiver6?.let { receiver6 -> + //receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) + receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) + } + + val sender = MulticastSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(address, MulticastPort)) + joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) + } + _senders.add(sender) + } + + else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.") + } + } catch (e: Exception) { + Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.") + // Close the socket if there was an error + (_senders.lastOrNull() as? MulticastSocket)?.close() + } + } + } + + if (nics.isNotEmpty()) { + try { + updateBroadcastRecords() + broadcastRecords() + } catch (e: Exception) { + Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.") + } + } + } + + private fun onNicsRemoved(nics: List) { + _lockObject.withLock { + if (!_started) return + //TODO: Cleanup? + } + + if (nics.isNotEmpty()) { + try { + updateBroadcastRecords() + broadcastRecords() + } catch (e: Exception) { + Logger.e(TAG, "Exception occurred when broadcasting records", e) + } + } + } + + private fun receiveLoop(client: DatagramSocket) { + Logger.i(TAG, "Started receive loop") + + val buffer = ByteArray(8972) + val packet = DatagramPacket(buffer, buffer.size) + while (_started) { + try { + client.receive(packet) + handleResult(packet) + } catch (e: Exception) { + Logger.e(TAG, "An exception occurred while handling UDP result:", e) + } + } + + Logger.i(TAG, "Stopped receive loop") + } + + fun broadcastService( + deviceName: String, + serviceName: String, + port: UShort, + ttl: UInt = 120u, + weight: UShort = 0u, + priority: UShort = 0u, + texts: List? = null + ) { + _recordLockObject.withLock { + _services.add( + BroadcastService( + deviceName = deviceName, + port = port, + priority = priority, + serviceName = serviceName, + texts = texts, + ttl = ttl, + weight = weight + ) + ) + } + + updateBroadcastRecords() + broadcastRecords() + } + + private fun updateBroadcastRecords() { + _recordLockObject.withLock { + _recordsSRV.clear() + _recordsPTR.clear() + _recordsA.clear() + _recordsAAAA.clear() + _recordsTXT.clear() + + _services.forEach { service -> + val id = UUID.randomUUID().toString() + val deviceDomainName = "${service.deviceName}.${service.serviceName}" + val addressName = "$id.local" + + _recordsSRV.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.SRV.value.toInt(), + timeToLive = service.ttl, + name = deviceDomainName, + cacheFlush = false + ) to SRVRecord( + target = addressName, + port = service.port, + priority = service.priority, + weight = service.weight + ) + ) + + _recordsPTR.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.PTR.value.toInt(), + timeToLive = service.ttl, + name = service.serviceName, + cacheFlush = false + ) to PTRRecord( + domainName = deviceDomainName + ) + ) + + val addresses = _nicMonitor.current.flatMap { nic -> + nic.interfaceAddresses.map { it.address } + } + + addresses.forEach { address -> + when (address) { + is Inet4Address -> _recordsA.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.A.value.toInt(), + timeToLive = service.ttl, + name = addressName, + cacheFlush = false + ) to ARecord( + address = address + ) + ) + + is Inet6Address -> _recordsAAAA.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.AAAA.value.toInt(), + timeToLive = service.ttl, + name = addressName, + cacheFlush = false + ) to AAAARecord( + address = address + ) + ) + + else -> Logger.i(TAG, "Invalid address type: $address.") + } + } + + if (service.texts != null) { + _recordsTXT.add( + DnsResourceRecord( + clazz = ResourceRecordClass.IN.value.toInt(), + type = ResourceRecordType.TXT.value.toInt(), + timeToLive = service.ttl, + name = deviceDomainName, + cacheFlush = false + ) to TXTRecord( + texts = service.texts + ) + ) + } + } + } + } + + private fun broadcastRecords(questions: List? = null) { + val writer = DnsWriter() + _recordLockObject.withLock { + val recordsA: List> + val recordsAAAA: List> + val recordsPTR: List> + val recordsTXT: List> + val recordsSRV: List> + + if (questions != null) { + recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } + } else { + recordsA = _recordsA + recordsAAAA = _recordsAAAA + recordsPTR = _recordsPTR + recordsSRV = _recordsSRV + recordsTXT = _recordsTXT + } + + val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size + if (answerCount < 1) return + + val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + val ptrOffset = recordsA.size + recordsAAAA.size + val aaaaOffset = recordsA.size + + writer.writePacket( + DnsPacketHeader( + identifier = 0u, + queryResponse = QueryResponse.Response.value.toInt(), + opcode = DnsOpcode.StandardQuery.value.toInt(), + truncated = false, + nonAuthenticatedData = false, + recursionDesired = false, + answerAuthenticated = false, + authoritativeAnswer = true, + recursionAvailable = false, + responseCode = DnsResponseCode.NoError + ), + answerCount = answerCount, + answerWriter = { w, i -> + when { + i >= txtOffset -> { + val record = recordsTXT[i - txtOffset] + w.write(record.first) { it.write(record.second) } + } + + i >= srvOffset -> { + val record = recordsSRV[i - srvOffset] + w.write(record.first) { it.write(record.second) } + } + + i >= ptrOffset -> { + val record = recordsPTR[i - ptrOffset] + w.write(record.first) { it.write(record.second) } + } + + i >= aaaaOffset -> { + val record = recordsAAAA[i - aaaaOffset] + w.write(record.first) { it.write(record.second) } + } + + else -> { + val record = recordsA[i] + w.write(record.first) { it.write(record.second) } + } + } + } + ) + } + + send(writer.toByteArray()) + } + + private fun handleResult(result: DatagramPacket) { + try { + val packet = DnsPacket.parse(result.data) + if (packet.questions.isNotEmpty()) { + _scope?.launch(Dispatchers.IO) { + try { + broadcastRecords(packet.questions) + } catch (e: Throwable) { + Logger.i(TAG, "Broadcasting records failed", e) + } + } + + } + _serviceRecordAggregator.add(packet) + onPacket?.invoke(packet) + } catch (e: Exception) { + Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e) + } + } + + fun stop() { + _lockObject.withLock { + _started = false + + _scope?.cancel() + _scope = null + + _nicMonitor.stop() + _serviceRecordAggregator.stop() + + _receiver4?.close() + _receiver4 = null + + _receiver6?.close() + _receiver6 = null + + _senders.forEach { it.close() } + _senders.clear() + } + + _threadReceiver4?.join() + _threadReceiver4 = null + + _threadReceiver6?.join() + _threadReceiver6 = null + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt new file mode 100644 index 00000000..884e1514 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt @@ -0,0 +1,66 @@ +package com.futo.platformplayer.mdns + +import kotlinx.coroutines.* +import java.net.NetworkInterface + +class NICMonitor { + private val lockObject = Any() + private val nics = mutableListOf() + private var cts: Job? = null + + val current: List + get() = synchronized(nics) { nics.toList() } + + var added: ((List) -> Unit)? = null + var removed: ((List) -> Unit)? = null + + fun start() { + synchronized(lockObject) { + if (cts != null) throw Exception("Already started.") + + cts = CoroutineScope(Dispatchers.Default).launch { + loopAsync() + } + } + + nics.clear() + nics.addAll(getCurrentInterfaces().toList()) + } + + fun stop() { + synchronized(lockObject) { + cts?.cancel() + cts = null + } + + synchronized(nics) { + nics.clear() + } + } + + private suspend fun loopAsync() { + while (cts?.isActive == true) { + try { + val currentNics = getCurrentInterfaces().toList() + removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } }) + added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } }) + + synchronized(nics) { + nics.clear() + nics.addAll(currentNics) + } + } catch (ex: Exception) { + // Ignored + } + delay(5000) + } + } + + private fun getCurrentInterfaces(): List { + val nics = NetworkInterface.getNetworkInterfaces().toList() + .filter { it.isUp && !it.isLoopback } + + return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList() + .filter { it.isUp } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt new file mode 100644 index 00000000..f4a3e5e9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt @@ -0,0 +1,71 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.logging.Logger +import java.lang.Thread.sleep + +class ServiceDiscoverer(names: Array, private val _onServicesUpdated: (List) -> Unit) { + private val _names: Array + private var _listener: MDNSListener? = null + private var _started = false + private var _thread: Thread? = null + + init { + if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") + _names = names + } + + fun broadcastService( + deviceName: String, + serviceName: String, + port: UShort, + ttl: UInt = 120u, + weight: UShort = 0u, + priority: UShort = 0u, + texts: List? = null + ) { + _listener?.let { + it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts) + } + } + + fun stop() { + _started = false + _listener?.stop() + _listener = null + _thread?.join() + _thread = null + } + + fun start() { + if (_started) { + Logger.i(TAG, "Already started.") + return + } + _started = true + + val listener = MDNSListener() + _listener = listener + listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) } + listener.start() + + _thread = Thread { + try { + sleep(2000) + + while (_started) { + listener.queryServices(_names) + sleep(2000) + listener.queryAllQuestions(_names) + sleep(2000) + } + } catch (e: Throwable) { + Logger.i(TAG, "Exception in loop thread", e) + stop() + } + }.apply { start() } + } + + companion object { + private val TAG = "ServiceDiscoverer" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt new file mode 100644 index 00000000..5292d375 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt @@ -0,0 +1,226 @@ +package com.futo.platformplayer.mdns + +import com.futo.platformplayer.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.net.InetAddress +import java.util.Date + +data class DnsService( + var name: String, + var target: String, + var port: UShort, + val addresses: MutableList = mutableListOf(), + val pointers: MutableList = mutableListOf(), + val texts: MutableList = mutableListOf() +) + +data class CachedDnsAddressRecord( + val expirationTime: Date, + val address: InetAddress +) + +data class CachedDnsTxtRecord( + val expirationTime: Date, + val texts: List +) + +data class CachedDnsPtrRecord( + val expirationTime: Date, + val target: String +) + +data class CachedDnsSrvRecord( + val expirationTime: Date, + val service: SRVRecord +) + +class ServiceRecordAggregator { + private val _lockObject = Any() + private val _cachedAddressRecords = mutableMapOf>() + private val _cachedTxtRecords = mutableMapOf() + private val _cachedPtrRecords = mutableMapOf>() + private val _cachedSrvRecords = mutableMapOf() + private val _currentServices = mutableListOf() + private var _cts: Job? = null + + var onServicesUpdated: ((List) -> Unit)? = null + + fun start() { + synchronized(_lockObject) { + if (_cts != null) throw Exception("Already started.") + + _cts = CoroutineScope(Dispatchers.Default).launch { + try { + while (isActive) { + val now = Date() + synchronized(_currentServices) { + _cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } + _cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) } + _cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) } + _cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } + + val newServices = getCurrentServices() + _currentServices.clear() + _currentServices.addAll(newServices) + } + + onServicesUpdated?.invoke(_currentServices.toList()) + delay(5000) + } + } catch (e: Throwable) { + Logger.e(TAG, "Unexpected failure in MDNS loop", e) + } + } + } + } + + fun stop() { + synchronized(_lockObject) { + _cts?.cancel() + _cts = null + } + } + + fun add(packet: DnsPacket) { + val currentServices: List + val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities + val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() } + val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() } + val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() } + val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() } + val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() } + + /*val builder = StringBuilder() + builder.appendLine("Received records:") + srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") } + ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") } + txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") } + aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } + aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } + Logger.i(TAG, "$builder")*/ + + synchronized(this._currentServices) { + ptrRecords.forEach { record -> + val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } + val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) + cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName } + } + + aRecords.forEach { aRecord -> + val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() } + val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address) + cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address } + } + + aaaaRecords.forEach { aaaaRecord -> + val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() } + val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address) + cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address } + } + + txtRecords.forEach { txtRecord -> + _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts) + } + + srvRecords.forEach { srvRecord -> + _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) + } + + currentServices = getCurrentServices() + this._currentServices.clear() + this._currentServices.addAll(currentServices) + } + + onServicesUpdated?.invoke(currentServices) + } + + fun getAllQuestions(serviceName: String): List { + val questions = mutableListOf() + synchronized(_currentServices) { + val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList() + + val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target } + questions.addAll(ptrWithoutSrvRecord.flatMap { s -> + listOf( + DnsQuestion( + name = s, + type = QuestionType.SRV.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ) + ) + }) + + val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) } + questions.addAll(incompleteCurrentServices.flatMap { s -> + listOf( + DnsQuestion( + name = s.name, + type = QuestionType.TXT.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ), + DnsQuestion( + name = s.target, + type = QuestionType.A.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ), + DnsQuestion( + name = s.target, + type = QuestionType.AAAA.value.toInt(), + clazz = QuestionClass.IN.value.toInt(), + queryUnicast = false + ) + ) + }) + } + return questions + } + + private fun getCurrentServices(): MutableList { + val currentServices = _cachedSrvRecords.map { (key, value) -> + DnsService( + name = key, + target = value.service.target, + port = value.service.port + ) + }.toMutableList() + + currentServices.forEach { service -> + _cachedAddressRecords[service.target]?.let { + service.addresses.addAll(it.map { record -> record.address }) + } + } + + currentServices.forEach { service -> + service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key }) + } + + currentServices.forEach { service -> + _cachedTxtRecords[service.name]?.let { + service.texts.addAll(it.texts) + } + } + + return currentServices + } + + private inline fun MutableList.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) { + val index = indexOfFirst(predicate) + if (index >= 0) { + this[index] = newElement + } else { + add(newElement) + } + } + + private companion object { + private const val TAG = "ServiceRecordAggregator" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 83e2b45f..97fe6408 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -29,7 +29,7 @@ data class ImageVariable( Glide.with(imageView) .load(bitmap) .into(imageView) - } else if(resId != null && resId > 0) { + } else if(resId != null) { Glide.with(imageView) .load(resId) .into(imageView) diff --git a/app/src/main/java/com/futo/platformplayer/models/Playlist.kt b/app/src/main/java/com/futo/platformplayer/models/Playlist.kt index 758929d5..d7b1035f 100644 --- a/app/src/main/java/com/futo/platformplayer/models/Playlist.kt +++ b/app/src/main/java/com/futo/platformplayer/models/Playlist.kt @@ -5,7 +5,6 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.models.JSVideo -import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import kotlinx.serialization.Serializable @@ -36,15 +35,11 @@ class Playlist { this.videos = ArrayList(list); } - fun makeCopy(newName: String? = null): Playlist { - return Playlist(newName ?: name, videos) - } companion object { fun fromV8(config: SourcePluginConfig, obj: V8ValueObject?): Playlist? { if(obj == null) return null; - obj.ensureIsBusy(); val contextName = "Playlist"; diff --git a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt index 1b31a5fc..29229d6d 100644 --- a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt +++ b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt @@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient { //val domainParts = domain!!.split("."); //val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); val cookieDomain = domain!!.getSubdomainWildcardQuery(); - if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) }) + if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) _authConfig.cookiesToFind?.let { cookiesToFind -> val cookies = cookieString.split(";"); for(cookieStr in cookies) { diff --git a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt index b29ebd87..d524b7cf 100644 --- a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt +++ b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt @@ -8,16 +8,11 @@ import android.text.method.LinkMovementMethod import android.text.style.URLSpan import android.view.MotionEvent import android.widget.TextView -import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.timestampRegex -import com.futo.platformplayer.views.behavior.NonScrollingTextView -import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMethod() { @@ -65,43 +60,31 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe val dx = event.x - downX val dy = event.y - downY if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(widget, event)) { - for (link in pressedLinks!!) { - Logger.i(TAG) { "Link clicked '${link.url}'." } + runBlocking { + for (link in pressedLinks!!) { + Logger.i(TAG) { "Link clicked '${link.url}'." } - val c = _context - if (c is MainActivity) { - c.lifecycleScope.launch(Dispatchers.IO) { - if (c.handleUrl(link.url)) { - return@launch - } - if (timestampRegex.matches(link.url)) { - val tokens = link.url.split(':') - var time_s = -1L - when (tokens.size) { - 2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong() - 3 -> time_s = tokens[0].toLong() * 3600 + - tokens[1].toLong() * 60 + - tokens[2].toLong() - } - - if (time_s != -1L) { - withContext(Dispatchers.Main) { - MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) + if (_context is MainActivity) { + if (_context.handleUrl(link.url)) continue + if (timestampRegex.matches(link.url)) { + val tokens = link.url.split(':') + var time_s = -1L + when (tokens.size) { + 2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong() + 3 -> time_s = tokens[0].toLong() * 3600 + + tokens[1].toLong() * 60 + + tokens[2].toLong() } - return@launch - } - } - withContext(Dispatchers.Main) { - try { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to start activity.", e) + if (time_s != -1L) { + MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) + continue + } } } + _context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) } } - } pressedLinks = null linkPressed = false return true diff --git a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt index f9edf9e7..6b22d1a7 100644 --- a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt +++ b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt @@ -67,7 +67,7 @@ class WebViewRequirementExtractor { if(cookieString != null) { //val domainParts = domain!!.split("."); val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString("."); - if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) }) + if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) cookiesToFind?.let { cookiesToFind -> val cookies = cookieString.split(";"); for(cookieStr in cookies) { diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index ba6cdaf4..7d57a151 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -1,11 +1,5 @@ package com.futo.platformplayer.parsers -import android.net.Uri -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory -import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist -import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource @@ -13,15 +7,12 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.toYesNo import com.futo.platformplayer.yesNoToBoolean -import java.io.ByteArrayInputStream import java.net.URI import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import kotlin.text.ifEmpty class HLS { companion object { - @OptIn(UnstableApi::class) fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { val baseUrl = URI(sourceUrl).resolve("./").toString() @@ -58,31 +49,6 @@ class HLS { return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) } - fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? { - if (rendition.uri == null) { - return null - } - - val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") - return when (rendition.type) { - "AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, false, rendition.uri) - else -> null - } - } - - fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource { - var width: Int? = null - var height: Int? = null - val resolutionTokens = reference.streamInfo.resolution?.split('x') - if (resolutionTokens?.isNotEmpty() == true) { - width = resolutionTokens[0].toIntOrNull() - height = resolutionTokens[1].toIntOrNull() - } - - val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") - return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url) - } - fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { val lines = content.lines() val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() @@ -95,25 +61,7 @@ class HLS { val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } - val keyInfo = - lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",") - - val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"') - val iv = - keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x") - - val decryptionInfo: DecryptionInfo? = key?.let { k -> - DecryptionInfo(k, iv) - } - - val initSegment = - lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0) - ?.substringAfter("=")?.trim('"') val segments = mutableListOf() - if (initSegment != null) { - segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment))) - } - var currentSegment: MediaSegment? = null lines.forEach { line -> when { @@ -138,7 +86,7 @@ class HLS { } } - return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo) + return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) } fun parseAndGetVideoSources(source: Any, content: String, url: String): List { @@ -322,7 +270,7 @@ class HLS { val name: String?, val isDefault: Boolean?, val isAutoSelect: Boolean?, - val isForced: Boolean?, + val isForced: Boolean? ) { fun toM3U8Line(): String = buildString { append("#EXT-X-MEDIA:") @@ -371,13 +319,30 @@ class HLS { fun getVideoSources(): List { return variantPlaylistsRefs.map { - variantReferenceToVariant(it) + var width: Int? = null + var height: Int? = null + val resolutionTokens = it.streamInfo.resolution?.split('x') + if (resolutionTokens?.isNotEmpty() == true) { + width = resolutionTokens[0].toIntOrNull() + height = resolutionTokens[1].toIntOrNull() + } + + val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url) } } fun getAudioSources(): List { return mediaRenditions.mapNotNull { - return@mapNotNull mediaRenditionToVariant(it) + if (it.uri == null) { + return@mapNotNull null + } + + val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") + return@mapNotNull when (it.type) { + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri) + else -> null + } } } @@ -403,11 +368,6 @@ class HLS { } } - data class DecryptionInfo( - val keyUrl: String, - val iv: String? - ) - data class VariantPlaylist( val version: Int?, val targetDuration: Int?, @@ -416,8 +376,7 @@ class HLS { val programDateTime: ZonedDateTime?, val playlistType: String?, val streamInfo: StreamInfo?, - val segments: List, - val decryptionInfo: DecryptionInfo? = null + val segments: List ) { fun buildM3U8(): String = buildString { append("#EXTM3U\n") diff --git a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt index 5251fa75..5540406a 100644 --- a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt @@ -21,7 +21,6 @@ class MediaControlReceiver : BroadcastReceiver() { EVENT_NEXT -> onNextReceived.emit(); EVENT_PREV -> onPreviousReceived.emit(); EVENT_CLOSE -> onCloseReceived.emit(); - EVENT_BACKGROUND -> onBackgroundReceived.emit(); } } catch(ex: Throwable) { @@ -39,7 +38,6 @@ class MediaControlReceiver : BroadcastReceiver() { const val EVENT_NEXT = "Next"; const val EVENT_PREV = "Prev"; const val EVENT_CLOSE = "Close"; - const val EVENT_BACKGROUND = "Background"; val onPlayReceived = Event0(); val onPauseReceived = Event0(); @@ -50,7 +48,6 @@ class MediaControlReceiver : BroadcastReceiver() { val onLowerVolumeReceived = Event0(); val onCloseReceived = Event0() - val onBackgroundReceived = Event0() fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY); @@ -67,8 +64,5 @@ class MediaControlReceiver : BroadcastReceiver() { fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE); },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); - fun getToBackgroundIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { - this.putExtra(EXTRA_MEDIA_ACTION, EVENT_BACKGROUND); - },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index 9e9d112b..faee4e3b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -1,6 +1,5 @@ package com.futo.platformplayer.serializers -import com.futo.platformplayer.sToOffsetDateTimeUTC import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -38,7 +37,7 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MAX; else if(epochSecond < -9999999999) return OffsetDateTime.MIN; - return epochSecond.sToOffsetDateTimeUTC() + return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); } } class OffsetDateTimeStringSerializer : KSerializer { diff --git a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt index 8141d191..b39b4592 100644 --- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -48,7 +48,6 @@ class DownloadService : Service() { private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private var _notificationManager: NotificationManager? = null; private var _notificationChannel: NotificationChannel? = null; - private var _isForeground = false private val _client = ManagedHttpClient(OkHttpClient.Builder() //.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081))) @@ -63,11 +62,10 @@ class DownloadService : Service() { Logger.i(TAG, "onStartCommand"); synchronized(this) { if(_started) - return START_NOT_STICKY; + return START_STICKY; if(!FragmentedStorage.isInitialized) { Logger.i(TAG, "Attempted to start DownloadService without initialized files"); - stopSelf() closeDownloadSession(); return START_NOT_STICKY; } @@ -118,22 +116,6 @@ class DownloadService : Service() { override fun onCreate() { Logger.i(TAG, "onCreate"); super.onCreate() - - setupNotificationRequirements() - - val bootstrapNotif = NotificationCompat.Builder(this, DOWNLOAD_NOTIF_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_download) - .setContentTitle("Preparing downloads...") - .setOngoing(true) - .setSilent(true) - .build() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif, FOREGROUND_SERVICE_TYPE_DATA_SYNC) - else - startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif) - - _isForeground = true } override fun onBind(p0: Intent?): IBinder? { @@ -264,14 +246,15 @@ class DownloadService : Service() { } private fun notifyDownload(download: VideoDownload?) { - val channelId = DOWNLOAD_NOTIF_CHANNEL_ID + val channel = _notificationChannel ?: return; + val bringUpIntent = Intent(this, MainActivity::class.java); bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); bringUpIntent.action = "TAB"; bringUpIntent.putExtra("TAB", "Downloads"); - val builder = if(download != null) - NotificationCompat.Builder(this, channelId) + var builder = if(download != null) + NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG) .setSmallIcon(R.drawable.ic_download) .setOngoing(true) .setSilent(true) @@ -279,16 +262,16 @@ class DownloadService : Service() { .setContentTitle("${download.state}: ${download.name}") .setContentText(download.getDownloadInfo()) .setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0) - .setChannelId(channelId) + .setChannelId(channel.id) else - NotificationCompat.Builder(this, channelId) + NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG) .setSmallIcon(R.drawable.ic_download) .setOngoing(true) .setSilent(true) .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) .setContentTitle("Preparing for download...") .setContentText("Initializing download process...") - .setChannelId(channelId) + .setChannelId(channel.id) val notif = builder.build(); notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index c7174c12..3adbe9c8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -29,7 +29,6 @@ import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity -import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker @@ -156,8 +155,6 @@ class StateApp { return thisContext; } - private var _mainId: String? = null; - //Files private var _tempDirectory: File? = null; private var _cacheDirectory: File? = null; @@ -297,12 +294,9 @@ class StateApp { } //Lifecycle - fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null, mainId: String? = null) { - _mainId = mainId; + fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) { _context = context; _scope = coroutineScope - Logger.w(TAG, "Scope initialized ${(coroutineScope != null)}\n ${Log.getStackTraceString(Throwable())}") - } fun initializeFiles(force: Boolean = false) { @@ -417,15 +411,7 @@ class StateApp { } if (Settings.instance.synchronization.enabled) { - StateSync.instance.start(context) - } - - settingsActivityClosed.subscribe { - if (Settings.instance.synchronization.enabled) { - StateSync.instance.start(context) - } else { - StateSync.instance.stop() - } + StateSync.instance.start() } Logger.onLogSubmitted.subscribe { @@ -523,33 +509,22 @@ class StateApp { //Migration Logger.i(TAG, "MainApp Started: Check [Migrations]"); - - scopeOrNull?.launch(Dispatchers.IO) { - try { - migrateStores(context, listOf( - StateSubscriptions.instance.toMigrateCheck(), - StatePlaylists.instance.toMigrateCheck() - ).flatten(), 0) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to migrate stores") - } - } + migrateStores(context, listOf( + StateSubscriptions.instance.toMigrateCheck(), + StatePlaylists.instance.toMigrateCheck() + ).flatten(), 0); if(Settings.instance.subscriptions.fetchOnAppBoot) { scope.launch(Dispatchers.IO) { Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]"); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n"); - val isBelowRateLimit = !subRequestCounts.any { clientCount -> - clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true - }; - if (isBelowRateLimit) { + val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }; + if (isRateLimitReached) { Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); delay(5000); - scopeOrNull?.let { - if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) - StateSubscriptions.instance.updateSubscriptionFeed(it, false); - } + if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) + StateSubscriptions.instance.updateSubscriptionFeed(scope, false); } else Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); @@ -636,20 +611,6 @@ class StateApp { } } } - - scopeOrNull?.launch(Dispatchers.IO) { - val enabledPlugins = StatePlatform.instance.getEnabledClients(); - for(plugin in enabledPlugins) { - try { - if(plugin is JSClient) { - if(plugin.descriptor.appSettings.sync.enableHistorySync == true) - StateHistory.instance.syncRemoteHistory(plugin); - } - } catch (ex: Throwable) { - Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex); - } - } - } } fun mainAppStartedWithExternalFiles(context: Context) { @@ -714,33 +675,19 @@ class StateApp { } - private suspend fun migrateStores(context: Context, managedStores: List>, index: Int) { + private fun migrateStores(context: Context, managedStores: List>, index: Int) { if(managedStores.size <= index) return; val store = managedStores[index]; - if(store.hasMissingReconstructions()) { - withContext(Dispatchers.Main) { - try { - UIDialogs.showMigrateDialog(context, store) { - scopeOrNull?.launch(Dispatchers.IO) { - try { - migrateStores(context, managedStores, index + 1); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to migrate store", e) - } - } - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to migrate stores", e) - } - } - } else + if(store.hasMissingReconstructions()) + UIDialogs.showMigrateDialog(context, store) { + migrateStores(context, managedStores, index + 1); + }; + else migrateStores(context, managedStores, index + 1); } - fun mainAppDestroyed(context: Context, mainId: String? = null) { - if (mainId != null && (_mainId != mainId || _mainId == null)) - return + fun mainAppDestroyed(context: Context) { Logger.i(TAG, "App ended"); _receiverBecomingNoisy?.let { _receiverBecomingNoisy = null; @@ -756,7 +703,6 @@ class StateApp { StatePlayer.instance.closeMediaSession(); StateCasting.instance.stop(); - StateSync.instance.stop(); StatePlayer.dispose(); Companion.dispose(); _fileLogConsumer?.close(); @@ -764,8 +710,7 @@ class StateApp { fun dispose(){ _context = null; - // _scope = null; - Logger.w(TAG, "StateApp disposed: ${Log.getStackTraceString(Throwable())}") + _scope = null; } private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index 42ff55f4..e82cd0da 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -383,7 +383,7 @@ class StateDownloads { } private fun validateDownload(videoState: VideoDownload) { if(_downloading.hasItem { it.videoEither.url == videoState.videoEither.url }) - throw IllegalStateException("Video [${videoState.name}] is already queued for download"); + throw IllegalStateException("Video [${videoState.name}] is already queued for dowload"); val existing = getCachedVideo(videoState.id); if(existing != null) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt index f3bcca55..3047731d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -1,18 +1,15 @@ package com.futo.platformplayer.states import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo -import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.ImportCache -import com.futo.platformplayer.states.StateApp.Companion +import com.futo.platformplayer.states.StatePlaylists.Companion import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.stores.StringDateMapStorage import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.ReconstructStore @@ -22,6 +19,7 @@ import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap +import kotlin.math.min class StateHistory { //Legacy @@ -33,8 +31,6 @@ class StateHistory { }) .load(); - private val _remoteHistoryDatesStore = FragmentedStorage.get("remoteHistoryDates"); - private val historyIndex: ConcurrentMap = ConcurrentHashMap(); val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) .withIndex({ it.url }, historyIndex, false, true) @@ -135,13 +131,8 @@ class StateHistory { fun getHistoryPager(): IPager { return _historyDBStore.getObjectPager(); } - fun getHistorySearchPager(query: String, withAuthor: Boolean = false): IPager { - return if(!withAuthor) - _historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10) - else - _historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10) - //_historyDBStore.queryLike2ObjectPager(DBHistory.Index::name, DBHistory.Index::auth,"%${query}%", 10) - //TODO: See if we can include author name? + fun getHistorySearchPager(query: String): IPager { + return _historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10); } fun getHistoryIndexByUrl(url: String): DBHistory.Index? { return historyIndex[url]; @@ -190,95 +181,8 @@ class StateHistory { val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 }; for(item in toDelete) _historyDBStore.delete(item); - _remoteHistoryDatesStore.map = HashMap(); - _remoteHistoryDatesStore.save(); } - fun syncRemoteHistory(plugin: JSClient) { - if (plugin.capabilities.hasGetUserHistory && - plugin.isLoggedIn) { - Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]"); - - val hist = StatePlatform.instance.getUserHistory(plugin.id); - - syncRemoteHistory(plugin.id, hist, 100, 3); - } - } - fun syncRemoteHistory(pluginId: String, videos: IPager, maxVideos: Int, maxPages: Int) { - val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN; - val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos; - val maxPageCount = if(maxPages <= 0) 3 else maxPages; - var exceededDate = false; - try { - val toSync = mutableListOf(); - var pageCount = 0; - var videoCount = 0; - var isFirst = true; - var oldestPlayback = OffsetDateTime.MAX; - var newestPlayback = OffsetDateTime.MIN; - do { - if (!isFirst) videos.nextPage(); - val newVideos = videos.getResults(); - - var foundVideos = false; - var toSyncAddedCount = 0; - for(video in newVideos) { - if(video is IPlatformVideo && video.playbackDate != null) { - - if(video.playbackDate!! < lastDate) { - exceededDate = true; - break; - } - - if(video.playbackTime > 0) { - toSync.add(video); - toSyncAddedCount++; - foundVideos = true; - oldestPlayback = video.playbackDate!!; - if(newestPlayback == OffsetDateTime.MIN) - newestPlayback = video.playbackDate!!; - } - } - } - - pageCount++; - videoCount += newVideos.size; - isFirst = false; - - if(!foundVideos) - { - Logger.i(TAG, "Found no more videos in remote history"); - break; - } - } - while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate); - - var updated = 0; - if(oldestPlayback < OffsetDateTime.MAX) { - for(video in toSync){ - val hist = getHistoryByVideo(video, true, video.playbackDate); - if(hist != null && hist.position < video.playbackTime) { - Logger.i(TAG, "Updated history for video [${video.name}] from remote history"); - updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false); - updated++; - } - } - if(updated > 0) { - _remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback); - - try { - val client = StatePlatform.instance.getClient(pluginId); - UIDialogs.appToast("Updated ${updated} history from ${client.name}") - } - catch(ex: Throwable){} - } - } - } - catch(ex: Throwable) { - val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null; - Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message) - } - } companion object { val TAG = "StateHistory"; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index cba656dd..dfddd51f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -2,10 +2,10 @@ package com.futo.platformplayer.states import android.content.Context import androidx.collection.LruCache -import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.PlatformMultiClientPool @@ -39,7 +39,6 @@ import com.futo.platformplayer.awaitFirstNotNullDeferred import com.futo.platformplayer.constructs.BatchedTaskHandler import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException import com.futo.platformplayer.fromPool import com.futo.platformplayer.getNowDiffDays import com.futo.platformplayer.getNowDiffSeconds @@ -47,6 +46,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.ToastView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -56,6 +56,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.internal.concat import java.lang.Thread.sleep import java.time.OffsetDateTime import kotlin.streams.asSequence @@ -93,11 +94,9 @@ class StatePlatform { private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode - private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls private val _icons : HashMap = HashMap(); - private val _iconsByName : HashMap = HashMap(); val hasClients: Boolean get() = _availableClients.size > 0; @@ -114,14 +113,14 @@ class StatePlatform { Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]"); if(!StateApp.instance.privateMode) { - _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { + _enabledClients.find { it.isContentDetailsUrl(url) }?.let { _mainClientPool.getClientPooled(it).getContentDetails(url) } ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } else { Logger.i(TAG, "Fetching details with private client"); - _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { + _enabledClients.find { it.isContentDetailsUrl(url) }?.let { _privateClientPool.getClientPooled(it).getContentDetails(url) } ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); @@ -193,7 +192,6 @@ class StatePlatform { _availableClients.clear(); _icons.clear(); - _iconsByName.clear() _icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red); StatePlugins.instance.updateEmbeddedPlugins(context); @@ -202,8 +200,6 @@ class StatePlatform { for (plugin in StatePlugins.instance.getPlugins()) { _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: ImageVariable(plugin.config.absoluteIconUrl, null); - _iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: - ImageVariable(plugin.config.absoluteIconUrl, null); val client = JSClient(context, plugin); client.onCaptchaException.subscribe { c, ex -> @@ -303,33 +299,13 @@ class StatePlatform { return null; } - fun getPlatformIconByName(name: String?) : ImageVariable? { - if(name == null) - return null; - val nameLower = name.lowercase() - if(_iconsByName.containsKey(nameLower)) - return _iconsByName[nameLower]; - return null; - } - fun setPlatformOrder(platformOrder: List) { _platformOrderPersistent.values.clear(); _platformOrderPersistent.values.addAll(platformOrder); _platformOrderPersistent.save(); } - fun handleReloadRequired(reloadRequiredException: ScriptReloadRequiredException, afterReload: (() -> Unit)? = null) { - val id = if(reloadRequiredException.config is SourcePluginConfig) reloadRequiredException.config.id else ""; - UIDialogs.appToast("Reloading [${reloadRequiredException.config.name}] by plugin request"); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - if(!reloadRequiredException.reloadData.isNullOrEmpty()) - reEnableClientWithData(id, reloadRequiredException.reloadData, afterReload); - else - reEnableClient(id, afterReload); - } - } - - suspend fun reloadClient(context: Context, id: String, afterReload: (()->Unit)? = null) : JSClient? { + suspend fun reloadClient(context: Context, id: String) : JSClient? { return withContext(Dispatchers.IO) { val client = getClient(id); if (client !is JSClient) @@ -360,27 +336,10 @@ class StatePlatform { _availableClients.removeIf { it.id == id }; _availableClients.add(newClient); } - afterReload?.invoke(); return@withContext newClient; }; } - suspend fun reEnableClientWithData(id: String, data: String? = null, afterReload: (()->Unit)? = null) { - val enabledBefore = getEnabledClients().map { it.id }; - if(data != null) { - val client = getClientOrNull(id); - if(client != null && client is JSClient) - client.setReloadData(data); - } - selectClients({ - _scope.launch(Dispatchers.IO) { - selectClients({ - afterReload?.invoke(); - }, *(enabledBefore).distinct().toTypedArray()); - } - }, *(enabledBefore.filter { it != id }).distinct().toTypedArray()) - } - suspend fun reEnableClient(id: String, afterReload: (()->Unit)? = null) = reEnableClientWithData(id, null, afterReload); suspend fun enableClient(ids: List) { val currentClients = getEnabledClients().map { it.id }; @@ -391,13 +350,9 @@ class StatePlatform { * If a client is disabled, NO requests are made to said client */ suspend fun selectClients(vararg ids: String) { - selectClients(null, *ids); - } - suspend fun selectClients(afterLoad: (() -> Unit)?, vararg ids: String) { withContext(Dispatchers.IO) { - var removed: MutableList; synchronized(_clientsLock) { - removed = _enabledClients.toMutableList(); + val removed = _enabledClients.toMutableList(); _enabledClients.clear(); for (id in ids) { val client = getClient(id); @@ -413,13 +368,12 @@ class StatePlatform { } _enabledClientsPersistent.set(*ids); _enabledClientsPersistent.save(); - } - for (oldClient in removed) { - oldClient.disable(); - onSourceDisabled.emit(oldClient); + for (oldClient in removed) { + oldClient.disable(); + onSourceDisabled.emit(oldClient); + } } - afterLoad?.invoke(); }; } @@ -463,47 +417,6 @@ class StatePlatform { pager.initialize(); return pager; } - fun getShorts(): IPager { - Logger.i(TAG, "Platform - getShorts"); - var clientIdsOngoing = mutableListOf(); - val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true }; - - StateApp.instance.scopeOrNull?.let { - it.launch(Dispatchers.Default) { - try { - // plugins that take longer than 5 seconds to load are considered "slow" - delay(5000); - val slowClients = synchronized(clientIdsOngoing) { - return@synchronized clients.filter { clientIdsOngoing.contains(it.id) }; - }; - for(client in slowClients) - UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to show toast for slow source.", e) - } - } - } - - val pages = clients.parallelStream() - .map { - Logger.i(TAG, "getShorts - ${it.name}") - synchronized(clientIdsOngoing) { - clientIdsOngoing.add(it.id); - } - val shortsResult = it.fromPool(_pagerClientPool).getShorts(); - synchronized(clientIdsOngoing) { - clientIdsOngoing.remove(it.id); - } - return@map shortsResult; - } - .asSequence() - .toList() - .associateWith { 1f }; - - val pager = MultiDistributionContentPager(pages); - pager.initialize(); - return pager; - } suspend fun getHomeRefresh(scope: CoroutineScope): IPager { Logger.i(TAG, "Platform - getHome (Refresh)"); val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; @@ -719,33 +632,12 @@ class StatePlatform { return pager; } - fun searchChannelsAsContent(query: String): IPager { - Logger.i(TAG, "Platform - searchChannels"); - val pagers = mutableMapOf, Float>(); - getSortedEnabledClient().parallelStream().forEach { - try { - if (it.capabilities.hasChannelSearch) - pagers.put(it.searchChannelsAsContent(query), 1f); - } - catch(ex: Throwable) { - Logger.e(TAG, "Failed search channels", ex) - UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})"); - } - }; - if(pagers.isEmpty()) - return EmptyPager(); - - val pager = MultiDistributionContentPager(pagers); - pager.initialize(); - return pager; - } - //Video - fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; + fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url) ?: throw NoPlatformClientException("No client enabled that supports this content url (${url})"); - fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; + fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) }; fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred { Logger.i(TAG, "Platform - getContentDetails (${url})"); if(forceRefetch) @@ -786,14 +678,14 @@ class StatePlatform { return client.getContentRecommendations(url); } - fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) }; + fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; fun getChannelClient(url : String, exclude: List? = null) : IPlatformClient = getChannelClientOrNull(url, exclude) ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); fun getChannelClientOrNull(url : String, exclude: List? = null) : IPlatformClient? = if(exclude == null) - getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) } + getEnabledClients().find { it.isChannelUrl(url) } else - getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) }; + getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) }; fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred { Logger.i(TAG, "Platform - getChannel"); @@ -805,7 +697,7 @@ class StatePlatform { } } - fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, type: String? = null): IPager { + fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager { val clientCapabilities = baseClient.getChannelCapabilities(); val client = if(usePooledClients > 1) _channelClientPool.getClientPooled(baseClient, usePooledClients); @@ -814,75 +706,66 @@ class StatePlatform { var lastStream: OffsetDateTime? = null; val pagerResult: IPager; - if (type == null) { - if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && - ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || - clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || - clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || - clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) - )) { - val toQuery = mutableListOf(); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - toQuery.add(ResultCapabilities.TYPE_VIDEOS); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) - toQuery.add(ResultCapabilities.TYPE_STREAMS); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) - toQuery.add(ResultCapabilities.TYPE_LIVE); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) - toQuery.add(ResultCapabilities.TYPE_POSTS); + if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && + ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || + clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || + clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || + clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) + )) { + val toQuery = mutableListOf(); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + toQuery.add(ResultCapabilities.TYPE_VIDEOS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) + toQuery.add(ResultCapabilities.TYPE_STREAMS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) + toQuery.add(ResultCapabilities.TYPE_LIVE); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) + toQuery.add(ResultCapabilities.TYPE_POSTS); - if(isSubscriptionOptimized) { - val sub = StateSubscriptions.instance.getSubscription(channelUrl); - if(sub != null) { - if(!sub.shouldFetchStreams()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_LIVE); - } - if(!sub.shouldFetchLiveStreams()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_STREAMS); - } - if(!sub.shouldFetchPosts()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_POSTS); - } + if(isSubscriptionOptimized) { + val sub = StateSubscriptions.instance.getSubscription(channelUrl); + if(sub != null) { + if(!sub.shouldFetchStreams()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_LIVE); + } + if(!sub.shouldFetchLiveStreams()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_STREAMS); + } + if(!sub.shouldFetchPosts()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_POSTS); } } + } - //Merged pager - val pagers = toQuery - .parallelStream() - .map { - val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; + //Merged pager + val pagers = toQuery + .parallelStream() + .map { + val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; - when(it) { - ResultCapabilities.TYPE_STREAMS -> { - val streamResults = results.getResults(); - if(streamResults.size == 0) - lastStream = OffsetDateTime.MIN; - else - lastStream = results.getResults().firstOrNull()?.datetime; - } + when(it) { + ResultCapabilities.TYPE_STREAMS -> { + val streamResults = results.getResults(); + if(streamResults.size == 0) + lastStream = OffsetDateTime.MIN; + else + lastStream = results.getResults().firstOrNull()?.datetime; } - return@map results; } - .asSequence() - .toList(); + return@map results; + } + .asSequence() + .toList(); - val pager = MultiChronoContentPager(pagers.toTypedArray()); - pager.initialize(); - pagerResult = pager; - } - else { - pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); - } - } else { - pagerResult = if (type == ResultCapabilities.TYPE_SHORTS && clientCapabilities.hasType(ResultCapabilities.TYPE_SHORTS)) { - client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL); - } else { - EmptyPager() - } + val pager = MultiChronoContentPager(pagers.toTypedArray()); + pager.initialize(); + pagerResult = pager; } + else + pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); //Subscription optimization val sub = StateSubscriptions.instance.getSubscription(channelUrl); @@ -934,10 +817,10 @@ class StatePlatform { return pagerResult; } - fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List? = null, type: String? = null): IPager { + fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List? = null): IPager { Logger.i(TAG, "Platform - getChannelVideos"); val baseClient = getChannelClient(channelUrl, ignorePlugins); - return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, type); + return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients); } fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager { val client = getChannelClient(channelUrl); @@ -989,9 +872,9 @@ class StatePlatform { return urls; } - fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }; - fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) } - fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) } + fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) }; + fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) } + fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) } ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})"); fun getPlaylist(url: String): IPlatformPlaylistDetails { return getPlaylistClient(url).getPlaylist(url); @@ -1011,7 +894,7 @@ class StatePlatform { return EmptyPager(); if(!StateApp.instance.privateMode) - return client.fromPool(_pagerClientPool).getComments(url); + return client.fromPool(_mainClientPool).getComments(url); else return client.fromPool(_privateClientPool).getComments(url); } @@ -1036,16 +919,6 @@ class StatePlatform { return client.getLiveChatWindow(url); } - //Account - fun getUserHistory(id: String): IPager { - val client = getClient(id); - if(client is JSClient && client.isLoggedIn) { - return client.fromPool(_pagerClientPool).getUserHistory() - } - return EmptyPager(); - } - - fun injectDevPlugin(source: SourcePluginConfig, script: String): String? { var devId: String? = null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index eae8adf5..b8368ea5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -38,7 +38,6 @@ class StatePlayer { //Players private var _exoplayer : PlayerManager? = null; private var _thumbnailExoPlayer : PlayerManager? = null; - private var _shortExoPlayer: PlayerManager? = null //Video Status var rotationLock: Boolean = false @@ -599,7 +598,7 @@ class StatePlayer { } if(_queuePosition < _queue.size) { - return getCurrentQueueItem(); + return _queue[_queuePosition]; } } return null; @@ -634,13 +633,6 @@ class StatePlayer { } return _thumbnailExoPlayer!!; } - fun getShortPlayerOrCreate(context: Context) : PlayerManager { - if(_shortExoPlayer == null) { - val player = createExoPlayer(context); - _shortExoPlayer = PlayerManager(player); - } - return _shortExoPlayer!!; - } @OptIn(UnstableApi::class) private fun createExoPlayer(context : Context): ExoPlayer { @@ -664,13 +656,10 @@ class StatePlayer { fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; - val shortPlayer = _shortExoPlayer _exoplayer = null; _thumbnailExoPlayer = null; - _shortExoPlayer = null player?.release(); thumbPlayer?.release(); - shortPlayer?.release() } diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index cbe1c518..e2054c90 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.states import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import androidx.fragment.app.Fragment import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException @@ -18,8 +19,8 @@ import com.futo.platformplayer.exceptions.ReconstructionException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Playlist -import com.futo.platformplayer.sToOffsetDateTimeUTC import com.futo.platformplayer.smartMerge +import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringDateMapStorage @@ -28,12 +29,15 @@ import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import com.futo.platformplayer.sync.models.SyncWatchLaterPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File +import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -81,7 +85,7 @@ class StatePlaylists { if(value.isEmpty()) return OffsetDateTime.MIN; val tryParse = value.toLongOrNull() ?: 0; - return tryParse.sToOffsetDateTimeUTC(); + return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC); } private fun setWatchLaterReorderTime() { val now = OffsetDateTime.now(ZoneOffset.UTC); @@ -173,30 +177,31 @@ class StatePlaylists { StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); } } - fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false): Boolean { + fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean { + var wasNew = false; synchronized(_watchlistStore) { - if (_watchlistStore.hasItem { it.url == video.url }) { - return false + if(!_watchlistStore.hasItem { it.url == video.url }) + wasNew = true; + _watchlistStore.saveAsync(video); + if(orderPosition == -1) + _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray()); + else { + val existing = _watchlistOrderStore.getAllValues().toMutableList(); + existing.add(orderPosition, video.url); + _watchlistOrderStore.set(*existing.toTypedArray()); } - - _watchlistStore.saveAsync(video) - if (Settings.instance.other.watchLaterAddStart) { - _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray()) - } else { - _watchlistOrderStore.set(*(_watchlistOrderStore.values + listOf(video.url)).toTypedArray()) - } - _watchlistOrderStore.save() + _watchlistOrderStore.save(); } onWatchLaterChanged.emit(); - if (isUserInteraction) { + if(isUserInteraction) { val now = OffsetDateTime.now(); _watchLaterAdds.setAndSave(video.url, now); broadcastWatchLaterAddition(video, now); } StateDownloads.instance.checkForOutdatedPlaylists(); - return true; + return wasNew; } fun getLastPlayedPlaylist() : Playlist? { @@ -395,15 +400,12 @@ class StatePlaylists { companion object { val TAG = "StatePlaylists"; private var _instance : StatePlaylists? = null; - private var _lockObject = Object() val instance : StatePlaylists - get() { - synchronized(_lockObject) { - if (_instance == null) - _instance = StatePlaylists(); - return _instance!!; - } - } + get(){ + if(_instance == null) + _instance = StatePlaylists(); + return _instance!!; + }; fun finish() { _instance?.let { @@ -417,25 +419,17 @@ class StatePlaylists { class PlaylistBackup: ReconstructStore() { override fun toReconstruction(obj: Playlist): String { val items = ArrayList(); - items.add(obj.name + ":::" + obj.id); + items.add(obj.name); items.addAll(obj.videos.map { it.url }); return items.map { it.replace("\n","") }.joinToString("\n"); } override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder, importCache: ImportCache?): Playlist { - var idToUse = id; val items = backup.split("\n"); if(items.size <= 0) { throw IllegalStateException("Cannot reconstructor playlist ${id}"); } - var name = items[0]; - if(name.contains(":::")){ - val splitIndex = name.indexOf(":::"); - val foundId = name.substring(splitIndex + 3); - if(!foundId.isNullOrEmpty()) - idToUse = foundId; - name = name.substring(0, splitIndex); - } + val name = items[0]; val videos = items.drop(1).filter { it.isNotEmpty() }.map { try { val videoUrl = it; @@ -467,7 +461,7 @@ class StatePlaylists { throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex); } }.filter { it != null }.map { it!! } - return Playlist(idToUse, name, videos); + return Playlist(id, name, videos); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index 86ae541a..9d6f7437 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -236,7 +236,7 @@ class StatePolycentric { return Pair(didUpdate, listOf(url)); } - fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, type: String? = null): IPager? { + fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager? { ensureEnabled() //TODO: Currently abusing subscription concurrency for parallelism @@ -248,11 +248,7 @@ class StatePolycentric { return@mapNotNull Pair(client, scope.async(Dispatchers.IO) { try { - if (type == null) { - return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); - } else { - return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, type = type); - } + return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); } catch (ex: Throwable) { Logger.e(TAG, "getChannelContent", ex); return@async null; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 60026ea6..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -329,19 +329,8 @@ class StateSubscriptions { } } - if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url)) { - val subGroups = StateSubscriptionGroups.instance.getSubscriptionGroups().filter { it.urls.contains(sub.channel.url) }; - for(group in subGroups) { - group.urls.remove(sub.channel.url); - StateSubscriptionGroups.instance.updateSubscriptionGroup(group); - } - /* - getSubscriptionOtherOrCreate( - sub.channel.url, - sub.channel.name, - sub.channel.thumbnail - ); */ - } + if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url)) + getSubscriptionOtherOrCreate(sub.channel.url, sub.channel.name, sub.channel.thumbnail); } return sub; } 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 25cff055..96c25f9d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -1,205 +1,232 @@ package com.futo.platformplayer.states -import android.content.Context -import com.futo.platformplayer.R +import android.os.Build +import android.util.Log +import com.futo.platformplayer.LittleEndianDataInputStream +import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity -import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.encryption.GEncryptionProvider +import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.HistoryVideo -import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.sToOffsetDateTimeUTC -import com.futo.platformplayer.smartMerge +import com.futo.platformplayer.mdns.DnsService +import com.futo.platformplayer.mdns.ServiceDiscoverer +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringStorage -import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringTMapStorage import com.futo.platformplayer.sync.SyncSessionData import com.futo.platformplayer.sync.internal.GJSyncOpcodes -import com.futo.platformplayer.sync.internal.ISyncDatabaseProvider -import com.futo.platformplayer.sync.internal.Opcode +import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.SyncKeyPair -import com.futo.platformplayer.sync.internal.SyncService -import com.futo.platformplayer.sync.internal.SyncServiceSettings import com.futo.platformplayer.sync.internal.SyncSession -import com.futo.platformplayer.sync.models.SendToDevicePackage -import com.futo.platformplayer.sync.models.SyncPlaylistsPackage -import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage -import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage -import com.futo.platformplayer.sync.models.SyncWatchLaterPackage +import com.futo.platformplayer.sync.internal.SyncSocketSession +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.io.ByteArrayInputStream -import java.nio.ByteBuffer -import java.time.OffsetDateTime +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.Base64 +import java.util.Locale 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") - var syncService: SyncService? = null - private set + private var _serverSocket: ServerSocket? = null + private var _thread: Thread? = null + private var _connectThread: Thread? = null + private var _started = false + private val _sessions: MutableMap = mutableMapOf() + private val _lastConnectTimesMdns: MutableMap = mutableMapOf() + private val _lastConnectTimesIp: MutableMap = mutableMapOf() + //TODO: Should sync mdns and casting mdns be merged? + //TODO: Decrease interval that devices are updated + //TODO: Send less data + val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) } + + var keyPair: DHState? = null + var publicKey: String? = null val deviceRemoved: Event1 = Event1() val deviceUpdatedOrAdded: Event2 = Event2() - fun start(context: Context) { - if (syncService != null) { + fun hasAuthorizedDevice(): Boolean { + synchronized(_sessions) { + return _sessions.any{ it.value.connected && it.value.isAuthorized }; + } + } + + fun start() { + if (_started) { Logger.i(TAG, "Already started.") return } + _started = true - syncService = SyncService( - SERVICE_NAME, - RELAY_SERVER, - RELAY_PUBLIC_KEY, - APP_ID, - StoreBasedSyncDatabaseProvider(), - SyncServiceSettings( - mdnsBroadcast = Settings.instance.synchronization.broadcast, - mdnsConnectDiscovered = Settings.instance.synchronization.connectDiscovered, - bindListener = Settings.instance.synchronization.localConnections, - connectLastKnown = Settings.instance.synchronization.connectLast, - relayHandshakeAllowed = Settings.instance.synchronization.connectThroughRelay, - relayPairAllowed = Settings.instance.synchronization.pairThroughRelay, - relayEnabled = Settings.instance.synchronization.discoverThroughRelay, - relayConnectDirect = Settings.instance.synchronization.connectLocalDirectThroughRelay, - relayConnectRelayed = Settings.instance.synchronization.connectThroughRelay - ) - ).apply { - onAuthorized = { sess, isNewlyAuthorized, isNewSession -> - if (isNewSession) { - deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) - StateApp.instance.scope.launch(Dispatchers.IO) { - try { - checkForSync(sess) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to check for sync.", e) + if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { + _serviceDiscoverer.start() + } + + try { + val syncKeyPair = Json.decodeFromString(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) + val p = Noise.createDH(dh) + p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) + p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) + keyPair = p + } catch (e: Throwable) { + //Sync key pair non-existing, invalid or lost + val p = Noise.createDH(dh) + p.generateKeyPair() + + val publicKey = ByteArray(p.publicKeyLength) + p.getPublicKey(publicKey, 0) + val privateKey = ByteArray(p.privateKeyLength) + p.getPrivateKey(privateKey, 0) + + val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) + _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(syncKeyPair))) + + Logger.e(TAG, "Failed to load existing key pair", e) + keyPair = p + } + + publicKey = keyPair?.let { + val pkey = ByteArray(it.publicKeyLength) + it.getPublicKey(pkey, 0) + return@let pkey.toBase64() + } + + if (Settings.instance.synchronization.broadcast) { + publicKey?.let { _serviceDiscoverer.broadcastService(getDeviceName(), "_gsync._tcp.local", PORT.toUShort(), texts = arrayListOf("pk=${it.replace('+', '-').replace('/', '_').replace("=", "")}")) } + } + + Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") + + _thread = Thread { + try { + val serverSocket = ServerSocket(PORT) + _serverSocket = serverSocket + + Log.i(TAG, "Running on port ${PORT} (TCP)") + + while (_started) { + val socket = serverSocket.accept() + val session = createSocketSession(socket, true) { session, socketSession -> + + } + + session.startAsResponder() + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e) + UIDialogs.toast("Failed to start sync, port in use") + } + }.apply { start() } + + if (Settings.instance.synchronization.connectLast) { + _connectThread = Thread { + Log.i(TAG, "Running auto reconnector") + + while (_started) { + val authorizedDevices = synchronized(_authorizedDevices) { + return@synchronized _authorizedDevices.values + } + + val lastKnownMap = synchronized(_lastAddressStorage) { + return@synchronized _lastAddressStorage.map.toMap() + } + + val addressesToConnect = authorizedDevices.mapNotNull { + val connected = isConnected(it) + if (connected) { + return@mapNotNull null } + + val lastKnownAddress = lastKnownMap[it] ?: return@mapNotNull null + return@mapNotNull Pair(it, lastKnownAddress) } - } - } - onUnauthorized = { sess -> - StateApp.instance.scope.launch(Dispatchers.Main) { - try { - UIDialogs.showConfirmationDialog( - context, - "Device Unauthorized: ${sess.displayName}", - action = { - Logger.i(TAG, "${sess.remotePublicKey} unauthorized received") - removeAuthorizedDevice(sess.remotePublicKey) - deviceRemoved.emit(sess.remotePublicKey) - }, - cancelAction = {} - ) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to show unauthorized dialog.", e) - } - } - } - - onConnectedChanged = { sess, _ -> deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) } - onClose = { sess -> deviceRemoved.emit(sess.remotePublicKey) } - onData = { it, opcode, subOpcode, data -> - val dataCopy = ByteArray(data.remaining()) - data.get(dataCopy) - - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy)) - } catch (e: Throwable) { - Logger.e(TAG, "Exception occurred while handling data, closing session", e) - it.close() - } - } - } - authorizePrompt = { remotePublicKey, callback -> - val scope = StateApp.instance.scopeOrNull - val activity = SyncShowPairingCodeActivity.activity - - if (scope != null && activity != null) { - scope.launch(Dispatchers.Main) { + for (connectPair in addressesToConnect) { try { - UIDialogs.showConfirmationDialog( - activity, "Allow connection from $remotePublicKey?", - action = { - scope.launch(Dispatchers.IO) { - try { - callback(true) - Logger.i( - TAG, - "Connection authorized for $remotePublicKey by confirmation" - ) + val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT) - activity.finish() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize", e) - } - } - }, - cancelAction = { - scope.launch(Dispatchers.IO) { - try { - callback(false) - Logger.i(TAG, "$remotePublicKey unauthorized received") - } catch (e: Throwable) { - Logger.w(TAG, "Failed to send unauthorize", e) - } - } + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesIp) { + _lastConnectTimesIp[connectPair.first] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesIp) { + _lastConnectTimesIp[connectPair.first] = now } - ) + + Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") + connect(syncDeviceInfo) + } } catch (e: Throwable) { - Logger.e(TAG, "Failed to show authorized dialog.", e) + Logger.i(TAG, "Failed to connect to " + connectPair.first, e) } } - } else { - callback(false) - Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask") + Thread.sleep(5000) } - } + }.apply { start() } } - - syncService?.start(context) } - 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) - Settings.instance.save() - onStarted.invoke() - }, { - onNotStarted.invoke() - }) + private fun getDeviceName(): String { + val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + val model = Build.MODEL + + return if (model.startsWith(manufacturer, ignoreCase = true)) { + model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } } else { - onStarted.invoke() + "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } } } - fun hasAuthorizedDevice(): Boolean { - return (syncService?.getAuthorizedDeviceCount() ?: 0) > 0 + fun isConnected(publicKey: String): Boolean { + return synchronized(_sessions) { + _sessions[publicKey]?.connected ?: false + } } fun isAuthorized(publicKey: String): Boolean { - return syncService?.isAuthorized(publicKey) ?: false + return synchronized(_authorizedDevices) { + _authorizedDevices.values.contains(publicKey) + } } fun getSession(publicKey: String): SyncSession? { - return syncService?.getSession(publicKey) + return synchronized(_sessions) { + _sessions[publicKey] + } + } + fun getSessions(): List { + return synchronized(_sessions) { + return _sessions.values.toList() + }; } - fun getAuthorizedSessions(): List { - return syncService?.getSessions()?.filter { it.isAuthorized }?.toList() ?: listOf() + return synchronized(_sessions) { + return _sessions.values.filter { it.isAuthorized }.toList() + }; } fun getSyncSessionData(key: String): SyncSessionData { @@ -212,280 +239,197 @@ class StateSync { _syncSessionData.setAndSave(data.publicKey, data); } - private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { - val added = mutableListOf() - for(sub in pack.subscriptions) { - if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { - val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); - if(sub.creationTime > removalTime) { - val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); - added.add(newSub); - } - } + private fun handleServiceUpdated(services: List) { + if (!Settings.instance.synchronization.connectDiscovered) { + return } - if(added.size > 3) - UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); - else if(added.size > 0) - UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + - added.map { it.channel.name }.joinToString("\n")); + for (s in services) { + //TODO: Addresses IPv4 only? + val addresses = s.addresses.mapNotNull { it.hostAddress }.toTypedArray() + val port = s.port.toInt() + if (s.name.endsWith("._gsync._tcp.local")) { + val name = s.name.substring(0, s.name.length - "._gsync._tcp.local".length) + val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue + val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) - if(pack.subscriptionRemovals.isNotEmpty()) { - for (subRemoved in pack.subscriptionRemovals) { - val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); - if(removed.size > 3) { - UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}"); - } else if(removed.isNotEmpty()) { - UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}:\n" + removed.map { it.channel.name }.joinToString("\n")); + val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port) + val authorized = isAuthorized(pkey) + + if (authorized && !isConnected(pkey)) { + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] = now + } + + Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") + + try { + connect(syncDeviceInfo) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to connect to $pkey", e) + } + } } } } } - private val _lockSubscriptions = Any(); - private val _lockSubscriptionGroups = Any(); - private val _lockPlaylists = Any(); - private val _lockWatchlater = Any(); + private fun unauthorize(remotePublicKey: String) { + Logger.i(TAG, "${remotePublicKey} unauthorized received") + _authorizedDevices.remove(remotePublicKey) + _authorizedDevices.save() + deviceRemoved.emit(remotePublicKey) + } - private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { - val remotePublicKey = session.remotePublicKey - when (subOpcode) { - GJSyncOpcodes.sendToDevices -> { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - val context = StateApp.instance.contextOrNull; - if (context != null && context is MainActivity) { - val dataBody = ByteArray(data.remaining()); - val remainder = data.remaining(); - data.get(dataBody, 0, remainder); - val json = String(dataBody, Charsets.UTF_8); - val obj = Json.decodeFromString(json); - UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}"); - context.handleUrl(obj.url, obj.position); - } - }; - } + private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession, socketSession: SyncSocketSession) -> Unit): SyncSocketSession { + var session: SyncSession? = null + return SyncSocketSession((socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, LittleEndianDataInputStream(socket.getInputStream()), LittleEndianDataOutputStream(socket.getOutputStream()), + onClose = { s -> + session?.removeSocketSession(s) + }, + onHandshakeComplete = { s -> + val remotePublicKey = s.remotePublicKey + if (remotePublicKey == null) { + s.stop() + return@SyncSocketSession + } - GJSyncOpcodes.syncStateExchange -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val syncSessionData = Serializer.json.decodeFromString(json); + Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") - Logger.i(TAG, "Received SyncSessionData from $remotePublicKey"); + synchronized(_sessions) { + session = _sessions[s.remotePublicKey] + if (session == null) { + val remoteDeviceName = synchronized(_nameStorage) { + _nameStorage.get(remotePublicKey) + } - val subscriptionPackageString = StateSubscriptions.instance.getSyncSubscriptionsPackageString() - Logger.i(TAG, "syncStateExchange syncSubscriptions b (size: ${subscriptionPackageString.length})") - session.sendData(GJSyncOpcodes.syncSubscriptions, subscriptionPackageString); - Logger.i(TAG, "syncStateExchange syncSubscriptions (size: ${subscriptionPackageString.length})") - - val subscriptionGroupPackageString = StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString() - Logger.i(TAG, "syncStateExchange syncSubscriptionGroups b (size: ${subscriptionGroupPackageString.length})") - session.sendData(GJSyncOpcodes.syncSubscriptionGroups, subscriptionGroupPackageString); - Logger.i(TAG, "syncStateExchange syncSubscriptionGroups (size: ${subscriptionGroupPackageString.length})") - - val syncPlaylistPackageString = StatePlaylists.instance.getSyncPlaylistsPackageString() - Logger.i(TAG, "syncStateExchange syncPlaylists b (size: ${syncPlaylistPackageString.length})") - session.sendData(GJSyncOpcodes.syncPlaylists, syncPlaylistPackageString) - Logger.i(TAG, "syncStateExchange syncPlaylists (size: ${syncPlaylistPackageString.length})") - - val watchLaterPackageString = Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)) - Logger.i(TAG, "syncStateExchange syncWatchLater b (size: ${watchLaterPackageString.length})") - session.sendData(GJSyncOpcodes.syncWatchLater, watchLaterPackageString); - Logger.i(TAG, "syncStateExchange syncWatchLater (size: ${watchLaterPackageString.length})") - - val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); - - Logger.i(TAG, "syncStateExchange syncHistory b (size: ${recentHistory.size})") - if(recentHistory.isNotEmpty()) - session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); - - Logger.i(TAG, "syncStateExchange syncHistory (size: ${recentHistory.size})") - } - - GJSyncOpcodes.syncExport -> { - val dataBody = ByteArray(data.remaining()); - val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); - bytesStr.use { bytesStrBytes -> - val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStrBytes); - for (store in exportStruct.stores) { - if (store.key.equals("subscriptions", true)) { - val subStore = - StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val pack = SyncSubscriptionsPackage( - store.value.map { - subStore.fromReconstruction(it, exportStruct.cache) - }, - StateSubscriptions.instance.getSubscriptionRemovals() - ); - handleSyncSubscriptionPackage(session, pack); + session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> + if (!isNewSession) { + return@SyncSession } + + 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) + } + + onAuthorized(it, s) + _authorizedDevices.addDistinct(remotePublicKey) + _authorizedDevices.save() + deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) + + checkForSync(it); + }, onUnauthorized = { + unauthorize(remotePublicKey) + + synchronized(_sessions) { + session?.close() + _sessions.remove(remotePublicKey) + } + }, onConnectedChanged = { it, connected -> + Logger.i(TAG, "${s.remotePublicKey} connected: " + connected) + deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) + }, onClose = { + Logger.i(TAG, "${s.remotePublicKey} closed") + + synchronized(_sessions) + { + _sessions.remove(it.remotePublicKey) + } + + deviceRemoved.emit(it.remotePublicKey) + + }, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addSocketSession(s) + } + + if (isResponder) { + val isAuthorized = synchronized(_authorizedDevices) { + _authorizedDevices.values.contains(remotePublicKey) + } + + if (!isAuthorized) { + val scope = StateApp.instance.scopeOrNull + val activity = SyncShowPairingCodeActivity.activity + + if (scope != null && activity != null) { + scope.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = { + scope.launch(Dispatchers.IO) { + try { + session!!.authorize(s) + Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize", e) + } + } + }, cancelAction = { + scope.launch(Dispatchers.IO) { + try { + unauthorize(remotePublicKey) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to send unauthorize", e) + } + + synchronized(_sessions) { + session?.close() + _sessions.remove(remotePublicKey) + } + } + }) + } + } else { + val publicKey = session!!.remotePublicKey + session!!.unauthorize(s) + session!!.close() + + synchronized(_sessions) { + _sessions.remove(publicKey) + } + + Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask") } + } else { + //Responder does not need to check because already approved + session!!.authorize(s) + Logger.i(TAG, "Connection authorized for ${remotePublicKey} because already authorized") } + } else { + //Initiator does not need to check because the manual action of scanning the QR counts as approval + session!!.authorize(s) + Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator") } - } - - GJSyncOpcodes.syncSubscriptions -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val subPackage = Serializer.json.decodeFromString(json); - synchronized(_lockSubscriptions) { - handleSyncSubscriptionPackage(session, subPackage); - } - - if(subPackage.subscriptions.size > 0) { - val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; - - val sesData = getSyncSessionData(remotePublicKey); - if (newestSub > sesData.lastSubscription) { - sesData.lastSubscription = newestSub; - saveSyncSessionData(sesData); - } - } - } - - GJSyncOpcodes.syncSubscriptionGroups -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - var lastSubgroupChange = OffsetDateTime.MIN; - synchronized(_lockSubscriptionGroups) { - for(group in pack.groups){ - if(group.lastChange > lastSubgroupChange) - lastSubgroupChange = group.lastChange; - - val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); - - if(existing == null) - StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); - else if(existing.lastChange < group.lastChange) { - existing.name = group.name; - existing.urls = group.urls; - existing.image = group.image; - existing.priority = group.priority; - existing.lastChange = group.lastChange; - StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); - } - } - } - for(removal in pack.groupRemovals) { - val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); - val removalTime = removal.value.sToOffsetDateTimeUTC(); - if(creation != null && creation.creationTime < removalTime) - StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); - } - } - - GJSyncOpcodes.syncPlaylists -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - synchronized(_lockPlaylists) { - for(playlist in pack.playlists) { - val existing = StatePlaylists.instance.getPlaylist(playlist.id); - - if(existing == null) - StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); - else if(existing.dateUpdate < playlist.dateUpdate) { - existing.dateUpdate = playlist.dateUpdate; - existing.name = playlist.name; - existing.videos = playlist.videos; - existing.dateCreation = playlist.dateCreation; - existing.datePlayed = playlist.datePlayed; - StatePlaylists.instance.createOrUpdatePlaylist(existing, false); - } - } - } - for(removal in pack.playlistRemovals) { - val creation = StatePlaylists.instance.getPlaylist(removal.key); - val removalTime = removal.value.sToOffsetDateTimeUTC(); - if(creation != null && creation.dateCreation < removalTime) - StatePlaylists.instance.removePlaylist(creation, false); - - } - } - - GJSyncOpcodes.syncWatchLater -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})"); - - val allExisting = StatePlaylists.instance.getWatchLater(); - synchronized(_lockWatchlater) { - for(video in pack.videos) { - val existing = allExisting.firstOrNull { it.url == video.url }; - val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN; - val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN; - if(existing == null && time > removalTime) { - StatePlaylists.instance.addToWatchLater(video, false); - if(time > OffsetDateTime.MIN) - StatePlaylists.instance.setWatchLaterAddTime(video.url, time); - } - } - } - for(removal in pack.videoRemovals) { - val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; - val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; - val removalTime = removal.value.sToOffsetDateTimeUTC() - if(creation < removalTime) - StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); - } - - val packReorderTime = pack.reorderTime.sToOffsetDateTimeUTC() - val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); - if(localReorderTime < packReorderTime && pack.ordering != null) { - StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); - } - } - - GJSyncOpcodes.syncHistory -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val history = Serializer.json.decodeFromString>(json); - Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); - if (history.size == 1) { - Logger.i(TAG, "SyncHistory received update video '${history[0].video.name}' (url: ${history[0].video.url}) at timestamp ${history[0].position}"); - } - - var lastHistory = OffsetDateTime.MIN; - for(video in history){ - val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date); - if(hist != null) - StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date) - if(lastHistory < video.date) - lastHistory = video.date; - } - - if(lastHistory != OffsetDateTime.MIN && history.size > 1) { - val sesData = getSyncSessionData(remotePublicKey); - if (lastHistory > sesData.lastHistory) { - sesData.lastHistory = lastHistory; - saveSyncSessionData(sesData); - } - } - } - } + }, + onData = { s, opcode, subOpcode, data -> + session?.handlePacket(s, opcode, subOpcode, data) + }) } inline fun broadcastJsonData(subOpcode: UByte, data: T) { - broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); + broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data)); } fun broadcastData(subOpcode: UByte, data: String) { - broadcast(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))); + broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); } fun broadcast(opcode: UByte, subOpcode: UByte, data: String) { - broadcast(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))); + broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); } - fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) { for(session in getAuthorizedSessions()) { try { session.send(opcode, subOpcode, data); @@ -506,17 +450,40 @@ class StateSync { } fun stop() { - syncService?.stop() - syncService = null + _started = false + _serviceDiscoverer.stop() + + _serverSocket?.close() + _serverSocket = null + + //_thread?.join() + _thread = null + _connectThread = null } + fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((session: SyncSocketSession?, complete: Boolean, message: String) -> Unit)? = null): SyncSocketSession { + onStatusUpdate?.invoke(null, false, "Connecting...") + val socket = getConnectedSocket(deviceInfo.addresses.map { InetAddress.getByName(it) }, deviceInfo.port) ?: throw Exception("Failed to connect") + onStatusUpdate?.invoke(null, false, "Handshaking...") + + val session = createSocketSession(socket, false) { _, ss -> + onStatusUpdate?.invoke(ss, true, "Handshake complete") + } + + session.startAsInitiator(deviceInfo.publicKey) + return session + } fun getAll(): List { - return syncService?.getAllAuthorizedDevices()?.toList() ?: listOf() + synchronized(_authorizedDevices) { + return _authorizedDevices.values.toList() + } } fun getCachedName(publicKey: String): String? { - return syncService?.getCachedName(publicKey) + return synchronized(_nameStorage) { + _nameStorage.get(publicKey) + } } suspend fun delete(publicKey: String) { @@ -533,8 +500,14 @@ class StateSync { session.close() } - syncService?.removeSession(publicKey) - syncService?.removeAuthorizedDevice(publicKey) + synchronized(_sessions) { + _sessions.remove(publicKey) + } + + synchronized(_authorizedDevices) { + _authorizedDevices.remove(publicKey) + } + _authorizedDevices.save() withContext(Dispatchers.Main) { deviceRemoved.emit(publicKey) @@ -543,47 +516,16 @@ class StateSync { Logger.w(TAG, "Failed to delete", e) } } - } - class StoreBasedSyncDatabaseProvider : ISyncDatabaseProvider { - 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") - - override fun isAuthorized(publicKey: String): Boolean = synchronized(_authorizedDevices) { _authorizedDevices.values.contains(publicKey) } - override fun addAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) { - _authorizedDevices.addDistinct(publicKey) - _authorizedDevices.save() - } - override fun removeAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) { - _authorizedDevices.remove(publicKey) - _authorizedDevices.save() - } - override fun getAllAuthorizedDevices(): Array = synchronized(_authorizedDevices) { _authorizedDevices.values.toTypedArray() } - override fun getAuthorizedDeviceCount(): Int = synchronized(_authorizedDevices) { _authorizedDevices.values.size } - override fun getSyncKeyPair(): SyncKeyPair? = try { - Json.decodeFromString(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) - } catch (e: Throwable) { null } - override fun setSyncKeyPair(value: SyncKeyPair) { _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(value))) } - override fun getLastAddress(publicKey: String): String? = synchronized(_lastAddressStorage) { _lastAddressStorage.map[publicKey] } - override fun setLastAddress(publicKey: String, address: String) = synchronized(_lastAddressStorage) { - _lastAddressStorage.map[publicKey] = address - _lastAddressStorage.save() - } - override fun getDeviceName(publicKey: String): String? = synchronized(_nameStorage) { _nameStorage.map[publicKey] } - override fun setDeviceName(publicKey: String, name: String) = synchronized(_nameStorage) { - _nameStorage.map[publicKey] = name - _nameStorage.save() - } } companion object { + val dh = "25519" + val pattern = "IK" + val cipher = "ChaChaPoly" + val hash = "BLAKE2b" + var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" val version = 1 - val RELAY_SERVER = "relay.grayjay.app" - val SERVICE_NAME = "_gsync._tcp" - val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" - val APP_ID = 0x534A5247u //GRayJaySync (GRJS) private const val TAG = "StateSync" const val PORT = 12315 diff --git a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt index bddbe862..3ecff95a 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt @@ -19,11 +19,6 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() { return deviceInfos.toList(); } - @Synchronized - fun getDeviceNames() : List { - return deviceInfos.map { it.name }.toList(); - } - @Synchronized fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo { val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name } diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt index c5ff802a..7a5b7cf2 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -1,7 +1,6 @@ package com.futo.platformplayer.stores import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.StoreSerializer diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 6b9ed65f..2e493eef 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -274,17 +274,10 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?"; val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize)); return deserializeIndexes(dbDaoBase.getMultiple(query)); - }fun queryLike2Page(field: String, field2: String, obj: String, page: Int, pageSize: Int): List { - val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? OR ${field2} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?"; - val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, obj, pageSize, page * pageSize)); - return deserializeIndexes(dbDaoBase.getMultiple(query)); } fun queryLikeObjectPage(field: String, obj: String, page: Int, pageSize: Int): List { return convertObjects(queryLikePage(field, obj, page, pageSize)); } - fun queryLike2ObjectPage(field: String, field2: String, obj: String, page: Int, pageSize: Int): List { - return convertObjects(queryLike2Page(field, field2, obj, page, pageSize)); - } //Query Page Objects @@ -343,13 +336,6 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA queryLikePage(field, obj, it - 1, pageSize); }); } - fun queryLike2Pager(field: KProperty<*>, field2: KProperty<*>, obj: String, pageSize: Int): IPager = queryLike2Pager(validateFieldName(field), validateFieldName(field2), obj, pageSize); - fun queryLike2Pager(field: String, field2: String, obj: String, pageSize: Int): IPager { - return AdhocPager({ - Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); - queryLike2Page(field, field2, obj, it - 1, pageSize); - }); - } fun queryLikeObjectPager(field: KProperty<*>, obj: String, pageSize: Int): IPager = queryLikeObjectPager(validateFieldName(field), obj, pageSize); fun queryLikeObjectPager(field: String, obj: String, pageSize: Int): IPager { return AdhocPager({ @@ -358,14 +344,6 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }); } - fun queryLike2ObjectPager(field: KProperty<*>, field2: KProperty<*>, obj: String, pageSize: Int): IPager = queryLike2ObjectPager(validateFieldName(field), validateFieldName(field2), obj, pageSize); - fun queryLike2ObjectPager(field: String, field2: String, obj: String, pageSize: Int): IPager { - return AdhocPager({ - Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); - queryLike2ObjectPage(field, field2, obj, it - 1, pageSize); - }); - } - //Query Pager with convert fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int, convert: (I)->X): IPager = queryPager(validateFieldName(field), obj, pageSize, convert); fun queryPager(field: String, obj: Any, pageSize: Int, convert: (I)->X): IPager { diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 2740ca8b..b72e840c 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -174,7 +174,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( if (resolve != null) { resolveCount = resolves.size; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") for(result in resolve){ val task = providedTasks?.find { it.url == result.channelUrl }; if(task != null) { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt deleted file mode 100644 index 4e9b2653..00000000 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ /dev/null @@ -1,404 +0,0 @@ -package com.futo.platformplayer.sync.internal - -import com.futo.platformplayer.ensureNotMainThread -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.noise.protocol.CipherStatePair -import com.futo.platformplayer.noise.protocol.DHState -import com.futo.platformplayer.noise.protocol.HandshakeState -import com.futo.platformplayer.states.StateSync -import com.futo.polycentric.core.base64ToByteArray -import com.futo.polycentric.core.toBase64 -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.Base64 -import java.util.zip.GZIPOutputStream - -interface IChannel : AutoCloseable { - val remotePublicKey: String? - val remoteVersion: Int? - var authorizable: IAuthorizable? - var syncSession: SyncSession? - fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) - fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) - fun setCloseHandler(onClose: ((IChannel) -> Unit)?) - val linkType: LinkType -} - -class ChannelSocket(private val session: SyncSocketSession) : IChannel { - override val remotePublicKey: String? get() = session.remotePublicKey - override val remoteVersion: Int? get() = session.remoteVersion - private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null - private var onClose: ((IChannel) -> Unit)? = null - override val linkType: LinkType get() = LinkType.Direct - - override var authorizable: IAuthorizable? - get() = session.authorizable - set(value) { session.authorizable = value } - override var syncSession: SyncSession? = null - - override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { - this.onData = onData - } - - override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) { - this.onClose = onClose - } - - override fun close() { - session.stop() - onClose?.invoke(this) - } - - fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { - onData?.invoke(session, this, opcode, subOpcode, data) - } - - override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) { - ensureNotMainThread() - if (data != null) { - session.send(opcode, subOpcode, data, contentEncoding) - } else { - session.send(opcode, subOpcode) - } - } -} - -class ChannelRelayed( - private val session: SyncSocketSession, - private val localKeyPair: DHState, - private val publicKey: String, - private val initiator: Boolean -) : IChannel { - private val sendLock = Object() - private val decryptLock = Object() - private var handshakeState: HandshakeState? = if (initiator) { - HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply { - localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) - remotePublicKey.setPublicKey(publicKey.base64ToByteArray(), 0) - } - } else { - HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply { - localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) - } - } - private var transport: CipherStatePair? = null - override var authorizable: IAuthorizable? = null - val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false - var connectionId: Long = 0L - override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64() - private set - override var remoteVersion: Int? = null - private set - override var syncSession: SyncSession? = null - override val linkType: LinkType get() = LinkType.Relayed - - private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null - private var onClose: ((IChannel) -> Unit)? = null - private var disposed = false - private var _lastPongTime: Long = 0 - private val _pingInterval: Long = 5000 // 5 seconds in milliseconds - private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds - - init { - handshakeState?.start() - } - - private fun startPingLoop() { - if (remoteVersion!! < 5) { - return - } - - _lastPongTime = System.currentTimeMillis() - - Thread { - try { - while (!disposed) { - Thread.sleep(_pingInterval) - if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) { - Logger.e("ChannelRelayed", "Channel timed out waiting for PONG; closing.") - close() - break - } - send(Opcode.PING.value, 0u) - } - } catch (e: Exception) { - Logger.e("ChannelRelayed", "Ping loop failed", e) - close() - } - }.start() - } - - override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { - this.onData = onData - } - - override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) { - this.onClose = onClose - } - - override fun close() { - disposed = true - - if (connectionId != 0L) { - Thread { - try { - session.sendRelayError(connectionId, SyncErrorCode.ConnectionClosed) - } catch (e: Exception) { - Logger.e("ChannelRelayed", "Exception while sending relay error", e) - } - }.start() - } - - transport?.sender?.destroy() - transport?.receiver?.destroy() - transport = null - handshakeState?.destroy() - handshakeState = null - - onClose?.invoke(this) - } - - private fun throwIfDisposed() { - if (disposed) throw IllegalStateException("ChannelRelayed is disposed") - } - - fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { - if (opcode == Opcode.PONG.value) { - _lastPongTime = System.currentTimeMillis() - return - } - onData?.invoke(session, this, opcode, subOpcode, data) - } - - private fun completeHandshake(remoteVersion: Int, transport: CipherStatePair) { - throwIfDisposed() - - this.remoteVersion = remoteVersion - val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength) - handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - this.remotePublicKey = remoteKeyBytes.toBase64() - handshakeState?.destroy() - handshakeState = null - this.transport = transport - Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId") - startPingLoop() - } - - private fun sendPacket(packet: ByteArray) { - throwIfDisposed() - ensureNotMainThread() - - synchronized(sendLock) { - val encryptedPayload = ByteArray(packet.size + 16) - val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size) - - val relayedPacket = ByteArray(8 + encryptedLength) - ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply { - putLong(connectionId) - put(encryptedPayload, 0, encryptedLength) - } - - session.send(Opcode.RELAY.value, RelayOpcode.DATA.value, ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN)) - } - } - - fun sendError(errorCode: SyncErrorCode) { - throwIfDisposed() - ensureNotMainThread() - - synchronized(sendLock) { - val packet = ByteArray(4) - ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(errorCode.value) - - val encryptedPayload = ByteArray(4 + 16) - val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size) - - val relayedPacket = ByteArray(8 + encryptedLength) - ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply { - putLong(connectionId) - put(encryptedPayload, 0, encryptedLength) - } - - session.send(Opcode.RELAY.value, RelayOpcode.ERROR.value, ByteBuffer.wrap(relayedPacket)) - } - } - - override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) { - throwIfDisposed() - ensureNotMainThread() - - var contentEncoding: ContentEncoding? = ce - var processedData = data - if (data != null && contentEncoding == ContentEncoding.Gzip) { - val isGzipSupported = opcode == Opcode.DATA.value - if (isGzipSupported) { - val compressedStream = ByteArrayOutputStream() - GZIPOutputStream(compressedStream).use { gzipStream -> - gzipStream.write(data.array(), data.position(), data.remaining()) - gzipStream.finish() - } - processedData = ByteBuffer.wrap(compressedStream.toByteArray()) - } else { - Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.") - contentEncoding = ContentEncoding.Raw - } - } - - val ENCRYPTION_OVERHEAD = 16 - val CONNECTION_ID_SIZE = 8 - val HEADER_SIZE = 7 - val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 - - Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.size: ${processedData?.remaining()})") - - if (processedData != null && processedData.remaining() > MAX_DATA_PER_PACKET) { - val streamId = session.generateStreamId() - var sendOffset = 0 - - while (sendOffset < processedData.remaining()) { - val bytesRemaining = processedData.remaining() - sendOffset - val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - HEADER_SIZE + 4, bytesRemaining) - - val streamData: ByteArray - val streamOpcode: StreamOpcode - if (sendOffset == 0) { - streamOpcode = StreamOpcode.START - streamData = ByteArray(4 + HEADER_SIZE + bytesToSend) - ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(streamId) - putInt(processedData.remaining()) - put(opcode.toByte()) - put(subOpcode.toByte()) - put(contentEncoding?.value?.toByte() ?: 0.toByte()) - put(processedData.array(), processedData.position() + sendOffset, bytesToSend) - } - } else { - streamData = ByteArray(4 + 4 + bytesToSend) - ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(streamId) - putInt(sendOffset) - put(processedData.array(), processedData.position() + sendOffset, bytesToSend) - } - streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END - } - - val fullPacket = ByteArray(HEADER_SIZE + streamData.size) - ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(streamData.size + HEADER_SIZE - 4) - put(Opcode.STREAM.value.toByte()) - put(streamOpcode.value.toByte()) - put(ContentEncoding.Raw.value.toByte()) - put(streamData) - } - - sendPacket(fullPacket) - sendOffset += bytesToSend - } - } else { - val packet = ByteArray(HEADER_SIZE + (processedData?.remaining() ?: 0)) - ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt((processedData?.remaining() ?: 0) + HEADER_SIZE - 4) - put(opcode.toByte()) - put(subOpcode.toByte()) - put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) - if (processedData != null && processedData.remaining() > 0) put(processedData.array(), processedData.position(), processedData.remaining()) - } - sendPacket(packet) - } - } - - fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) { - throwIfDisposed() - ensureNotMainThread() - - synchronized(sendLock) { - val channelMessage = ByteArray(1024) - val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0) - - val publicKeyBytes = publicKey.base64ToByteArray() - if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") - - val (pairingMessageLength, pairingMessage) = if (pairingCode != null) { - val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply { - remotePublicKey.setPublicKey(publicKeyBytes, 0) - start() - } - val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) - if (pairingCodeBytes.size > 32) throw IllegalArgumentException("Pairing code must not exceed 32 bytes") - val pairingMessageBuffer = ByteArray(1024) - val bytesWritten = pairingHandshake.writeMessage(pairingMessageBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size) - bytesWritten to pairingMessageBuffer.copyOf(bytesWritten) - } else { - 0 to ByteArray(0) - } - - val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten - val packet = ByteArray(packetSize) - ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(requestId) - putInt(appId.toInt()) - put(publicKeyBytes) - putInt(pairingMessageLength) - if (pairingMessageLength > 0) put(pairingMessage) - putInt(channelBytesWritten) - put(channelMessage, 0, channelBytesWritten) - } - - session.send(Opcode.REQUEST.value, RequestOpcode.TRANSPORT.value, ByteBuffer.wrap(packet)) - } - } - - fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) { - throwIfDisposed() - ensureNotMainThread() - - synchronized(sendLock) { - val message = ByteArray(1024) - val plaintext = ByteArray(1024) - handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0) - val bytesWritten = handshakeState!!.writeMessage(message, 0, null, 0, 0) - val transport = handshakeState!!.split() - - val responsePacket = ByteArray(20 + bytesWritten) - ByteBuffer.wrap(responsePacket).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(0) // Status code - putLong(connectionId) - putInt(requestId) - putInt(bytesWritten) - put(message, 0, bytesWritten) - } - - completeHandshake(remoteVersion, transport) - session.send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, ByteBuffer.wrap(responsePacket)) - } - } - - fun decrypt(encryptedPayload: ByteBuffer): ByteBuffer { - throwIfDisposed() - - synchronized(decryptLock) { - val encryptedBytes = ByteArray(encryptedPayload.remaining()).also { encryptedPayload.get(it) } - val decryptedPayload = ByteArray(encryptedBytes.size - 16) - val plen = transport!!.receiver.decryptWithAd(null, encryptedBytes, 0, decryptedPayload, 0, encryptedBytes.size) - if (plen != decryptedPayload.size) throw IllegalStateException("Expected decrypted payload length to be $plen") - return ByteBuffer.wrap(decryptedPayload).order(ByteOrder.LITTLE_ENDIAN) - } - } - - fun handleTransportRelayed(remoteVersion: Int, connectionId: Long, handshakeMessage: ByteArray) { - throwIfDisposed() - - synchronized(decryptLock) { - this.connectionId = connectionId - val plaintext = ByteArray(1024) - val plen = handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0) - val transport = handshakeState!!.split() - completeHandshake(remoteVersion, transport) - } - } - - companion object { - private val TAG = "Channel" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt deleted file mode 100644 index ab9ed6a9..00000000 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.futo.platformplayer.sync.internal - -enum class ContentEncoding(val value: UByte) { - Raw(0u), - Gzip(1u) -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java b/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java index 256ed422..a3fe431c 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java @@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal; public enum LinkType { None, - Direct, - Relayed + Local, + Proxied } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt deleted file mode 100644 index 8a12b579..00000000 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.futo.platformplayer.sync.internal - -enum class Opcode(val value: UByte) { - PING(0u), - PONG(1u), - NOTIFY(2u), - STREAM(3u), - DATA(4u), - REQUEST(5u), - RESPONSE(6u), - RELAY(7u) -} - -enum class NotifyOpcode(val value: UByte) { - AUTHORIZED(0u), - UNAUTHORIZED(1u), - CONNECTION_INFO(2u) -} - -enum class StreamOpcode(val value: UByte) { - START(0u), - DATA(1u), - END(2u) -} - -enum class RequestOpcode(val value: UByte) { - CONNECTION_INFO(0u), - TRANSPORT(1u), - TRANSPORT_RELAYED(2u), - PUBLISH_RECORD(3u), - DELETE_RECORD(4u), - LIST_RECORD_KEYS(5u), - GET_RECORD(6u), - BULK_PUBLISH_RECORD(7u), - BULK_GET_RECORD(8u), - BULK_CONNECTION_INFO(9u), - BULK_DELETE_RECORD(10u) -} - -enum class ResponseOpcode(val value: UByte) { - CONNECTION_INFO(0u), - TRANSPORT(1u), - TRANSPORT_RELAYED(2u), //TODO: Server errors also included in this one, disentangle? - PUBLISH_RECORD(3u), - DELETE_RECORD(4u), - LIST_RECORD_KEYS(5u), - GET_RECORD(6u), - BULK_PUBLISH_RECORD(7u), - BULK_GET_RECORD(8u), - BULK_CONNECTION_INFO(9u), - BULK_DELETE_RECORD(10u) -} - -enum class RelayOpcode(val value: UByte) { - DATA(0u), - RELAYED_DATA(1u), - ERROR(2u), - RELAYED_ERROR(3u), - RELAY_ERROR(4u) -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt index a3bb6e00..17a70860 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt @@ -5,12 +5,10 @@ class SyncDeviceInfo { var publicKey: String var addresses: Array var port: Int - var pairingCode: String? - constructor(publicKey: String, addresses: Array, port: Int, pairingCode: String?) { + constructor(publicKey: String, addresses: Array, port: Int) { this.publicKey = publicKey this.addresses = addresses this.port = port - this.pairingCode = pairingCode } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt deleted file mode 100644 index 0b4be0ce..00000000 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.futo.platformplayer.sync.internal - -enum class SyncErrorCode(val value: Int) { - ConnectionClosed(1), - NotFound(2) -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt deleted file mode 100644 index 23ea684b..00000000 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt +++ /dev/null @@ -1,836 +0,0 @@ -package com.futo.platformplayer.sync.internal - -import android.content.Context -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.Build -import android.util.Log -import com.futo.platformplayer.Settings -import com.futo.platformplayer.generateReadablePassword -import com.futo.platformplayer.getConnectedSocket -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.noise.protocol.DHState -import com.futo.platformplayer.noise.protocol.Noise -import com.futo.platformplayer.states.StateSync -import com.futo.polycentric.core.base64ToByteArray -import com.futo.polycentric.core.base64UrlToByteArray -import com.futo.polycentric.core.toBase64 -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.nio.ByteBuffer -import java.util.Base64 -import java.util.Locale -import kotlin.math.min - -public data class SyncServiceSettings( - val listenerPort: Int = 12315, - val mdnsBroadcast: Boolean = true, - val mdnsConnectDiscovered: Boolean = true, - val bindListener: Boolean = true, - val connectLastKnown: Boolean = true, - val relayHandshakeAllowed: Boolean = true, - val relayPairAllowed: Boolean = true, - val relayEnabled: Boolean = true, - val relayConnectDirect: Boolean = true, - val relayConnectRelayed: Boolean = true -) - -interface ISyncDatabaseProvider { - fun isAuthorized(publicKey: String): Boolean - fun addAuthorizedDevice(publicKey: String) - fun removeAuthorizedDevice(publicKey: String) - fun getAllAuthorizedDevices(): Array? - fun getAuthorizedDeviceCount(): Int - fun getSyncKeyPair(): SyncKeyPair? - fun setSyncKeyPair(value: SyncKeyPair) - fun getLastAddress(publicKey: String): String? - fun setLastAddress(publicKey: String, address: String) - fun getDeviceName(publicKey: String): String? - fun setDeviceName(publicKey: String, name: String) -} - -class SyncService( - private val serviceName: String, - private val relayServer: String, - private val relayPublicKey: String, - private val appId: UInt, - private val database: ISyncDatabaseProvider, - private val settings: SyncServiceSettings = SyncServiceSettings() -) { - private var _serverSocket: ServerSocket? = null - private var _thread: Thread? = null - private var _connectThread: Thread? = null - private var _mdnsThread: Thread? = null - @Volatile private var _started = false - private val _sessions: MutableMap = mutableMapOf() - private val _lastConnectTimesMdns: MutableMap = mutableMapOf() - private val _lastConnectTimesIp: MutableMap = 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 - - private val _pairingCode: String? = generateReadablePassword(8) - val pairingCode: String? get() = _pairingCode - private var _relaySession: SyncSocketSession? = null - private var _threadRelay: Thread? = null - private val _remotePendingStatusUpdate = mutableMapOf Unit>() - private var _nsdManager: NsdManager? = null - private var _scope: CoroutineScope? = null - private val _mdnsCache = mutableMapOf() - private var _discoveryListener: NsdManager.DiscoveryListener = 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") - val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return - val pkey = urlSafePkey.base64UrlToByteArray().toBase64() - synchronized(_mdnsCache) { - _mdnsCache.remove(pkey) - } - } - - 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) - } - } - - fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { - if (!Settings.instance.synchronization.connectDiscovered) { - return - } - - val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return - val pkey = urlSafePkey.base64UrlToByteArray().toBase64() - val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) - - synchronized(_mdnsCache) { - _mdnsCache[pkey] = syncDeviceInfo - } - } - - override fun onServiceFound(service: NsdServiceInfo) { - Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") - addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - service.hostAddresses.toTypedArray() - } else { - if(service.host != null) - arrayOf(service.host); - else - arrayOf(); - }, service.port, service.attributes) - - 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, serviceInfo.attributes) - } - - override fun onServiceLost() { - Log.v(TAG, "onServiceLost: $service") - val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return - val pkey = urlSafePkey.base64UrlToByteArray().toBase64() - synchronized(_mdnsCache) { - _mdnsCache.remove(pkey) - } - } - - 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, serviceInfo.attributes) - } - }) - } - } - } - - private val _registrationListener = object : NsdManager.RegistrationListener { - override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}") - } - - override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") - } - - override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}") - } - - override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") - } - } - - var keyPair: DHState? = null - var publicKey: String? = null - - var onAuthorized: ((SyncSession, Boolean, Boolean) -> Unit)? = null - var onUnauthorized: ((SyncSession) -> Unit)? = null - var onConnectedChanged: ((SyncSession, Boolean) -> Unit)? = null - var onClose: ((SyncSession) -> Unit)? = null - var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null - var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null - - fun start(context: Context) { - if (_started) { - Logger.i(TAG, "Already started.") - return - } - _started = true - _scope = CoroutineScope(Dispatchers.IO) - - try { - val syncKeyPair = database.getSyncKeyPair() ?: throw Exception("SyncKeyPair not found") - val p = Noise.createDH(dh) - p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) - p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) - keyPair = p - } catch (e: Throwable) { - //Sync key pair non-existing, invalid or lost - val p = Noise.createDH(dh) - p.generateKeyPair() - - val publicKey = ByteArray(p.publicKeyLength) - p.getPublicKey(publicKey, 0) - val privateKey = ByteArray(p.privateKeyLength) - p.getPrivateKey(privateKey, 0) - - val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) - database.setSyncKeyPair(syncKeyPair) - - Logger.e(TAG, "Failed to load existing key pair", e) - keyPair = p - } - - publicKey = keyPair?.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let pkey.toBase64() - } - - _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager - if (settings.mdnsConnectDiscovered) { - startMdnsRetryLoop() - } - - if (settings.mdnsBroadcast) { - val pk = publicKey - val nsdManager = _nsdManager - - if (pk != null && nsdManager != null) { - val sn = serviceName - val serviceInfo = NsdServiceInfo().apply { - serviceName = getDeviceName() - serviceType = sn - port = settings.listenerPort - setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", "")) - } - - nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener) - } - } - - Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") - - serverSocketStarted = false - if (settings.bindListener) { - startListener() - } - - relayConnected = false - if (settings.relayEnabled) { - startRelayLoop() - } - - if (settings.connectLastKnown) { - startConnectLastLoop() - } - } - - 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) { - val socket = serverSocket.accept() - 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 - serverSocketStarted = false - } - }.apply { start() } - } - - private fun startMdnsRetryLoop() { - _nsdManager?.apply { - discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener) - } - - _mdnsThread = Thread { - while (_started) { - try { - val now = System.currentTimeMillis() - synchronized(_mdnsCache) { - for ((pkey, info) in _mdnsCache) { - if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue - - val last = synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] ?: 0L - } - if (now - last > 30_000L) { - _lastConnectTimesMdns[pkey] = now - try { - Logger.i(TAG, "MDNS-retry: connecting to $pkey") - connect(info) - } catch (ex: Throwable) { - Logger.w(TAG, "MDNS retry failed for $pkey", ex) - } - } - } - } - } catch (ex: Throwable) { - Logger.e(TAG, "Error in MDNS retry loop", ex) - } - Thread.sleep(5000) - } - }.apply { start() } - } - - - private fun startConnectLastLoop() { - _connectThread = Thread { - Log.i(TAG, "Running auto reconnector") - - while (_started) { - val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf() - val addressesToConnect = authorizedDevices.mapNotNull { - val connectedDirectly = getLinkType(it) == LinkType.Direct - if (connectedDirectly) { - return@mapNotNull null - } - - val lastKnownAddress = database.getLastAddress(it) ?: return@mapNotNull null - return@mapNotNull Pair(it, lastKnownAddress) - } - - for (connectPair in addressesToConnect) { - try { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesIp) { - _lastConnectTimesIp[connectPair.first] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesIp) { - _lastConnectTimesIp[connectPair.first] = now - } - - Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") - connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null) - } - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to " + connectPair.first, e) - } - } - Thread.sleep(5000) - } - }.apply { start() } - } - - private fun startRelayLoop() { - relayConnected = false - _threadRelay = Thread { - try { - var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) - var backoffIndex = 0; - - 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!! - } - 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 - - Thread { - try { - while (_started && !socketClosed) { - val unconnectedAuthorizedDevices = - database.getAllAuthorizedDevices() - ?.filter { - if (Settings.instance.synchronization.connectLocalDirectThroughRelay) { - getLinkType(it) != LinkType.Direct - } else { - !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 (getLinkType(targetKey) != LinkType.Direct && 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 (!isConnected(targetKey) && 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) - } - } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in relay session.", e) - relaySession.stop() - } - }.start() - } - ) - - _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++)]) - } - } - } catch (ex: Throwable) { - Log.i(TAG, "Unhandled exception in relay loop.", ex) - } - }.apply { start() } - } - - private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession { - var session: SyncSession? = null - var channelSocket: ChannelSocket? = null - return SyncSocketSession( - (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, - keyPair!!, - socket, - onClose = { s -> - if (channelSocket != null) - session?.removeChannel(channelSocket!!) - }, - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, - onHandshakeComplete = { s -> - val remotePublicKey = s.remotePublicKey - if (remotePublicKey == null) { - s.stop() - return@SyncSocketSession - } - - Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") - - channelSocket = ChannelSocket(s) - - synchronized(_sessions) { - session = _sessions[s.remotePublicKey] - if (session == null) { - val remoteDeviceName = database.getDeviceName(remotePublicKey) - database.setLastAddress(remotePublicKey, s.remoteAddress) - session = createNewSyncSession(remotePublicKey, remoteDeviceName) - _sessions[remotePublicKey] = session!! - } - session!!.addChannel(channelSocket!!) - } - - handleAuthorization(channelSocket!!, isResponder) - }, - onData = { s, opcode, subOpcode, data -> - session?.handlePacket(opcode, subOpcode, data) - } - ) - } - - private fun handleAuthorization(channel: IChannel, isResponder: Boolean) { - val syncSession = channel.syncSession!! - val remotePublicKey = channel.remotePublicKey!! - - if (isResponder) { - val isAuthorized = database.isAuthorized(remotePublicKey) - if (!isAuthorized) { - val ap = this.authorizePrompt - if (ap == null) { - try { - Logger.i(TAG, "$remotePublicKey unauthorized because AuthorizePrompt is null") - syncSession.unauthorize() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize result.", e) - } - return; - } - - ap.invoke(remotePublicKey) { - try { - _scope?.launch(Dispatchers.IO) { - if (it) { - Logger.i(TAG, "$remotePublicKey manually authorized") - syncSession.authorize() - } else { - Logger.i(TAG, "$remotePublicKey manually unauthorized") - syncSession.unauthorize() - syncSession.close() - } - } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize result.") - } - } - } else { - //Responder does not need to check because already approved - syncSession.authorize() - Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized") - } - } else { - //Initiator does not need to check because the manual action of scanning the QR counts as approval - syncSession.authorize() - Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator") - } - } - - private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean { - Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).") - if (publicKey == StateSync.RELAY_PUBLIC_KEY) - return true - - if (database.isAuthorized(publicKey)) { - if (linkType == LinkType.Relayed && !settings.relayHandshakeAllowed) - return false - return true - } - - Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).") - if (_pairingCode == null || pairingCode.isNullOrEmpty()) - return false - - if (linkType == LinkType.Relayed && !settings.relayPairAllowed) - return false - - return _pairingCode == pairingCode - } - - private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession { - val remotePublicKey = rpk.base64ToByteArray().toBase64() - return SyncSession( - remotePublicKey, - onAuthorized = { it, isNewlyAuthorized, isNewSession -> - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") - } - - if (isNewSession) { - it.remoteDeviceName?.let { remoteDeviceName -> - database.setDeviceName(remotePublicKey, remoteDeviceName) - } - - database.addAuthorizedDevice(remotePublicKey) - } - - onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession) - }, - onUnauthorized = { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") - } - - onUnauthorized?.invoke(it) - }, - onConnectedChanged = { it, connected -> - Logger.i(TAG, "$remotePublicKey connected: $connected") - onConnectedChanged?.invoke(it, connected) - }, - onClose = { - Logger.i(TAG, "$remotePublicKey closed") - - removeSession(it.remotePublicKey) - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed") - } - - onClose?.invoke(it) - }, - dataHandler = { it, opcode, subOpcode, data -> - onData?.invoke(it, opcode, subOpcode, data) - }, - remoteDeviceName - ) - } - - fun getLinkType(publicKey: String): LinkType = synchronized(_sessions) { _sessions[publicKey]?.linkType ?: LinkType.None } - fun isConnected(publicKey: String): Boolean = synchronized(_sessions) { _sessions[publicKey]?.connected ?: false } - fun isAuthorized(publicKey: String): Boolean = database.isAuthorized(publicKey) - fun getSession(publicKey: String): SyncSession? = synchronized(_sessions) { _sessions[publicKey] } - fun getSessions(): List = synchronized(_sessions) { _sessions.values.toList() } - fun removeSession(publicKey: String) = synchronized(_sessions) { _sessions.remove(publicKey) } - fun getCachedName(publicKey: String): String? = database.getDeviceName(publicKey) - fun getAuthorizedDeviceCount(): Int = database.getAuthorizedDeviceCount() - fun getAllAuthorizedDevices(): Array? = database.getAllAuthorizedDevices() - fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey) - - fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) { - try { - connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to connect directly", e) - val relaySession = _relaySession - if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) { - onStatusUpdate?.invoke(null, "Connecting via relay...") - - runBlocking { - if (onStatusUpdate != null) { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate - } - } - relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode) - } - } else { - throw e - } - } - } - - fun connect(addresses: Array, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession { - onStatusUpdate?.invoke(null, "Connecting directly...") - val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") - onStatusUpdate?.invoke(null, "Handshaking...") - - val session = createSocketSession(socket, false) - if (onStatusUpdate != null) { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate - } - } - - session.startAsInitiator(publicKey, appId, pairingCode) - return session - } - - fun stop() { - _scope?.cancel() - _scope = null - _relaySession?.stop() - _relaySession = null - _serverSocket?.close() - _serverSocket = null - - synchronized(_sessions) { - _sessions.values.toList() - }.forEach { it.close() } - - synchronized(_sessions) { - _sessions.clear() - } - } - - private fun getDeviceName(): String { - val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - val model = Build.MODEL - - return if (model.startsWith(manufacturer, ignoreCase = true)) { - model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } else { - "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } - } - - companion object { - val dh = "25519" - val pattern = "IK" - val cipher = "ChaChaPoly" - val hash = "BLAKE2b" - var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" - - private const val TAG = "SyncService" - } -} \ No newline at end of file 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 59e048c6..e4273d63 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 @@ -1,14 +1,37 @@ package com.futo.platformplayer.sync.internal import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.ensureNotMainThread +import com.futo.platformplayer.activities.MainActivity +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.smartMerge +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateBackup +import com.futo.platformplayer.states.StateHistory +import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.states.StateSync +import com.futo.platformplayer.sync.SyncSessionData +import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode +import com.futo.platformplayer.sync.models.SendToDevicePackage +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage +import com.futo.platformplayer.sync.models.SyncWatchLaterPackage +import com.futo.platformplayer.toUtf8String +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch 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 import java.util.UUID interface IAuthorizable { @@ -16,16 +39,13 @@ interface IAuthorizable { } class SyncSession : IAuthorizable { - private val _channels: MutableList = mutableListOf() - @Volatile - private var _snapshot: Array = emptyArray() + private val _socketSessions: MutableList = mutableListOf() private var _authorized: Boolean = false private var _remoteAuthorized: Boolean = false private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit private val _onUnauthorized: (session: SyncSession) -> Unit private val _onClose: (session: SyncSession) -> Unit private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit - private val _dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit val remotePublicKey: String override val isAuthorized get() = _authorized && _remoteAuthorized private var _wasAuthorized = false @@ -36,150 +56,140 @@ class SyncSession : IAuthorizable { private set val displayName: String get() = remoteDeviceName ?: remotePublicKey - val linkType: LinkType get() - { - var linkType = LinkType.None - synchronized(_channels) - { - for (channel in _channels) - { - if (channel.linkType == LinkType.Direct) - return LinkType.Direct - if (channel.linkType == LinkType.Relayed) - linkType = LinkType.Relayed - } - } - return linkType - } - var connected: Boolean = false private set(v) { - if (field != v) { - field = v - this._onConnectedChanged(this, v) - } + if (field != v) { + field = v + this._onConnectedChanged(this, v) } + } - 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, - dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit, - remoteDeviceName: String? = null - ) { + 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 - this.remoteDeviceName = remoteDeviceName _onAuthorized = onAuthorized _onUnauthorized = onUnauthorized _onConnectedChanged = onConnectedChanged _onClose = onClose - _dataHandler = dataHandler } - fun addChannel(channel: IChannel) { - if (channel.remotePublicKey != remotePublicKey) { - throw Exception("Public key of session must match public key of channel") + fun addSocketSession(socketSession: SyncSocketSession) { + if (socketSession.remotePublicKey != remotePublicKey) { + throw Exception("Public key of session must match public key of socket session") } - synchronized(_channels) { - _channels.add(channel) - _channels.sortBy { it.linkType.ordinal } - _snapshot = _channels.toTypedArray() - connected = _channels.isNotEmpty() + synchronized(_socketSessions) { + _socketSessions.add(socketSession) + connected = _socketSessions.isNotEmpty() } - channel.authorizable = this - channel.syncSession = this + socketSession.authorizable = this } - fun authorize() { + fun authorize(socketSession: SyncSocketSession) { Logger.i(TAG, "Sent AUTHORIZED with session id $_id") - val idString = _id.toString() - val idBytes = idString.toByteArray(Charsets.UTF_8) - val name = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}" - val nameBytes = name.toByteArray(Charsets.UTF_8) - val buffer = ByteArray(1 + idBytes.size + 1 + nameBytes.size) - buffer[0] = idBytes.size.toByte() - System.arraycopy(idBytes, 0, buffer, 1, idBytes.size) - buffer[1 + idBytes.size] = nameBytes.size.toByte() - System.arraycopy(nameBytes, 0, buffer, 2 + idBytes.size, nameBytes.size) - send(Opcode.NOTIFY.value, NotifyOpcode.AUTHORIZED.value, ByteBuffer.wrap(buffer)) + + 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() } - fun unauthorize() { - send(Opcode.NOTIFY.value, NotifyOpcode.UNAUTHORIZED.value) + fun unauthorize(socketSession: SyncSocketSession? = null) { + if (socketSession != null) + socketSession.send(Opcode.NOTIFY_UNAUTHORIZED.value) + else { + val ss = synchronized(_socketSessions) { + _socketSessions.first() + } + + ss.send(Opcode.NOTIFY_UNAUTHORIZED.value) + } } private fun checkAuthorized() { if (isAuthorized) { - val isNewlyAuthorized = !_wasAuthorized - val isNewSession = _lastAuthorizedRemoteId != _remoteId - Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)") - _onAuthorized(this, isNewlyAuthorized, isNewSession) + val isNewlyAuthorized = !_wasAuthorized; + val isNewSession = _lastAuthorizedRemoteId != _remoteId; + Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)"); + _onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId) _wasAuthorized = true _lastAuthorizedRemoteId = _remoteId } } - fun removeChannel(channel: IChannel) { - synchronized(_channels) { - _channels.remove(channel) - _snapshot = _channels.toTypedArray() - connected = _channels.isNotEmpty() + fun removeSocketSession(socketSession: SyncSocketSession) { + synchronized(_socketSessions) { + _socketSessions.remove(socketSession) + connected = _socketSessions.isNotEmpty() } } fun close() { - val toClose = synchronized(_channels) { - val arr = _channels.toTypedArray() - _channels.clear() - _snapshot = emptyArray() - connected = false - arr + synchronized(_socketSessions) { + for (socketSession in _socketSessions) { + socketSession.stop() + } + + _socketSessions.clear() } - toClose.forEach { it.close() } - _onClose(this) + + _onClose.invoke(this) } - fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { try { - Logger.i(TAG, "Handle packet (opcode: $opcode, subOpcode: $subOpcode, data.length: ${data.remaining()})") + Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})") when (opcode) { - Opcode.NOTIFY.value -> when (subOpcode) { - NotifyOpcode.AUTHORIZED.value -> { + Opcode.NOTIFY_AUTHORIZED.value -> { + 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) - _remoteAuthorized = true - Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") - checkAuthorized() - return - } - NotifyOpcode.UNAUTHORIZED.value -> { - _remoteAuthorized = false - _remoteId = null + } else { + val str = data.toUtf8String() + _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") remoteDeviceName = null - _lastAuthorizedRemoteId = null - _onUnauthorized(this) - return } + + _remoteAuthorized = true + 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) + return + } + //TODO: Handle any kind of packet (that is not necessarily authorized) } if (!isAuthorized) { @@ -187,62 +197,282 @@ class SyncSession : IAuthorizable { } if (opcode != Opcode.DATA.value) { - Logger.w(TAG, "Unknown opcode received: (opcode = $opcode, subOpcode = $subOpcode)") + Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}") return } - Logger.i(TAG, "Received (opcode = $opcode, subOpcode = $subOpcode) (${data.remaining()} bytes)") - _dataHandler.invoke(this, opcode, subOpcode, data) - } catch (ex: Exception) { - Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex) + Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)") + //TODO: Abstract this out + when (subOpcode) { + GJSyncOpcodes.sendToDevices -> { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + val context = StateApp.instance.contextOrNull; + if (context != null && context is MainActivity) { + val dataBody = ByteArray(data.remaining()); + val remainder = data.remaining(); + data.get(dataBody, 0, remainder); + val json = String(dataBody, Charsets.UTF_8); + val obj = Json.decodeFromString(json); + UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{${obj.url}"); + context.handleUrl(obj.url, obj.position); + } + }; + } + + GJSyncOpcodes.syncStateExchange -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val syncSessionData = Serializer.json.decodeFromString(json); + + Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey); + + + sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); + sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); + sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) + + sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); + + val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); + if(recentHistory.size > 0) + sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); + } + + GJSyncOpcodes.syncExport -> { + val dataBody = ByteArray(data.remaining()); + val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); + try { + val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStr); + for (store in exportStruct.stores) { + if (store.key.equals("subscriptions", true)) { + val subStore = + StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val pack = SyncSubscriptionsPackage( + store.value.map { + subStore.fromReconstruction(it, exportStruct.cache) + }, + StateSubscriptions.instance.getSubscriptionRemovals() + ); + handleSyncSubscriptionPackage(this@SyncSession, pack); + } + } + } + } finally { + bytesStr.close(); + } + } + + GJSyncOpcodes.syncSubscriptions -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val subPackage = Serializer.json.decodeFromString(json); + handleSyncSubscriptionPackage(this, subPackage); + + val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; + + val sesData = StateSync.instance.getSyncSessionData(remotePublicKey); + if(newestSub > sesData.lastSubscription) { + sesData.lastSubscription = newestSub; + StateSync.instance.saveSyncSessionData(sesData); + } + } + + GJSyncOpcodes.syncSubscriptionGroups -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + var lastSubgroupChange = OffsetDateTime.MIN; + for(group in pack.groups){ + if(group.lastChange > lastSubgroupChange) + lastSubgroupChange = group.lastChange; + + val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + + if(existing == null) + StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); + else if(existing.lastChange < group.lastChange) { + existing.name = group.name; + existing.urls = group.urls; + existing.image = group.image; + existing.priority = group.priority; + existing.lastChange = group.lastChange; + StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); + } + } + for(removal in pack.groupRemovals) { + val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.creationTime < removalTime) + StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); + } + } + + GJSyncOpcodes.syncPlaylists -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + for(playlist in pack.playlists) { + val existing = StatePlaylists.instance.getPlaylist(playlist.id); + + if(existing == null) + StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); + else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { + existing.dateUpdate = playlist.dateUpdate; + existing.name = playlist.name; + existing.videos = playlist.videos; + existing.dateCreation = playlist.dateCreation; + existing.datePlayed = playlist.datePlayed; + StatePlaylists.instance.createOrUpdatePlaylist(existing, false); + } + } + for(removal in pack.playlistRemovals) { + val creation = StatePlaylists.instance.getPlaylist(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.dateCreation < removalTime) + StatePlaylists.instance.removePlaylist(creation, false); + + } + } + + GJSyncOpcodes.syncWatchLater -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})"); + + val allExisting = StatePlaylists.instance.getWatchLater(); + for(video in pack.videos) { + val existing = allExisting.firstOrNull { it.url == video.url }; + val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN; + + if(existing == null) { + StatePlaylists.instance.addToWatchLater(video, false); + if(time > OffsetDateTime.MIN) + StatePlaylists.instance.setWatchLaterAddTime(video.url, time); + } + } + for(removal in pack.videoRemovals) { + val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; + val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC); + if(creation < removalTime) + StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); + } + + val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC); + val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); + if(localReorderTime < packReorderTime && pack.ordering != null) { + StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); + } + } + + GJSyncOpcodes.syncHistory -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val history = Serializer.json.decodeFromString>(json); + Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); + + var lastHistory = OffsetDateTime.MIN; + for(video in history){ + val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date); + if(hist != null) + StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date) + if(lastHistory < video.date) + lastHistory = video.date; + } + + if(lastHistory != OffsetDateTime.MIN && history.size > 1) { + val sesData = StateSync.instance.getSyncSessionData(remotePublicKey); + if (lastHistory > sesData.lastHistory) { + sesData.lastHistory = lastHistory; + StateSync.instance.saveSyncSessionData(sesData); + } + } + } + } } catch(ex: Exception) { Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex); } } + private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { + val added = mutableListOf() + for(sub in pack.subscriptions) { + if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { + val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); + if(sub.creationTime > removalTime) { + val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); + added.add(newSub); + } + } + } + if(added.size > 3) + UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); + else if(added.size > 0) + UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + + added.map { it.channel.name }.joinToString("\n")); + + + if(pack.subscriptions != null && pack.subscriptions.size > 0) { + for (subRemoved in pack.subscriptionRemovals) { + val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); + if(removed.size > 3) + UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); + else if(removed.size > 0) + UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + + removed.map { it.channel.name }.joinToString("\n")); + + } + } + } + inline fun sendJsonData(subOpcode: UByte, data: T) { - ensureNotMainThread() - send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)) + send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); } - fun sendData(subOpcode: UByte, data: String) { - ensureNotMainThread() - send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) + send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); } - fun send(opcode: UByte, subOpcode: UByte, data: String) { - ensureNotMainThread() - send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) + send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); } + fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) { + val socketSessions = synchronized(_socketSessions) { + _socketSessions.toList() + } - fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) { - ensureNotMainThread() - val channels = _snapshot - if (channels.isEmpty()) { - Logger.v(TAG, "Packet was not sent … no connected sockets") + if (socketSessions.isEmpty()) { + Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets") return } var sent = false - for (channel in channels) { + for (socketSession in socketSessions) { try { - channel.send(opcode, subOpcode, data, contentEncoding) + socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data)) sent = true break } catch (e: Throwable) { - Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode), closing channel", e) - channel.close() - removeChannel(channel) + Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e) } } if (!sent) { - throw Exception("Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to send errors and no remaining candidates") + throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates") } } - companion object { - private const val TAG = "SyncSession" + private companion object { + const val TAG = "SyncSession" } } \ No newline at end of file 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 35437a7b..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 @@ -1,139 +1,76 @@ package com.futo.platformplayer.sync.internal -import android.os.Build +import com.futo.platformplayer.LittleEndianDataInputStream +import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.ensureNotMainThread -import com.futo.platformplayer.findCandidateAddresses import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState -import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSync -import com.futo.polycentric.core.base64ToByteArray -import com.futo.polycentric.core.toBase64 -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.io.OutputStream -import java.net.Inet4Address -import java.net.Inet6Address -import java.net.InetAddress -import java.net.NetworkInterface -import java.net.Socket import java.nio.ByteBuffer import java.nio.ByteOrder -import java.util.Base64 -import java.util.Locale -import java.util.concurrent.ConcurrentHashMap -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream -import kotlin.system.measureTimeMillis +import java.util.UUID class SyncSocketSession { - private val _socket: Socket - private val _inputStream: InputStream - private val _outputStream: OutputStream + enum class Opcode(val value: UByte) { + PING(0u), + PONG(1u), + NOTIFY_AUTHORIZED(2u), + NOTIFY_UNAUTHORIZED(3u), + STREAM_START(4u), + STREAM_DATA(5u), + STREAM_END(6u), + DATA(7u) + } + + private val _inputStream: LittleEndianDataInputStream + private val _outputStream: LittleEndianDataOutputStream private val _sendLockObject = Object() private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE) private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE) - private val _sendBufferEncrypted = ByteArray(4 + MAXIMUM_PACKET_SIZE_ENCRYPTED) + private val _sendBufferEncrypted = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _syncStreams = hashMapOf() - private var _streamIdGenerator = 0 + private val _streamIdGenerator = 0 private val _streamIdGeneratorLock = Object() - private var _requestIdGenerator = 0 - private val _requestIdGeneratorLock = Object() - private val _onClose: ((session: SyncSocketSession) -> Unit)? - private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? - private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? - private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? - private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? + private val _onClose: (session: SyncSocketSession) -> Unit + private val _onHandshakeComplete: (session: SyncSocketSession) -> Unit + private var _thread: Thread? = null private var _cipherStatePair: CipherStatePair? = null private var _remotePublicKey: String? = null val remotePublicKey: String? get() = _remotePublicKey private var _started: Boolean = false private val _localKeyPair: DHState - private var _thread: Thread? = null private var _localPublicKey: String val localPublicKey: String get() = _localPublicKey - private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? - val isAuthorized: Boolean - get() = authorizable?.isAuthorized ?: false + 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 - private val _channels = ConcurrentHashMap() - private val _pendingChannels = ConcurrentHashMap>>() - private val _pendingConnectionInfoRequests = ConcurrentHashMap>() - private val _pendingPublishRequests = ConcurrentHashMap>() - private val _pendingDeleteRequests = ConcurrentHashMap>() - private val _pendingListKeysRequests = ConcurrentHashMap>>>() - private val _pendingGetRecordRequests = ConcurrentHashMap?>>() - private val _pendingBulkGetRecordRequests = ConcurrentHashMap>>>() - private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap>>() - - @Volatile - private var _lastPongTime: Long = System.currentTimeMillis() - private val _pingInterval: Long = 5000 // 5 seconds in milliseconds - private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds - - data class ConnectionInfo( - val port: UShort, - val name: String, - val remoteIp: String, - val ipv4Addresses: List, - val ipv6Addresses: List, - val allowLocalDirect: Boolean, - val allowRemoteDirect: Boolean, - val allowRemoteHolePunched: Boolean, - val allowRemoteRelayed: Boolean - ) - - constructor( - remoteAddress: String, - localKeyPair: DHState, - socket: Socket, - onClose: ((session: SyncSocketSession) -> Unit)? = null, - onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null, - onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, - onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, - onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, - isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? = null - ) { - _socket = socket - _socket.receiveBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED - _socket.sendBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED - _socket.tcpNoDelay = true - _inputStream = _socket.getInputStream() - _outputStream = _socket.getOutputStream() + constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit) { + _inputStream = inputStream + _outputStream = outputStream _onClose = onClose _onHandshakeComplete = onHandshakeComplete _localKeyPair = localKeyPair _onData = onData - _onNewChannel = onNewChannel - _onChannelEstablished = onChannelEstablished - _isHandshakeAllowed = isHandshakeAllowed this.remoteAddress = remoteAddress val localPublicKey = ByteArray(localKeyPair.publicKeyLength) localKeyPair.getPublicKey(localPublicKey, 0) - _localPublicKey = localPublicKey.toBase64() + _localPublicKey = java.util.Base64.getEncoder().encodeToString(localPublicKey) } - fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { + fun startAsInitiator(remotePublicKey: String) { _started = true _thread = Thread { try { - handshakeAsInitiator(remotePublicKey, appId, pairingCode) - _onHandshakeComplete?.invoke(this) - startPingLoop() + handshakeAsInitiator(remotePublicKey) + _onHandshakeComplete.invoke(this) receiveLoop() } catch (e: Throwable) { Logger.e(TAG, "Failed to run as initiator", e) @@ -143,30 +80,14 @@ class SyncSocketSession { }.apply { start() } } - fun runAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { - _started = true - try { - handshakeAsInitiator(remotePublicKey, appId, pairingCode) - _onHandshakeComplete?.invoke(this) - startPingLoop() - receiveLoop() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to run as initiator", e) - } finally { - stop() - } - } - fun startAsResponder() { _started = true _thread = Thread { try { - if (handshakeAsResponder()) { - _onHandshakeComplete?.invoke(this) - startPingLoop() - receiveLoop() - } - } catch (e: Throwable) { + handshakeAsResponder() + _onHandshakeComplete.invoke(this) + receiveLoop() + } catch(e: Throwable) { Logger.e(TAG, "Failed to run as responder", e) } finally { stop() @@ -174,45 +95,30 @@ class SyncSocketSession { }.apply { start() } } - private fun readExact(buffer: ByteArray, offset: Int, size: Int) { - var totalBytesReceived: Int = 0 - while (totalBytesReceived < size) { - val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived) - if (bytesReceived <= 0) - throw Exception("Socket disconnected") - totalBytesReceived += bytesReceived - } - } - private fun receiveLoop() { while (_started) { try { - //Logger.v(TAG, "Waiting for message size...") - - readExact(_buffer, 0, 4) - val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int - - //Logger.v(TAG, "Read message size ${messageSize}.") - + val messageSize = _inputStream.readInt() if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") } //Logger.i(TAG, "Receiving message (size = ${messageSize})") - readExact(_buffer, 0, messageSize) - //Logger.v(TAG, "Read ${messageSize}.") + var bytesRead = 0 + while (bytesRead < messageSize) { + val read = _inputStream.read(_buffer, bytesRead, messageSize - bytesRead) + if (read == -1) + throw Exception("Stream closed") + bytesRead += read + } - //Logger.v(TAG, "Decrypting ${messageSize} bytes.") val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize) //Logger.i(TAG, "Decrypted message (size = ${plen})") - //Logger.v(TAG, "Decrypted ${messageSize} bytes.") - handleData(_bufferDecrypted, plen, null) - //Logger.v(TAG, "Handled data ${messageSize} bytes.") + handleData(_bufferDecrypted, plen) } catch (e: Throwable) { - Logger.e(TAG, "Exception while receiving data, closing socket session", e) - stop() + Logger.e(TAG, "Exception while receiving data", e) break } } @@ -220,1104 +126,273 @@ class SyncSocketSession { fun stop() { _started = false - _pendingConnectionInfoRequests.forEach { it.value.cancel() } - _pendingConnectionInfoRequests.clear() - _pendingPublishRequests.forEach { it.value.cancel() } - _pendingPublishRequests.clear() - _pendingDeleteRequests.forEach { it.value.cancel() } - _pendingDeleteRequests.clear() - _pendingListKeysRequests.forEach { it.value.cancel() } - _pendingListKeysRequests.clear() - _pendingGetRecordRequests.forEach { it.value.cancel() } - _pendingGetRecordRequests.clear() - _pendingBulkGetRecordRequests.forEach { it.value.cancel() } - _pendingBulkGetRecordRequests.clear() - _pendingBulkConnectionInfoRequests.forEach { it.value.cancel() } - _pendingBulkConnectionInfoRequests.clear() - _pendingChannels.forEach { it.value.first.close(); it.value.second.cancel() } - _pendingChannels.clear() - synchronized(_syncStreams) { - _syncStreams.clear() - } - _channels.values.forEach { it.close() } - _channels.clear() - _onClose?.invoke(this) - _socket.close() + _onClose(this) + _inputStream.close() + _outputStream.close() _thread = null - _cipherStatePair?.sender?.destroy() - _cipherStatePair?.receiver?.destroy() Logger.i(TAG, "Session closed") } - private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) { + private fun handshakeAsInitiator(remotePublicKey: String) { performVersionCheck() - val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR) + val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) initiator.localKeyPair.copyFrom(_localKeyPair) - initiator.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0) - initiator.start() - val pairingMessage: ByteArray - val pairingMessageLength: Int - if (pairingCode != null) { - val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) - pairingHandshake.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0) - pairingHandshake.start() - val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) - val pairingBuffer = ByteArray(512) - pairingMessageLength = pairingHandshake.writeMessage(pairingBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size) - pairingMessage = pairingBuffer.copyOf(pairingMessageLength) - } else { - pairingMessage = ByteArray(0) - pairingMessageLength = 0 + initiator.remotePublicKey.setPublicKey(java.util.Base64.getDecoder().decode(remotePublicKey), 0) + _cipherStatePair = handshake(initiator) + + _remotePublicKey = initiator.remotePublicKey.let { + val pkey = ByteArray(it.publicKeyLength) + it.getPublicKey(pkey, 0) + return@let java.util.Base64.getEncoder().encodeToString(pkey) } - - val mainBuffer = ByteArray(512) - val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) - - val messageSize = 4 + 4 + pairingMessageLength + mainLength - val messageData = ByteBuffer.allocate(4 + messageSize).order(ByteOrder.LITTLE_ENDIAN) - messageData.putInt(messageSize) - messageData.putInt(appId.toInt()) - messageData.putInt(pairingMessageLength) - if (pairingMessageLength > 0) messageData.put(pairingMessage) - messageData.put(mainBuffer, 0, mainLength) - val messageDataArray = messageData.array() - _outputStream.write(messageDataArray, 0, 4 + messageSize) - - readExact(_buffer, 0, 4) - val responseSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int - if (responseSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { - throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") - } - - val responseMessage = ByteArray(responseSize) - readExact(responseMessage, 0, responseSize) - - val plaintext = ByteArray(512) // Buffer for any payload (none expected here) - initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0) - - _cipherStatePair = initiator.split() - val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) - initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - _remotePublicKey = remoteKeyBytes.toBase64() } - private fun handshakeAsResponder(): Boolean { + private fun handshakeAsResponder() { performVersionCheck() - val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER) + val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER) responder.localKeyPair.copyFrom(_localKeyPair) - responder.start() + _cipherStatePair = handshake(responder) - readExact(_buffer, 0, 4) - val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int - if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { - throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") + _remotePublicKey = responder.remotePublicKey.let { + val pkey = ByteArray(it.publicKeyLength) + it.getPublicKey(pkey, 0) + return@let java.util.Base64.getEncoder().encodeToString(pkey) } - - val message = ByteArray(messageSize) - readExact(message, 0, messageSize) - - val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) - val appId = messageBuffer.int.toUInt() - val pairingMessageLength = messageBuffer.int - val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() - val mainLength = messageBuffer.remaining() - val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } - - var pairingCode: String? = null - if (pairingMessageLength > 0) { - val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER) - pairingHandshake.localKeyPair.copyFrom(_localKeyPair) - pairingHandshake.start() - val pairingPlaintext = ByteArray(512) - val plaintextLength = pairingHandshake.readMessage(pairingMessage, 0, pairingMessageLength, pairingPlaintext, 0) - pairingCode = String(pairingPlaintext, 0, plaintextLength, Charsets.UTF_8) - } - - val plaintext = ByteArray(512) - responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) - val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) - responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - val remotePublicKey = remoteKeyBytes.toBase64() - - val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true) - if (!isAllowedToConnect) { - stop() - return false - } - - val responseBuffer = ByteArray(4 + 512) - val responseLength = responder.writeMessage(responseBuffer, 4, null, 0, 0) - ByteBuffer.wrap(responseBuffer).order(ByteOrder.LITTLE_ENDIAN).putInt(responseLength) - _outputStream.write(responseBuffer, 0, 4 + responseLength) - - _cipherStatePair = responder.split() - _remotePublicKey = remotePublicKey.base64ToByteArray().toBase64() - return true } private fun performVersionCheck() { - val CURRENT_VERSION = 5 - val MINIMUM_VERSION = 4 - - val versionBytes = ByteArray(4) - ByteBuffer.wrap(versionBytes).order(ByteOrder.LITTLE_ENDIAN).putInt(CURRENT_VERSION) - _outputStream.write(versionBytes, 0, 4) - - readExact(versionBytes, 0, 4) - remoteVersion = ByteBuffer.wrap(versionBytes, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + val CURRENT_VERSION = 3 + val MINIMUM_VERSION = 2 + _outputStream.writeInt(CURRENT_VERSION) + remoteVersion = _inputStream.readInt() Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") if (remoteVersion < MINIMUM_VERSION) throw Exception("Invalid version") } - fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } - private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } + private fun handshake(handshakeState: HandshakeState): CipherStatePair { + handshakeState.start() - fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, ce: ContentEncoding? = null) { - ensureNotMainThread() + val message = ByteArray(8192) + val plaintext = ByteArray(8192) - Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})") + while (_started) { + when (handshakeState.action) { + HandshakeState.READ_MESSAGE -> { + val messageSize = _inputStream.readInt() + Logger.i(TAG, "Handshake read message (size = ${messageSize})") - var contentEncoding: ContentEncoding? = ce - var processedData = data - if (contentEncoding == ContentEncoding.Gzip) { - val isGzipSupported = opcode == Opcode.DATA.value - if (isGzipSupported) { - val compressedStream = ByteArrayOutputStream() - GZIPOutputStream(compressedStream).use { gzipStream -> - gzipStream.write(data.array(), data.position(), data.remaining()) - gzipStream.finish() + var bytesRead = 0 + while (bytesRead < messageSize) { + val read = _inputStream.read(message, bytesRead, messageSize - bytesRead) + if (read == -1) + throw Exception("Stream closed") + bytesRead += read + } + + handshakeState.readMessage(message, 0, messageSize, plaintext, 0) } - processedData = ByteBuffer.wrap(compressedStream.toByteArray()) - } else { - Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.") - contentEncoding = ContentEncoding.Raw + HandshakeState.WRITE_MESSAGE -> { + val messageSize = handshakeState.writeMessage(message, 0, null, 0, 0) + Logger.i(TAG, "Handshake wrote message (size = ${messageSize})") + _outputStream.writeInt(messageSize) + _outputStream.write(message, 0, messageSize) + } + HandshakeState.SPLIT -> { + //Logger.i(TAG, "Handshake split") + return handshakeState.split() + } + else -> throw Exception("Unexpected state (handshakeState.action = ${handshakeState.action})") } } - if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { + throw Exception("Handshake finished without completing") + } + + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + ensureNotMainThread() + + if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentData = ByteArray(segmentSize) var sendOffset = 0 - val id = generateStreamId() + val id = synchronized(_streamIdGeneratorLock) { + _streamIdGenerator + 1 + } - while (sendOffset < processedData.remaining()) { - val bytesRemaining = processedData.remaining() - sendOffset + while (sendOffset < data.remaining()) { + val bytesRemaining = data.remaining() - sendOffset var bytesToSend: Int var segmentPacketSize: Int - val streamOp: StreamOpcode + val segmentOpcode: UByte if (sendOffset == 0) { - streamOp = StreamOpcode.START - bytesToSend = segmentSize - 4 - HEADER_SIZE - segmentPacketSize = bytesToSend + 4 + HEADER_SIZE + segmentOpcode = Opcode.STREAM_START.value + bytesToSend = segmentSize - 4 - 4 - 1 - 1 + segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1 } else { bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining) - streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA + segmentOpcode = if (bytesToSend >= bytesRemaining) Opcode.STREAM_END.value else Opcode.STREAM_DATA.value segmentPacketSize = bytesToSend + 4 + 4 } ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(id) - putInt(if (streamOp == StreamOpcode.START) processedData.remaining() else sendOffset) - if (streamOp == StreamOpcode.START) { + putInt(if (segmentOpcode == Opcode.STREAM_START.value) data.remaining() else sendOffset) + if (segmentOpcode == Opcode.STREAM_START.value) { put(opcode.toByte()) put(subOpcode.toByte()) - put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) } - put(processedData.array(), processedData.position() + sendOffset, bytesToSend) + put(data.array(), data.position() + sendOffset, bytesToSend) } - send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) + send(segmentOpcode, 0u, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) sendOffset += bytesToSend } } else { synchronized(_sendLockObject) { ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(processedData.remaining() + HEADER_SIZE - 4) + putInt(data.remaining() + 2) put(opcode.toByte()) put(subOpcode.toByte()) - put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) - put(processedData.array(), processedData.position(), processedData.remaining()) + put(data.array(), data.position(), data.remaining()) } - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, processedData.remaining() + HEADER_SIZE) - val sendDuration = measureTimeMillis { - ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) - _outputStream.write(_sendBufferEncrypted, 0, 4 + len) - } - Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") + //Logger.i(TAG, "Encrypting message (size = ${data.size + HEADER_SIZE})") + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, data.remaining() + HEADER_SIZE) + //Logger.i(TAG, "Sending encrypted message (size = ${len})") + _outputStream.writeInt(len) + _outputStream.write(_sendBufferEncrypted, 0, len) } } } - @OptIn(ExperimentalUnsignedTypes::class) fun send(opcode: UByte, subOpcode: UByte = 0u) { ensureNotMainThread() synchronized(_sendLockObject) { - ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4) + ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2) _sendBuffer.asUByteArray()[4] = opcode _sendBuffer.asUByteArray()[5] = subOpcode - _sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value //Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})") - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE) + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE) //Logger.i(TAG, "Sending encrypted message (size = ${len})") - ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) - _outputStream.write(_sendBufferEncrypted, 0, 4 + len) + _outputStream.writeInt(len) + _outputStream.write(_sendBufferEncrypted, 0, len) } } - private fun handleData(data: ByteArray, length: Int, sourceChannel: ChannelRelayed?) { - return handleData(ByteBuffer.wrap(data, 0, length).order(ByteOrder.LITTLE_ENDIAN), sourceChannel) - } - - private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) { - val length = data.remaining() + private fun handleData(data: ByteArray, length: Int) { if (length < HEADER_SIZE) - throw Exception("Packet must be at least ${HEADER_SIZE} bytes (header size)") + throw Exception("Packet must be at least 6 bytes (header size)") - val size = data.int + val size = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int if (size != length - 4) throw Exception("Incomplete packet received") - val opcode = data.get().toUByte() - val subOpcode = data.get().toUByte() - val contentEncoding = data.get().toUByte() - - //Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})") - handlePacket(opcode, subOpcode, data, contentEncoding, sourceChannel) + val opcode = data.asUByteArray()[4] + val subOpcode = data.asUByteArray()[5] + val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 2) + handlePacket(opcode, subOpcode, packetData.order(ByteOrder.LITTLE_ENDIAN)) } - private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { - when (subOpcode) { - RequestOpcode.TRANSPORT_RELAYED.value -> { - Logger.i(TAG, "Received request for a relayed transport") - if (data.remaining() < 52) { - Logger.e(TAG, "HandleRequestTransport: Packet too short") - return - } - val remoteVersion = data.int - val connectionId = data.long - val requestId = data.int - val appId = data.int.toUInt() - val publicKeyBytes = ByteArray(32).also { data.get(it) } - val pairingMessageLength = data.int - if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128) (app id: $appId)") - val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0) - val channelMessageLength = data.int - if (data.remaining() != channelMessageLength) { - Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()} (app id: $appId)") - return - } - val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } - val publicKey = publicKeyBytes.toBase64() - val pairingCode = if (pairingMessageLength > 0) { - val pairingProtocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { - localKeyPair.copyFrom(_localKeyPair) - start() - } - val plaintext = ByteArray(1024) - val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) - String(plaintext, 0, length, Charsets.UTF_8) - } else null - 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(7) // Status code for not allowed - rp.putLong(connectionId) - rp.putInt(requestId) - rp.rewind() - send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, rp) - return - } - val channel = ChannelRelayed(this, _localKeyPair, publicKey, false) - channel.connectionId = connectionId - _onNewChannel?.invoke(this, channel) - _channels[connectionId] = channel - channel.sendResponseTransport(remoteVersion, requestId, channelHandshakeMessage) - _onChannelEstablished?.invoke(this, channel, true) - } - else -> Logger.w(TAG, "Unhandled request opcode: $subOpcode") - } - } - - private fun handleResponse(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { - if (data.remaining() < 8) { - Logger.e(TAG, "Response packet too short") - return - } - val requestId = data.int - val statusCode = data.int - when (subOpcode) { - ResponseOpcode.CONNECTION_INFO.value -> { - _pendingConnectionInfoRequests.remove(requestId)?.let { tcs -> - if (statusCode == 0) { - try { - val connectionInfo = parseConnectionInfo(data) - tcs.complete(connectionInfo) - } catch (e: Exception) { - tcs.completeExceptionally(e) - } - } else { - tcs.complete(null) - } - } ?: Logger.e(TAG, "No pending request for requestId $requestId") - } - ResponseOpcode.TRANSPORT_RELAYED.value -> { - if (statusCode == 0) { - if (data.remaining() < 16) { - Logger.e(TAG, "RESPONSE_TRANSPORT packet too short") - return - } - val remoteVersion = data.int - val connectionId = data.long - val messageLength = data.int - if (data.remaining() != messageLength) { - Logger.e(TAG, "Invalid RESPONSE_TRANSPORT packet size. Expected ${16 + messageLength}, got ${data.remaining() + 16}") - return - } - val handshakeMessage = ByteArray(messageLength).also { data.get(it) } - _pendingChannels.remove(requestId)?.let { (channel, tcs) -> - channel.handleTransportRelayed(remoteVersion, connectionId, handshakeMessage) - _channels[connectionId] = channel - tcs.complete(channel) - _onChannelEstablished?.invoke(this, channel, false) - } ?: Logger.e(TAG, "No pending channel for requestId $requestId") - } else { - _pendingChannels.remove(requestId)?.let { (channel, tcs) -> - channel.close() - tcs.completeExceptionally(Exception("Relayed transport request $requestId failed with code $statusCode")) - } - } - } - ResponseOpcode.PUBLISH_RECORD.value, ResponseOpcode.BULK_PUBLISH_RECORD.value -> { - _pendingPublishRequests.remove(requestId)?.complete(statusCode == 0) - ?: Logger.e(TAG, "No pending publish request for requestId $requestId") - } - ResponseOpcode.DELETE_RECORD.value, ResponseOpcode.BULK_DELETE_RECORD.value -> { - _pendingDeleteRequests.remove(requestId)?.complete(statusCode == 0) - ?: Logger.e(TAG, "No pending delete request for requestId $requestId") - } - ResponseOpcode.LIST_RECORD_KEYS.value -> { - _pendingListKeysRequests.remove(requestId)?.let { tcs -> - if (statusCode == 0) { - try { - val keyCount = data.int - val keys = mutableListOf>() - repeat(keyCount) { - val keyLength = data.get().toInt() - val key = ByteArray(keyLength).also { data.get(it) }.toString(Charsets.UTF_8) - val timestamp = data.long - keys.add(key to timestamp) - } - tcs.complete(keys) - } catch (e: Exception) { - tcs.completeExceptionally(e) - } - } else { - tcs.completeExceptionally(Exception("Error listing keys: status code $statusCode")) - } - } ?: Logger.e(TAG, "No pending list keys request for requestId $requestId") - } - ResponseOpcode.GET_RECORD.value -> { - _pendingGetRecordRequests.remove(requestId)?.let { tcs -> - if (statusCode == 0) { - try { - val blobLength = data.int - val encryptedBlob = ByteArray(blobLength).also { data.get(it) } - val timestamp = data.long - val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { - localKeyPair.copyFrom(_localKeyPair) - start() - } - val handshakeMessage = encryptedBlob.copyOf(48) - val plaintext = ByteArray(0) - protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) - val transportPair = protocol.split() - var blobOffset = 48 - val chunks = mutableListOf() - while (blobOffset + 4 <= encryptedBlob.size) { - val chunkLength = ByteBuffer.wrap(encryptedBlob, blobOffset, 4).order(ByteOrder.LITTLE_ENDIAN).int - blobOffset += 4 - val encryptedChunk = encryptedBlob.copyOfRange(blobOffset, blobOffset + chunkLength) - val decryptedChunk = ByteArray(chunkLength - 16) - transportPair.receiver.decryptWithAd(null, encryptedChunk, 0, decryptedChunk, 0, encryptedChunk.size) - chunks.add(decryptedChunk) - blobOffset += chunkLength - } - val dataResult = chunks.reduce { acc, bytes -> acc + bytes } - tcs.complete(dataResult to timestamp) - } catch (e: Exception) { - tcs.completeExceptionally(e) - } - } else if (statusCode == 2) { - tcs.complete(null) - } else { - tcs.completeExceptionally(Exception("Error getting record: statusCode $statusCode")) - } - } - } - ResponseOpcode.BULK_GET_RECORD.value -> { - _pendingBulkGetRecordRequests.remove(requestId)?.let { tcs -> - if (statusCode == 0) { - try { - val recordCount = data.get().toInt() - val records = mutableMapOf>() - repeat(recordCount) { - val publisherBytes = ByteArray(32).also { data.get(it) } - val publisher = publisherBytes.toBase64() - val blobLength = data.int - val encryptedBlob = ByteArray(blobLength).also { data.get(it) } - val timestamp = data.long - val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { - localKeyPair.copyFrom(_localKeyPair) - start() - } - val handshakeMessage = encryptedBlob.copyOf(48) - val plaintext = ByteArray(0) - protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) - val transportPair = protocol.split() - var blobOffset = 48 - val chunks = mutableListOf() - while (blobOffset + 4 <= encryptedBlob.size) { - val chunkLength = ByteBuffer.wrap(encryptedBlob, blobOffset, 4).order(ByteOrder.LITTLE_ENDIAN).int - blobOffset += 4 - val encryptedChunk = encryptedBlob.copyOfRange(blobOffset, blobOffset + chunkLength) - val decryptedChunk = ByteArray(chunkLength - 16) - transportPair.receiver.decryptWithAd(null, encryptedChunk, 0, decryptedChunk, 0, encryptedChunk.size) - chunks.add(decryptedChunk) - blobOffset += chunkLength - } - val dataResult = chunks.reduce { acc, bytes -> acc + bytes } - records[publisher] = dataResult to timestamp - } - tcs.complete(records) - } catch (e: Exception) { - tcs.completeExceptionally(e) - } - } else { - tcs.completeExceptionally(Exception("Error getting bulk records: statusCode $statusCode")) - } - } - } - ResponseOpcode.BULK_CONNECTION_INFO.value -> { - _pendingBulkConnectionInfoRequests.remove(requestId)?.let { tcs -> - try { - val numResponses = data.get().toInt() - val result = mutableMapOf() - repeat(numResponses) { - val publicKey = ByteArray(32).also { data.get(it) }.toBase64() - val status = data.get().toInt() - if (status == 0) { - val infoSize = data.int - val infoData = ByteArray(infoSize).also { data.get(it) } - result[publicKey] = parseConnectionInfo(ByteBuffer.wrap(infoData).order(ByteOrder.LITTLE_ENDIAN)) - } - } - tcs.complete(result) - } catch (e: Exception) { - tcs.completeExceptionally(e) - } - } ?: Logger.e(TAG, "No pending bulk request for requestId $requestId") - } - } - } - - private fun parseConnectionInfo(data: ByteBuffer): ConnectionInfo { - val ipSize = data.get().toInt() - val remoteIpBytes = ByteArray(ipSize).also { data.get(it) } - val remoteIp = remoteIpBytes.joinToString(".") { it.toUByte().toString() } - val handshakeMessage = ByteArray(48).also { data.get(it) } - val ciphertext = ByteArray(data.remaining()).also { data.get(it) } - val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { - localKeyPair.copyFrom(_localKeyPair) - start() - } - val plaintext = ByteArray(0) - protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) - val transportPair = protocol.split() - val decryptedData = ByteArray(ciphertext.size - 16) - transportPair.receiver.decryptWithAd(null, ciphertext, 0, decryptedData, 0, ciphertext.size) - val info = ByteBuffer.wrap(decryptedData).order(ByteOrder.LITTLE_ENDIAN) - val port = info.short.toUShort() - val nameLength = info.get().toInt() - val name = ByteArray(nameLength).also { info.get(it) }.toString(Charsets.UTF_8) - val ipv4Count = info.get().toInt() - val ipv4Addresses = List(ipv4Count) { ByteArray(4).also { info.get(it) }.joinToString(".") { it.toUByte().toString() } } - val ipv6Count = info.get().toInt() - val ipv6Addresses = List(ipv6Count) { ByteArray(16).also { info.get(it) }.joinToString(":") { it.toUByte().toString(16).padStart(2, '0') } } - val allowLocalDirect = info.get() != 0.toByte() - val allowRemoteDirect = info.get() != 0.toByte() - val allowRemoteHolePunched = info.get() != 0.toByte() - val allowRemoteRelayed = info.get() != 0.toByte() - return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteRelayed) - } - - private fun handleNotify(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { - when (subOpcode) { - NotifyOpcode.AUTHORIZED.value, NotifyOpcode.UNAUTHORIZED.value -> { - if (sourceChannel != null) - sourceChannel.invokeDataHandler(Opcode.NOTIFY.value, subOpcode, data) - else - _onData?.invoke(this, Opcode.NOTIFY.value, subOpcode, data) - } - NotifyOpcode.CONNECTION_INFO.value -> { /* Handle connection info if needed */ } - } - } - - fun sendRelayError(connectionId: Long, errorCode: SyncErrorCode) { - val packet = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) - packet.putLong(connectionId) - packet.putInt(errorCode.value) - packet.rewind() - send(Opcode.RELAY.value, RelayOpcode.RELAY_ERROR.value, packet) - } - - private fun handleRelay(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { - when (subOpcode) { - RelayOpcode.RELAYED_DATA.value -> { - if (data.remaining() < 8) { - Logger.e(TAG, "RELAYED_DATA packet too short") - return - } - val connectionId = data.long - val channel = _channels[connectionId] ?: run { - Logger.e(TAG, "No channel found for connectionId $connectionId") - return - } - val decryptedPayload = channel.decrypt(data) - try { - handleData(decryptedPayload, channel) - } catch (e: Exception) { - Logger.e(TAG, "Exception while handling relayed data", e) - channel.sendError(SyncErrorCode.ConnectionClosed) - channel.close() - _channels.remove(connectionId) - } - } - RelayOpcode.RELAYED_ERROR.value -> { - if (data.remaining() < 8) { - Logger.e(TAG, "RELAYED_ERROR packet too short") - return - } - val connectionId = data.long - val channel = _channels[connectionId] ?: run { - Logger.e(TAG, "No channel found for connectionId $connectionId") - sendRelayError(connectionId, SyncErrorCode.NotFound) - return - } - val decryptedPayload = channel.decrypt(data) - val errorCode = decryptedPayload.int - Logger.e(TAG, "Received relayed error (errorCode = $errorCode) on connectionId $connectionId, closing") - channel.close() - _channels.remove(connectionId) - } - RelayOpcode.RELAY_ERROR.value -> { - if (data.remaining() < 12) { - Logger.e(TAG, "RELAY_ERROR packet too short") - return - } - val connectionId = data.long - val errorCode = data.int - val channel = _channels[connectionId] ?: run { - Logger.e(TAG, "Received error code $errorCode for non-existent channel with connectionId $connectionId") - return - } - Logger.i(TAG, "Received relay error (errorCode = $errorCode) on connectionId $connectionId, closing") - channel.close() - _channels.remove(connectionId) - _pendingChannels.entries.find { it.value.first == channel }?.let { - _pendingChannels.remove(it.key)?.second?.cancel() - } - } - } - } - - private fun startPingLoop() { - if (remoteVersion < 5) return - - _lastPongTime = System.currentTimeMillis() - - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - while (_started) { - delay(_pingInterval) - - if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) { - Logger.e(TAG, "Session timed out waiting for PONG; closing.") - stop() - break - } - send(Opcode.PING.value) - } - } catch (e: Exception) { - Logger.e(TAG, "Ping loop failed", e) - stop() - } - } - } - - private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) { + private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})") - var data = d - if (contentEncoding == ContentEncoding.Gzip.value) { - val isGzipSupported = opcode == Opcode.DATA.value - if (!isGzipSupported) - throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).") - - val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining()) - val outputStream = ByteArrayOutputStream() - GZIPInputStream(compressedStream).use { gzipStream -> - val buffer = ByteArray(8192) // 8KB buffer - var bytesRead: Int - while (gzipStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - } - } - data = ByteBuffer.wrap(outputStream.toByteArray()) - } - when (opcode) { Opcode.PING.value -> { - if (sourceChannel != null) - sourceChannel.send(Opcode.PONG.value) - else - send(Opcode.PONG.value) + send(Opcode.PONG.value) //Logger.i(TAG, "Received ping, sent pong") return } Opcode.PONG.value -> { - if (sourceChannel != null) { - sourceChannel.invokeDataHandler(opcode, subOpcode, data) - } else { - _lastPongTime = System.currentTimeMillis() - } - Logger.v(TAG, "Received pong") + //Logger.i(TAG, "Received pong") return } - Opcode.REQUEST.value -> { - handleRequest(subOpcode, data, sourceChannel) + Opcode.NOTIFY_AUTHORIZED.value, + Opcode.NOTIFY_UNAUTHORIZED.value -> { + _onData.invoke(this, opcode, subOpcode, data) return } - Opcode.RESPONSE.value -> { - handleResponse(subOpcode, data, sourceChannel) - return - } - Opcode.NOTIFY.value -> { - handleNotify(subOpcode, data, sourceChannel) - return - } - Opcode.RELAY.value -> { - handleRelay(subOpcode, data, sourceChannel) - return - } - else -> if (isAuthorized) when (opcode) { - Opcode.STREAM.value -> when (subOpcode) - { - StreamOpcode.START.value -> { - val id = data.int - val expectedSize = data.int - val op = data.get().toUByte() - val subOp = data.get().toUByte() - val ce = data.get().toUByte() + } - val syncStream = SyncStream(expectedSize, op, subOp, ce) - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } + if (authorizable?.isAuthorized != true) { + return + } - synchronized(_syncStreams) { - _syncStreams[id] = syncStream - } - } - StreamOpcode.DATA.value -> { - val id = data.int - val expectedOffset = data.int + when (opcode) { + Opcode.STREAM_START.value -> { + val id = data.int + val expectedSize = data.int + val op = data.get().toUByte() + val subOp = data.get().toUByte() - val syncStream = synchronized(_syncStreams) { - _syncStreams[id] ?: throw Exception("Received data for sync stream that does not exist") - } - - if (expectedOffset != syncStream.bytesReceived) { - throw Exception("Expected offset does not match the amount of received bytes") - } - - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } - } - StreamOpcode.END.value -> { - val id = data.int - val expectedOffset = data.int - - val syncStream = synchronized(_syncStreams) { - _syncStreams.remove(id) ?: throw Exception("Received data for sync stream that does not exist") - } - - if (expectedOffset != syncStream.bytesReceived) { - throw Exception("Expected offset does not match the amount of received bytes") - } - - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } - - if (!syncStream.isComplete) { - throw Exception("After sync stream end, the stream must be complete") - } - - handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel) - } + val syncStream = SyncStream(expectedSize, op, subOp) + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) } - Opcode.DATA.value -> { - if (sourceChannel != null) - sourceChannel.invokeDataHandler(opcode, subOpcode, data) - else - _onData?.invoke(this, opcode, subOpcode, data) - } - else -> { - Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})") + + synchronized(_syncStreams) { + _syncStreams[id] = syncStream } } - } - } + Opcode.STREAM_DATA.value -> { + val id = data.int + val expectedOffset = data.int - suspend fun requestConnectionInfo(publicKey: String): ConnectionInfo? { - val requestId = generateRequestId() - val deferred = CompletableDeferred() - _pendingConnectionInfoRequests[requestId] = deferred - try { - val publicKeyBytes = publicKey.base64ToByteArray() - if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") - val packet = ByteBuffer.allocate(4 + 32).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(publicKeyBytes) - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.CONNECTION_INFO.value, packet) - } catch (e: Exception) { - _pendingConnectionInfoRequests.remove(requestId)?.completeExceptionally(e) - throw e - } - return deferred.await() - } - - suspend fun requestBulkConnectionInfo(publicKeys: Array): Map { - val requestId = generateRequestId() - val deferred = CompletableDeferred>() - _pendingBulkConnectionInfoRequests[requestId] = deferred - try { - val packet = ByteBuffer.allocate(4 + 1 + publicKeys.size * 32).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(publicKeys.size.toByte()) - for (pk in publicKeys) { - val pkBytes = pk.base64ToByteArray() - if (pkBytes.size != 32) throw IllegalArgumentException("Invalid public key length for $pk") - packet.put(pkBytes) - } - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.BULK_CONNECTION_INFO.value, packet) - } catch (e: Exception) { - _pendingBulkConnectionInfoRequests.remove(requestId)?.completeExceptionally(e) - throw e - } - return deferred.await() - } - - suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? { - val requestId = generateRequestId() - val deferred = CompletableDeferred() - val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true) - _onNewChannel?.invoke(this, channel) - _pendingChannels[requestId] = channel to deferred - try { - channel.sendRequestTransport(requestId, publicKey, appId, pairingCode) - } catch (e: Exception) { - _pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) } - throw e - } - return deferred.await() - } - - private fun getDeviceName(): String { - val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase( - Locale.getDefault()) else it.toString() } - val model = Build.MODEL - - return if (model.startsWith(manufacturer, ignoreCase = true)) { - model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } else { - "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } - } - - private fun getLimitedUtf8Bytes(str: String, maxByteLength: Int): ByteArray { - val bytes = str.toByteArray(Charsets.UTF_8) - if (bytes.size <= maxByteLength) return bytes - - var truncateAt = maxByteLength - while (truncateAt > 0 && (bytes[truncateAt].toInt() and 0xC0) == 0x80) { - truncateAt-- - } - return bytes.copyOf(truncateAt) - } - - fun publishConnectionInformation( - authorizedKeys: Array, - port: Int, - allowLocalDirect: Boolean, - allowRemoteDirect: Boolean, - allowRemoteHolePunched: Boolean, - allowRemoteRelayed: Boolean - ) { - if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255") - - val candidateAddresses = findCandidateAddresses() - val ipv4Addresses = candidateAddresses.filterIsInstance() - val ipv6Addresses = candidateAddresses.filterIsInstance() - - val deviceName = getDeviceName() - val nameBytes = getLimitedUtf8Bytes(deviceName, 255) - - val blobSize = 2 + 1 + nameBytes.size + 1 + ipv4Addresses.size * 4 + 1 + ipv6Addresses.size * 16 + 1 + 1 + 1 + 1 - val data = ByteBuffer.allocate(blobSize).order(ByteOrder.LITTLE_ENDIAN) - data.putShort(port.toShort()) - data.put(nameBytes.size.toByte()) - data.put(nameBytes) - data.put(ipv4Addresses.size.toByte()) - for (addr in ipv4Addresses) { - val addrBytes = addr.address - data.put(addrBytes) - } - data.put(ipv6Addresses.size.toByte()) - for (addr in ipv6Addresses) { - val addrBytes = addr.address - data.put(addrBytes) - } - data.put(if (allowLocalDirect) 1 else 0) - data.put(if (allowRemoteDirect) 1 else 0) - data.put(if (allowRemoteHolePunched) 1 else 0) - data.put(if (allowRemoteRelayed) 1 else 0) - - val handshakeSize = 48 // Noise handshake size for N pattern - - data.rewind() - val ciphertextSize = data.remaining() + 16 // Encrypted data size - val totalSize = 1 + authorizedKeys.size * (32 + handshakeSize + 4 + ciphertextSize) - val publishBytes = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) - publishBytes.put(authorizedKeys.size.toByte()) - - for (key in authorizedKeys) { - val publicKeyBytes = key.base64ToByteArray() - if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") - - val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) - protocol.remotePublicKey.setPublicKey(publicKeyBytes, 0) - protocol.start() - - val handshakeMessage = ByteArray(handshakeSize) - val handshakeBytesWritten = protocol.writeMessage(handshakeMessage, 0, null, 0, 0) - if (handshakeBytesWritten != handshakeSize) throw IllegalStateException("Handshake message size mismatch") - - val transportPair = protocol.split() - - publishBytes.put(publicKeyBytes) - publishBytes.put(handshakeMessage) - - val ciphertext = ByteArray(ciphertextSize) - val ciphertextBytesWritten = transportPair.sender.encryptWithAd(null, data.array(), data.position(), ciphertext, 0, data.remaining()) - if (ciphertextBytesWritten != ciphertextSize) throw IllegalStateException("Ciphertext size mismatch") - - publishBytes.putInt(ciphertextBytesWritten) - publishBytes.put(ciphertext, 0, ciphertextBytesWritten) - } - - publishBytes.rewind() - send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes) - } - - suspend fun publishRecords(consumerPublicKeys: List, key: String, data: ByteArray, contentEncoding: ContentEncoding? = null): Boolean { - val keyBytes = key.toByteArray(Charsets.UTF_8) - if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes") - if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required") - val requestId = generateRequestId() - val deferred = CompletableDeferred() - _pendingPublishRequests[requestId] = deferred - try { - val MAX_PLAINTEXT_SIZE = 65535 - val HANDSHAKE_SIZE = 48 - val LENGTH_SIZE = 4 - val TAG_SIZE = 16 - val chunkCount = (data.size + MAX_PLAINTEXT_SIZE - 1) / MAX_PLAINTEXT_SIZE - - var blobSize = HANDSHAKE_SIZE - var dataOffset = 0 - for (i in 0 until chunkCount) { - val chunkSize = minOf(MAX_PLAINTEXT_SIZE, data.size - dataOffset) - blobSize += LENGTH_SIZE + (chunkSize + TAG_SIZE) - dataOffset += chunkSize - } - - val totalPacketSize = 4 + 1 + keyBytes.size + 1 + consumerPublicKeys.size * (32 + 4 + blobSize) - val packet = ByteBuffer.allocate(totalPacketSize).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(keyBytes.size.toByte()) - packet.put(keyBytes) - packet.put(consumerPublicKeys.size.toByte()) - - for (consumer in consumerPublicKeys) { - val consumerBytes = consumer.base64ToByteArray() - if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") - packet.put(consumerBytes) - val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply { - remotePublicKey.setPublicKey(consumerBytes, 0) - start() + val syncStream = synchronized(_syncStreams) { + _syncStreams[id] ?: throw Exception("Received data for sync stream that does not exist") } - val handshakeMessage = ByteArray(HANDSHAKE_SIZE) - protocol.writeMessage(handshakeMessage, 0, null, 0, 0) - val transportPair = protocol.split() - packet.putInt(blobSize) - packet.put(handshakeMessage) - dataOffset = 0 - for (i in 0 until chunkCount) { - val chunkSize = minOf(MAX_PLAINTEXT_SIZE, data.size - dataOffset) - val plaintext = data.copyOfRange(dataOffset, dataOffset + chunkSize) - val ciphertext = ByteArray(chunkSize + TAG_SIZE) - val written = transportPair.sender.encryptWithAd(null, plaintext, 0, ciphertext, 0, plaintext.size) - packet.putInt(written) - packet.put(ciphertext, 0, written) - dataOffset += chunkSize + if (expectedOffset != syncStream.bytesReceived) { + throw Exception("Expected offset does not match the amount of received bytes") + } + + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) } } - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, ce = contentEncoding) - } catch (e: Exception) { - _pendingPublishRequests.remove(requestId)?.completeExceptionally(e) - throw e - } - return deferred.await() - } + Opcode.STREAM_END.value -> { + val id = data.int + val expectedOffset = data.int - suspend fun getRecord(publisherPublicKey: String, key: String): Pair? { - if (key.isEmpty() || key.length > 32) throw IllegalArgumentException("Key must be 1-32 bytes") - val requestId = generateRequestId() - val deferred = CompletableDeferred?>() - _pendingGetRecordRequests[requestId] = deferred - try { - val publisherBytes = publisherPublicKey.base64ToByteArray() - if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") - val keyBytes = key.toByteArray(Charsets.UTF_8) - val packet = ByteBuffer.allocate(4 + 32 + 1 + keyBytes.size).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(publisherBytes) - packet.put(keyBytes.size.toByte()) - packet.put(keyBytes) - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.GET_RECORD.value, packet) - } catch (e: Exception) { - _pendingGetRecordRequests.remove(requestId)?.completeExceptionally(e) - throw e - } - return deferred.await() - } + val syncStream = synchronized(_syncStreams) { + _syncStreams.remove(id) ?: throw Exception("Received data for sync stream that does not exist") + } - suspend fun getRecords(publisherPublicKeys: List, key: String): Map> { - if (key.isEmpty() || key.length > 32) throw IllegalArgumentException("Key must be 1-32 bytes") - if (publisherPublicKeys.isEmpty()) return emptyMap() - val requestId = generateRequestId() - val deferred = CompletableDeferred>>() - _pendingBulkGetRecordRequests[requestId] = deferred - try { - val keyBytes = key.toByteArray(Charsets.UTF_8) - val packet = ByteBuffer.allocate(4 + 1 + keyBytes.size + 1 + publisherPublicKeys.size * 32).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(keyBytes.size.toByte()) - packet.put(keyBytes) - packet.put(publisherPublicKeys.size.toByte()) - for (publisher in publisherPublicKeys) { - val bytes = publisher.base64ToByteArray() - if (bytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") - packet.put(bytes) + if (expectedOffset != syncStream.bytesReceived) { + throw Exception("Expected offset does not match the amount of received bytes") + } + + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + + if (!syncStream.isComplete) { + throw Exception("After sync stream end, the stream must be complete") + } + + handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }) } - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.BULK_GET_RECORD.value, packet) - } catch (e: Exception) { - _pendingBulkGetRecordRequests.remove(requestId)?.completeExceptionally(e) - throw e - } - return deferred.await() - } - - suspend fun deleteRecords(publisherPublicKey: String, consumerPublicKey: String, keys: List): Boolean { - if (keys.any { it.toByteArray(Charsets.UTF_8).size > 32 }) throw IllegalArgumentException("Keys must be at most 32 bytes") - val requestId = generateRequestId() - val deferred = CompletableDeferred() - _pendingDeleteRequests[requestId] = deferred - try { - val publisherBytes = publisherPublicKey.base64ToByteArray() - if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") - val consumerBytes = consumerPublicKey.base64ToByteArray() - if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") - val packetSize = 4 + 32 + 32 + 1 + keys.sumOf { 1 + it.toByteArray(Charsets.UTF_8).size } - val packet = ByteBuffer.allocate(packetSize).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(publisherBytes) - packet.put(consumerBytes) - packet.put(keys.size.toByte()) - for (key in keys) { - val keyBytes = key.toByteArray(Charsets.UTF_8) - packet.put(keyBytes.size.toByte()) - packet.put(keyBytes) + Opcode.DATA.value -> { + _onData.invoke(this, opcode, subOpcode, data) + } + else -> { + Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})") } - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.BULK_DELETE_RECORD.value, packet) - } catch (e: Exception) { - _pendingDeleteRequests.remove(requestId)?.completeExceptionally(e) - throw e } - return deferred.await() - } - - suspend fun listRecordKeys(publisherPublicKey: String, consumerPublicKey: String): List> { - val requestId = generateRequestId() - val deferred = CompletableDeferred>>() - _pendingListKeysRequests[requestId] = deferred - try { - val publisherBytes = publisherPublicKey.base64ToByteArray() - if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") - val consumerBytes = consumerPublicKey.base64ToByteArray() - if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") - val packet = ByteBuffer.allocate(4 + 32 + 32).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(requestId) - packet.put(publisherBytes) - packet.put(consumerBytes) - packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.LIST_RECORD_KEYS.value, packet) - } catch (e: Exception) { - _pendingListKeysRequests.remove(requestId)?.completeExceptionally(e) - throw e - } - return deferred.await() } companion object { - val dh = "25519" - val pattern = "N" - val cipher = "ChaChaPoly" - val hash = "BLAKE2b" - var nProtocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" - private const val TAG = "SyncSocketSession" const val MAXIMUM_PACKET_SIZE = 65535 - 16 const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16 - const val HEADER_SIZE = 7 + const val HEADER_SIZE = 6 } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt index b7ed0626..d558feef 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt @@ -1,6 +1,6 @@ package com.futo.platformplayer.sync.internal -class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte, val contentEncoding: UByte) { +class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) { companion object { const val MAXIMUM_SIZE = 10_000_000 } diff --git a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt index 90da8898..22926841 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -34,18 +34,15 @@ class PlayerManager { @Synchronized fun attach(view: PlayerView, stateName: String) { - if (view != _currentView) { - _currentView?.player = null - _currentView = null - switchState(stateName) - view.player = player - _currentView = view + if(view != _currentView) { + _currentView?.player = null; + switchState(stateName); + view.player = player; + _currentView = view; } } - fun detach() { - _currentView?.player = null - _currentView = null + _currentView?.player = null; } fun getState(name: String): PlayerState { diff --git a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt index c7d68127..77cf431e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt @@ -1,11 +1,7 @@ package com.futo.platformplayer.views import android.content.Context -import android.text.TextWatcher import android.util.AttributeSet -import android.view.View -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -22,9 +18,6 @@ class SearchView : FrameLayout { val buttonClear: ImageButton; var onSearchChanged = Event1(); - var onEnter = Event1(); - - val text: String get() = textSearch.text.toString(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.view_search_bar, this); @@ -32,26 +25,9 @@ class SearchView : FrameLayout { textSearch = findViewById(R.id.edit_search) buttonClear = findViewById(R.id.button_clear_search) - buttonClear.setOnClickListener { - textSearch.text = "" - textSearch?.clearFocus() - (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0) - onSearchChanged.emit("") - onEnter.emit("") - } - textSearch.setOnEditorActionListener { _, i, _ -> - if (i == EditorInfo.IME_ACTION_DONE) { - textSearch?.clearFocus() - (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0) - onEnter.emit(textSearch.text.toString()) - return@setOnEditorActionListener true - } - return@setOnEditorActionListener false - - } + buttonClear.setOnClickListener { textSearch.text = "" }; textSearch.addTextChangedListener { - buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE - onSearchChanged.emit(it.toString()) + onSearchChanged.emit(it.toString()); }; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt deleted file mode 100644 index bf9d3150..00000000 --- a/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt +++ /dev/null @@ -1,381 +0,0 @@ -package com.futo.platformplayer.views - -import android.content.Context -import android.graphics.* -import android.util.AttributeSet -import android.util.TypedValue -import android.view.HapticFeedbackConstants -import android.view.MotionEvent -import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.OvershootInterpolator -import androidx.core.graphics.ColorUtils -import androidx.core.graphics.toColorInt -import kotlin.math.* -import kotlin.random.Random -import com.futo.platformplayer.UIDialogs - -class TargetTapLoaderView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : View(context, attrs) { - private val primaryColor = "#2D63ED".toColorInt() - private val inactiveGlobalAlpha = 110 - private val idleSpeedMultiplier = .015f - private val overshootInterpolator = OvershootInterpolator(1.5f) - private val floatAccel = .03f - private val idleMaxSpeed = .35f - private val idleInitialTargets = 10 - private val idleHintText = "Waiting for media to become available" - - private var expectedDurationMs: Long? = null - private var loadStartTime = 0L - private var playStartTime = 0L - private var loaderFinished = false - private var forceIndeterminate= false - private var lastFrameTime = System.currentTimeMillis() - - private var score = 0 - private var isPlaying = false - - private val targets = mutableListOf() - private val particles = mutableListOf() - - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(0.7f, 1f, 1f, 1f) - textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics) - textAlign = Paint.Align.LEFT - setShadowLayer(4f, 0f, 0f, Color.BLACK) - typeface = Typeface.DEFAULT_BOLD - } - private val idleHintPaint = Paint(textPaint).apply { - textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics) - typeface = Typeface.DEFAULT - setShadowLayer(2f, 0f, 0f, Color.BLACK) - } - private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryColor } - private val spinnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = primaryColor; strokeWidth = 12f - style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND - } - private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(50, 0, 0, 0) } - private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val particlePaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val backgroundPaint = Paint() - private var spinnerShader: SweepGradient? = null - private var spinnerAngle = 0f - private val MIN_SPAWN_RATE = 1f - private val MAX_SPAWN_RATE = 20.0f - private val HIT_RATE_INCREMENT = 0.15f - private val MISS_RATE_DECREMENT = 0.09f - private var spawnRate = MIN_SPAWN_RATE - - private val frameRunnable = object : Runnable { - override fun run() { invalidate(); if (!loaderFinished) postDelayed(this, 16L) } - } - - init { setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_DOWN) handleTap(e.x, e.y); true } } - - fun startLoader(durationMs: Long? = null) { - val alreadyRunning = !loaderFinished - if (alreadyRunning && durationMs == null) { - expectedDurationMs = null - forceIndeterminate = true - return - } - - expectedDurationMs = durationMs?.takeIf { it > 0 } - forceIndeterminate = expectedDurationMs == null - loaderFinished = false - isPlaying = false - score = 0 - particles.clear() - spawnRate = MIN_SPAWN_RATE - - post { if (targets.isEmpty()) prepopulateIdleTargets() } - - loadStartTime = System.currentTimeMillis() - playStartTime = 0 - removeCallbacks(frameRunnable) - post(frameRunnable) - - if (!isIndeterminate) { - postDelayed({ - if (!loaderFinished) { - forceIndeterminate = true - expectedDurationMs = null - } - }, expectedDurationMs!!) - } - } - - fun finishLoader() { - loaderFinished = true - particles.clear() - isPlaying = false - invalidate() - } - - fun stopAndResetLoader() { - if (score > 0) { - val elapsed = (System.currentTimeMillis() - (if (playStartTime > 0) playStartTime else loadStartTime)) / 1000.0 - UIDialogs.toast("Nice! score $score | ${"%.1f".format(score / elapsed)} / s") - score = 0 - } - loaderFinished = true - isPlaying = false - targets.clear() - particles.clear() - removeCallbacks(frameRunnable) - invalidate() - } - - private val isIndeterminate get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L - - private fun handleTap(x: Float, y: Float) { - val idx = targets.indexOfFirst { !it.hit && hypot(x - it.x, y - it.y) <= it.radius } - if (idx >= 0) { - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - val t = targets[idx] - t.hit = true; t.hitTime = System.currentTimeMillis() - accelerateSpawnRate() - score += if (!isIndeterminate) 10 else 5 - spawnParticles(t.x, t.y, t.radius) - - if (!isPlaying) { - isPlaying = true - playStartTime = System.currentTimeMillis() - score = 0 - spawnRate = MIN_SPAWN_RATE - targets.retainAll { it === t } - spawnTarget() - } - } else if (isPlaying) decelerateSpawnRate() - } - - private inline fun accelerateSpawnRate() { - spawnRate = (spawnRate + HIT_RATE_INCREMENT).coerceAtMost(MAX_SPAWN_RATE) - } - - private inline fun decelerateSpawnRate() { - spawnRate = (spawnRate - MISS_RATE_DECREMENT).coerceAtLeast(MIN_SPAWN_RATE) - } - - private fun spawnTarget() { - if (loaderFinished || width == 0 || height == 0) { - postDelayed({ spawnTarget() }, 200L); return - } - - if (!isPlaying) { - postDelayed({ spawnTarget() }, 500L); return - } - - val radius = Random.nextInt(40, 80).toFloat() - val x = Random.nextFloat() * (width - 2 * radius) + radius - val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius - - val baseSpeed = Random.nextFloat() + .1f - val speed = baseSpeed - val angle = Random.nextFloat() * TAU - val vx = cos(angle) * speed - val vy = sin(angle) * speed - val alpha = Random.nextInt(150, 255) - - targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy) - - val delay = (1000f / spawnRate).roundToLong() - postDelayed({ spawnTarget() }, delay) - } - - private fun prepopulateIdleTargets() { - if (width == 0 || height == 0) { - post { prepopulateIdleTargets() } - return - } - repeat(idleInitialTargets) { - val radius = Random.nextInt(40, 80).toFloat() - val x = Random.nextFloat() * (width - 2 * radius) + radius - val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius - val angle = Random.nextFloat() * TAU - val speed = (Random.nextFloat() * .3f + .05f) * idleSpeedMultiplier - val vx = cos(angle) * speed - val vy = sin(angle) * speed - val alpha = Random.nextInt(60, 110) - targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy) - } - } - - private fun spawnParticles(cx: Float, cy: Float, radius: Float) { - repeat(12) { - val angle = Random.nextFloat() * TAU - val speed = Random.nextFloat() * 5f + 2f - val vx = cos(angle) * speed - val vy = sin(angle) * speed - val col = ColorUtils.setAlphaComponent(primaryColor, Random.nextInt(120, 255)) - particles += Particle(cx, cy, vx, vy, System.currentTimeMillis(), col) - } - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - val now = System.currentTimeMillis() - val deltaMs = now - lastFrameTime - lastFrameTime = now - - drawBackground(canvas) - drawTargets(canvas, now) - drawParticles(canvas, now) - - if (!loaderFinished) { - if (isIndeterminate) drawIndeterminateSpinner(canvas, deltaMs) - else drawDeterministicProgressBar(canvas, now) - } - - if (isPlaying) { - val margin = 24f - val scoreTxt = "Score: $score" - val speedTxt = "Speed: ${"%.2f".format(spawnRate)}/s" - val maxWidth = width - margin - val needRight = max(textPaint.measureText(scoreTxt), textPaint.measureText(speedTxt)) > maxWidth - - val alignX = if (needRight) (width - margin) else margin - textPaint.textAlign = if (needRight) Paint.Align.RIGHT else Paint.Align.LEFT - - canvas.drawText(scoreTxt, alignX, textPaint.textSize + margin, textPaint) - canvas.drawText(speedTxt, alignX, 2*textPaint.textSize + margin + 4f, textPaint) - textPaint.textAlign = Paint.Align.LEFT - } - else if (loaderFinished) - canvas.drawText("Loading Complete!", width/2f, height/2f, textPaint.apply { textAlign = Paint.Align.CENTER }) - else { - idleHintPaint.textAlign = Paint.Align.CENTER - canvas.drawText(idleHintText, width / 2f, height - 48f, idleHintPaint) - } - } - - private fun drawBackground(canvas: Canvas) { - val colors = intArrayOf( - Color.rgb(20, 20, 40), - Color.rgb(15, 15, 30), - Color.rgb(10, 10, 20), - Color.rgb( 5, 5, 10), - Color.BLACK - ) - val pos = floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f) - - if (backgroundPaint.shader == null) { - backgroundPaint.shader = LinearGradient( - 0f, 0f, 0f, height.toFloat(), - colors, pos, - Shader.TileMode.CLAMP - ) - } - - canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint) - } - - private fun drawTargets(canvas: Canvas, now: Long) { - val expireMsActive = if (isIndeterminate) 2500L else 1500L - val it = targets.iterator() - while (it.hasNext()) { - val t = it.next() - if (t.hit && now - t.hitTime > 300L) { it.remove(); continue } - if (isPlaying && !t.hit && now - t.spawnTime > expireMsActive) { - it.remove(); decelerateSpawnRate(); continue - } - t.x += t.vx; t.y += t.vy - t.vx += (Random.nextFloat() - .5f) * floatAccel - t.vy += (Random.nextFloat() - .5f) * floatAccel - val speedCap = if (isPlaying) Float.MAX_VALUE else idleMaxSpeed - val mag = hypot(t.vx, t.vy) - if (mag > speedCap) { - val s = speedCap / mag - t.vx *= s; t.vy *= s - } - if (t.x - t.radius < 0 || t.x + t.radius > width) t.vx *= -1 - if (t.y - t.radius < 0 || t.y + t.radius > height) t.vy *= -1 - val scale = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f,1f) - else { - val e = now - t.spawnAnimStart - if (e < 300L) overshootInterpolator.getInterpolation(e/300f) - else 1f + .02f * sin(((now - t.spawnAnimStart)/1000f)*TAU + t.pulseOffset) - } - val animAlpha = if (t.hit) ((1f - scale)*255).toInt() else 255 - val globalAlpha = if (isPlaying) 255 else inactiveGlobalAlpha - val alpha = (animAlpha * t.baseAlpha /255f * globalAlpha/255f).toInt().coerceAtMost(255) - val r = max(1f, t.radius*scale) - val outerCol = ColorUtils.setAlphaComponent(primaryColor, alpha) - val midCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.7f).toInt()) - val innerCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.4f).toInt()) - outerRingPaint.color = outerCol; middleRingPaint.color = midCol; centerDotPaint.color = innerCol - - glowPaint.shader = RadialGradient(t.x, t.y, r, outerCol, Color.TRANSPARENT, Shader.TileMode.CLAMP) - - canvas.drawCircle(t.x, t.y, r*1.2f, glowPaint) - canvas.drawCircle(t.x+4f, t.y+4f, r, shadowPaint) - canvas.drawCircle(t.x, t.y, r, outerRingPaint) - canvas.drawCircle(t.x, t.y, r*.66f, middleRingPaint) - canvas.drawCircle(t.x, t.y, r*.33f, centerDotPaint) - } - } - - private fun drawParticles(canvas: Canvas, now: Long) { - val lifespan = 400L - val it = particles.iterator() - while (it.hasNext()) { - val p = it.next() - val age = now - p.startTime - if (age > lifespan) { it.remove(); continue } - val a = ((1f - age/lifespan.toFloat())*255).toInt() - particlePaint.color = ColorUtils.setAlphaComponent(p.baseColor, a) - p.x += p.vx; p.y += p.vy - canvas.drawCircle(p.x, p.y, 6f, particlePaint) - } - } - - private fun drawDeterministicProgressBar(canvas: Canvas, now: Long) { - val dur = expectedDurationMs ?: return - val prog = ((now - loadStartTime) / dur.toFloat()).coerceIn(0f, 1f) - val eased = AccelerateDecelerateInterpolator().getInterpolation(prog) - val h = 20f; val r=10f - canvas.drawRoundRect(RectF(0f, height-h, width*eased, height.toFloat()), r, r, progressBarPaint) - } - - private fun drawIndeterminateSpinner(canvas: Canvas, dt: Long) { - val cx=width/2f; val cy=height/2f; val r=min(width,height)/6f - spinnerAngle = (spinnerAngle + .25f*dt)%360f - if(spinnerShader == null) spinnerShader = SweepGradient(cx,cy,intArrayOf(Color.TRANSPARENT,Color.WHITE,Color.TRANSPARENT),floatArrayOf(0f,.5f,1f)) - spinnerPaint.shader = spinnerShader - val glow = Paint(spinnerPaint).apply{ maskFilter = BlurMaskFilter(15f,BlurMaskFilter.Blur.SOLID) } - val sweep = 270f - canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,glow) - canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,spinnerPaint) - } - - private data class Target( - var x: Float, - var y: Float, - val radius: Float, - val spawnTime: Long, - var hit: Boolean = false, - var hitTime: Long = 0L, - val baseAlpha: Int = 255, - var vx: Float=0f, - var vy:Float=0f, - val spawnAnimStart: Long = System.currentTimeMillis(), - val pulseOffset: Float = Random.nextFloat() * TAU - ) - private data class Particle( - var x:Float, - var y:Float, - val vx:Float, - val vy:Float, - val startTime:Long, - val baseColor:Int - ) - - private companion object { private const val TAU = (2 * Math.PI).toFloat() } -} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt deleted file mode 100644 index 1d467732..00000000 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.futo.platformplayer.views.adapters - -import android.content.Context -import android.view.View -import android.widget.LinearLayout -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import com.futo.platformplayer.R -import com.futo.platformplayer.api.media.models.IPlatformChannelContent -import com.futo.platformplayer.api.media.models.PlatformAuthorLink -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.toHumanNumber -import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.others.CreatorThumbnail -import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.subscriptions.SubscribeButton - - -open class ChannelView : LinearLayout { - protected val _feedStyle : FeedStyle; - protected val _tiny: Boolean - - private val _textName: TextView; - private val _creatorThumbnail: CreatorThumbnail; - private val _textMetadata: TextView; - private val _buttonSubscribe: SubscribeButton; - private val _platformIndicator: PlatformIndicator; - - val onClick = Event1(); - - var currentChannel: IPlatformChannelContent? = null - private set - - val content: IPlatformContent? get() = currentChannel; - - constructor(context: Context, feedStyle: FeedStyle, tiny: Boolean) : super(context) { - inflate(feedStyle); - _feedStyle = feedStyle; - _tiny = tiny - - _textName = findViewById(R.id.text_channel_name); - _creatorThumbnail = findViewById(R.id.creator_thumbnail); - _textMetadata = findViewById(R.id.text_channel_metadata); - _buttonSubscribe = findViewById(R.id.button_subscribe); - _platformIndicator = findViewById(R.id.platform_indicator); - - //_textName.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } - //_creatorThumbnail.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } - //_textMetadata.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } - - if (_tiny) { - _buttonSubscribe.visibility = View.GONE; - _textMetadata.visibility = View.GONE; - } - - findViewById(R.id.root).setOnClickListener { - val s = currentChannel ?: return@setOnClickListener; - onClick.emit(s); - } - } - - protected open fun inflate(feedStyle: FeedStyle) { - inflate(context, when(feedStyle) { - FeedStyle.PREVIEW -> R.layout.list_creator - else -> R.layout.list_creator - }, this) - } - - open fun bind(content: IPlatformContent) { - isClickable = true; - - if(content !is IPlatformChannelContent) { - currentChannel = null; - return; - } - currentChannel = content; - - _creatorThumbnail.setThumbnail(content.thumbnail, false); - _textName.text = content.name; - - if(content.subscribers == null || (content.subscribers ?: 0) <= 0L) - _textMetadata.visibility = View.GONE; - else { - _textMetadata.text = if((content.subscribers ?: 0) > 0) content.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; - _textMetadata.visibility = View.VISIBLE; - } - _buttonSubscribe.setSubscribeChannel(content.url); - _platformIndicator.setPlatformFromClientID(content.id.pluginId); - } - - companion object { - private val TAG = "ChannelView" - } -} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index c13a2df0..3bd06903 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -9,10 +9,8 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment @@ -40,7 +38,6 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec val onContentUrlClicked = Event2() val onUrlClicked = Event1() val onContentClicked = Event2() - val onShortClicked = Event3, ArrayList>?>() val onChannelClicked = Event1() val onAddToClicked = Event1() val onAddToQueueClicked = Event1() @@ -84,9 +81,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec when (_tabs[position]) { ChannelTab.VIDEOS -> { fragment = ChannelContentsFragment.newInstance().apply { - onContentClicked.subscribe { video, num, _ -> - this@ChannelViewPagerAdapter.onContentClicked.emit(video, num) - } + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) @@ -99,7 +94,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec ChannelTab.SHORTS -> { fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit) + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt index c9558e4c..67e946e0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ContentPreviewViewHolder.kt @@ -10,7 +10,7 @@ abstract class ContentPreviewViewHolder(itemView: View) : ViewHolder(itemView) { abstract fun bind(content: IPlatformContent); - abstract suspend fun preview(details: IPlatformContentDetails?, paused: Boolean); + abstract fun preview(details: IPlatformContentDetails?, paused: Boolean); abstract fun stopPreview(); abstract fun pausePreview(); abstract fun resumePreview(); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt index a2ff2435..711a1675 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt @@ -7,16 +7,16 @@ import com.futo.platformplayer.R import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.constructs.Event1 -data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) - class DeviceAdapter : RecyclerView.Adapter { - private val _devices: List; + private val _devices: ArrayList; + private val _isRememberedDevice: Boolean; - var onPin = Event1(); + var onRemove = Event1(); var onConnect = Event1(); - constructor(devices: List) : super() { + constructor(devices: ArrayList, isRememberedDevice: Boolean) : super() { _devices = devices; + _isRememberedDevice = isRememberedDevice; } override fun getItemCount() = _devices.size; @@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder { val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false); val holder = DeviceViewHolder(view); - holder.onPin.subscribe { d -> onPin.emit(d); }; + holder.setIsRememberedDevice(_isRememberedDevice); + holder.onRemove.subscribe { d -> onRemove.emit(d); }; holder.onConnect.subscribe { d -> onConnect.emit(d); } return holder; } override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) { - val p = _devices[position]; - viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice); + viewHolder.bind(_devices[position]); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 133dd26b..3fa42219 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -2,11 +2,9 @@ package com.futo.platformplayer.views.adapters import android.graphics.drawable.Animatable import android.view.View -import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R import com.futo.platformplayer.casting.AirPlayCastingDevice @@ -16,71 +14,70 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event2 -import androidx.core.view.isVisible -import com.futo.platformplayer.UIDialogs class DeviceViewHolder : ViewHolder { - private val _layoutDevice: FrameLayout; private val _imageDevice: ImageView; private val _textName: TextView; private val _textType: TextView; private val _textNotReady: TextView; + private val _buttonDisconnect: LinearLayout; + private val _buttonConnect: LinearLayout; + private val _buttonRemove: LinearLayout; private val _imageLoader: ImageView; - private val _imageOnline: ImageView; - private val _root: ConstraintLayout; private var _animatableLoader: Animatable? = null; - private var _imagePin: ImageView; + private var _isRememberedDevice: Boolean = false; var device: CastingDevice? = null private set - var onPin = Event1(); + var onRemove = Event1(); val onConnect = Event1(); constructor(view: View) : super(view) { - _root = view.findViewById(R.id.layout_root); - _layoutDevice = view.findViewById(R.id.layout_device); _imageDevice = view.findViewById(R.id.image_device); _textName = view.findViewById(R.id.text_name); _textType = view.findViewById(R.id.text_type); _textNotReady = view.findViewById(R.id.text_not_ready); + _buttonDisconnect = view.findViewById(R.id.button_disconnect); + _buttonConnect = view.findViewById(R.id.button_connect); + _buttonRemove = view.findViewById(R.id.button_remove); _imageLoader = view.findViewById(R.id.image_loader); - _imageOnline = view.findViewById(R.id.image_online); - _imagePin = view.findViewById(R.id.image_pin); val d = _imageLoader.drawable; if (d is Animatable) { _animatableLoader = d; } - val connect = { - device?.let { dev -> - if (dev.isReady) { - StateCasting.instance.activeDevice?.stopCasting() - StateCasting.instance.connectDevice(dev) - onConnect.emit(dev) - } else { - try { - view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } - } catch (e: Throwable) { - //Ignored - } - } - } - } + _buttonDisconnect.setOnClickListener { + StateCasting.instance.activeDevice?.stopCasting(); + updateButton(); + }; - _textName.setOnClickListener { connect() }; - _textType.setOnClickListener { connect() }; - _layoutDevice.setOnClickListener { connect() }; - - _imagePin.setOnClickListener { + _buttonConnect.setOnClickListener { val dev = device ?: return@setOnClickListener; - onPin.emit(dev); + StateCasting.instance.activeDevice?.stopCasting(); + StateCasting.instance.connectDevice(dev); + onConnect.emit(dev); + }; + + _buttonRemove.setOnClickListener { + val dev = device ?: return@setOnClickListener; + onRemove.emit(dev); + }; + + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> + updateButton(); } + + setIsRememberedDevice(false); } - fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { + fun setIsRememberedDevice(isRememberedDevice: Boolean) { + _isRememberedDevice = isRememberedDevice; + _buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE; + } + + fun bind(d: CastingDevice) { if (d is ChromecastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_chromecast); _textType.text = "Chromecast"; @@ -93,47 +90,54 @@ class DeviceViewHolder : ViewHolder { } _textName.text = d.name; - _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE + device = d; + updateButton(); + } + + private fun updateButton() { + val d = device ?: return; if (!d.isReady) { + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.GONE; _imageLoader.visibility = View.GONE; _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.GONE; - } else { - _textNotReady.visibility = View.GONE; + return; + } - val dev = StateCasting.instance.activeDevice; - if (dev == d) { - if (dev.connectionState == CastConnectionState.CONNECTED) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { - _imageLoader.visibility = View.VISIBLE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.VISIBLE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; } else { - if (d.isReady) { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - _imagePin.visibility = View.VISIBLE; - } else { - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; - _imagePin.visibility = View.VISIBLE; - } + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.VISIBLE; + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; } - - _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) - - if (_imageLoader.isVisible) { - _animatableLoader?.start(); + } else { + if (d.isReady) { + _buttonConnect.visibility = View.VISIBLE; + _buttonDisconnect.visibility = View.GONE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; } else { - _animatableLoader?.stop(); + _buttonConnect.visibility = View.GONE; + _buttonDisconnect.visibility = View.GONE; + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; } } - device = d; + if (_imageLoader.visibility == View.VISIBLE) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt index 6830036a..05b33db2 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/EmptyPreviewViewHolder.kt @@ -11,7 +11,7 @@ class EmptyPreviewViewHolder(viewGroup: ViewGroup) : ContentPreviewViewHolder(Vi override fun bind(content: IPlatformContent) {} - override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {} + override fun preview(details: IPlatformContentDetails?, paused: Boolean) {} override fun stopPreview() {} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt index a31dd14b..2f018c9d 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt @@ -14,11 +14,9 @@ import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.models.HistoryVideo -import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.others.ProgressBar -import com.futo.platformplayer.views.platform.PlatformIndicator class HistoryListViewHolder : ViewHolder { private val _root: ConstraintLayout; @@ -32,7 +30,6 @@ class HistoryListViewHolder : ViewHolder { private val _imageRemove: ImageButton; private val _textHeader: TextView; private val _timeBar: ProgressBar; - private val _thumbnailPlatform: PlatformIndicator var video: HistoryVideo? = null private set; @@ -43,13 +40,13 @@ class HistoryListViewHolder : ViewHolder { constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false)) { _root = itemView.findViewById(R.id.root); _imageThumbnail = itemView.findViewById(R.id.image_video_thumbnail); + _imageThumbnail.clipToOutline = true; _textName = itemView.findViewById(R.id.text_video_name); _textAuthor = itemView.findViewById(R.id.text_author); _textMetadata = itemView.findViewById(R.id.text_video_metadata); _textVideoDuration = itemView.findViewById(R.id.thumbnail_duration); _containerDuration = itemView.findViewById(R.id.thumbnail_duration_container); _containerLive = itemView.findViewById(R.id.thumbnail_live_container); - _thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform) _imageRemove = itemView.findViewById(R.id.image_trash); _textHeader = itemView.findViewById(R.id.text_header); _timeBar = itemView.findViewById(R.id.time_bar); @@ -76,9 +73,6 @@ class HistoryListViewHolder : ViewHolder { _textAuthor.text = v.video.author.name; _textVideoDuration.text = v.video.duration.toHumanTime(false); - val pluginId = v.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(v.video.url)?.id - _thumbnailPlatform.setPlatformFromClientID(pluginId) - if(v.video.isLive) { _containerDuration.visibility = View.GONE; _containerLive.visibility = View.VISIBLE; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt index 3d0bec35..4b34d353 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ItemMoveCallback.kt @@ -10,11 +10,10 @@ class ItemMoveCallback : ItemTouchHelper.Callback { var onRowMoved = Event2(); var onRowSelected = Event1(); var onRowClear = Event1(); - var canEdit = true constructor() : super() { } - override fun isLongPressDragEnabled(): Boolean { return canEdit; } + override fun isLongPressDragEnabled(): Boolean { return true; } override fun isItemViewSwipeEnabled(): Boolean { return false; } override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt index 2f70996f..c9cb8b73 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -126,7 +126,7 @@ open class PlaylistView : LinearLayout { } else { currentPlaylist = null; - _imageThumbnail.setImageDrawable(null); + _imageThumbnail.setImageResource(0); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt index fb28c2e4..ef3f7cb0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -31,17 +31,15 @@ class SubscriptionAdapter : RecyclerView.Adapter { updateDataset(); } - constructor(inflater: LayoutInflater, confirmationMessage: String, sortByDefault: Int, onDatasetChanged: ((List)->Unit)? = null) : super() { + constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List)->Unit)? = null) : super() { _inflater = inflater; _confirmationMessage = confirmationMessage; _onDatasetChanged = onDatasetChanged; - sortBy = sortByDefault StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() } + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } else - updateDataset(); - } + updateDataset(); } updateDataset(); } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt index 6f3f870a..f7c313f5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt @@ -1,7 +1,6 @@ package com.futo.platformplayer.views.adapters import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 469ce702..77df0665 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -17,11 +17,9 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.states.StateDownloads -import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime -import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.platform.PlatformIndicator class VideoListEditorViewHolder : ViewHolder { @@ -38,7 +36,6 @@ class VideoListEditorViewHolder : ViewHolder { private val _imageDragDrop: ImageButton; private val _platformIndicator: PlatformIndicator; private val _layoutDownloaded: FrameLayout; - private val _timeBar: ProgressBar var video: IPlatformVideo? = null private set; @@ -51,6 +48,7 @@ class VideoListEditorViewHolder : ViewHolder { constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { _root = view.findViewById(R.id.root); _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); + _imageThumbnail?.clipToOutline = true; _textName = view.findViewById(R.id.text_video_name); _textAuthor = view.findViewById(R.id.text_author); _textMetadata = view.findViewById(R.id.text_video_metadata); @@ -61,7 +59,6 @@ class VideoListEditorViewHolder : ViewHolder { _imageOptions = view.findViewById(R.id.image_settings); _imageDragDrop = view.findViewById(R.id.image_drag_drop); _platformIndicator = view.findViewById(R.id.thumbnail_platform); - _timeBar = view.findViewById(R.id.time_bar); _layoutDownloaded = view.findViewById(R.id.layout_downloaded); _imageDragDrop.setOnTouchListener { _, event -> @@ -94,16 +91,7 @@ class VideoListEditorViewHolder : ViewHolder { .into(_imageThumbnail); _textName.text = v.name; _textAuthor.text = v.author.name; - - if(v.duration > 0) { - _textVideoDuration.text = v.duration.toHumanTime(false); - _textVideoDuration.visibility = View.VISIBLE; - } - else - _textVideoDuration.visibility = View.GONE; - - val historyPosition = StateHistory.instance.getHistoryPosition(v.url) - _timeBar.progress = historyPosition.toFloat() / v.duration.toFloat(); + _textVideoDuration.text = v.duration.toHumanTime(false); if(v.isLive) { _containerDuration.visibility = View.GONE; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt index e2ba8e5e..001779f1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListHorizontalViewHolder.kt @@ -29,6 +29,7 @@ class VideoListHorizontalViewHolder : ViewHolder { constructor(view: View) : super(view) { _root = view.findViewById(R.id.root); _imageThumbnail = view.findViewById(R.id.image_video_thumbnail); + _imageThumbnail?.clipToOutline = true; _textName = view.findViewById(R.id.text_video_name); _textAuthor = view.findViewById(R.id.text_author); _textVideoDuration = view.findViewById(R.id.thumbnail_duration); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt deleted file mode 100644 index ad09e0e9..00000000 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.futo.platformplayer.views.adapters.feedtypes - -import android.view.ViewGroup -import com.futo.platformplayer.api.media.models.IPlatformChannelContent -import com.futo.platformplayer.api.media.models.PlatformAuthorLink -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails -import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.fragment.mainactivity.main.CreatorFeedView -import com.futo.platformplayer.views.FeedStyle -import com.futo.platformplayer.views.adapters.ChannelView -import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder -import com.futo.platformplayer.views.adapters.PlaylistView - - -class PreviewChannelViewHolder : ContentPreviewViewHolder { - val onClick = Event1(); - - val currentChannel: IPlatformChannelContent? get() = view.currentChannel; - - override val content: IPlatformContent? get() = currentChannel; - - private val view: ChannelView get() = itemView as ChannelView; - - constructor(viewGroup: ViewGroup, feedStyle: FeedStyle, tiny: Boolean): super(ChannelView(viewGroup.context, feedStyle, tiny)) { - view.onClick.subscribe(onClick::emit); - } - - override fun bind(content: IPlatformContent) = view.bind(content); - - override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit; - override fun stopPreview() = Unit; - override fun pausePreview() = Unit; - override fun resumePreview() = Unit; - - companion object { - private val TAG = "PreviewChannelViewHolder" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt index 20327904..225cf6d7 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt @@ -4,7 +4,6 @@ import android.content.Context import android.util.Log import android.view.View import android.view.ViewGroup -import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent @@ -16,8 +15,6 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.debug.Stopwatch -import com.futo.platformplayer.fragment.mainactivity.main.ShortView -import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform @@ -26,10 +23,6 @@ import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import okhttp3.internal.platform.Platform class PreviewContentListAdapter : InsertedViewAdapterWithLoader { private var _initialPlay = true; @@ -39,7 +32,6 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onContentUrlClicked = Event2(); @@ -50,9 +42,15 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader(); val onLongPress = Event1(); - private var _taskLoadContent: TaskHandler, Pair> + private var _taskLoadContent = TaskHandler, Pair>( + StateApp.instance.scopeGetter, { (viewHolder, video) -> + val stopwatch = Stopwatch() + val contentDetails = StatePlatform.instance.getContentDetails(video.url).await(); + stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)") + return@TaskHandler Pair(viewHolder, contentDetails) + }).exception { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { previewContentDetails(it.first, it.second) } - constructor(scope: CoroutineScope, context: Context, feedStyle : FeedStyle, dataSet: ArrayList, exoPlayer: PlayerManager? = null, + constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList, exoPlayer: PlayerManager? = null, initialPlay: Boolean = false, viewsToPrepend: ArrayList = arrayListOf(), viewsToAppend: ArrayList = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) { @@ -61,24 +59,6 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader, Pair>( - { scope }, { (viewHolder, video) -> - val stopwatch = Stopwatch() - val contentDetails = StatePlatform.instance.getContentDetails(video.url).await(); - stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)") - return@TaskHandler Pair(viewHolder, contentDetails) - }).exception { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { - - _scope.launch(Dispatchers.Main) { - try { - previewContentDetails(it.first, it.second) - } catch (e: Throwable) { - Logger.e(TAG, "bindChild preview failed", e) - } - } - } } override fun getChildCount(): Int = _dataSet.size; @@ -98,13 +78,10 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader createPlaceholderViewHolder(viewGroup); ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup); - ContentType.ARTICLE -> createPostViewHolder(viewGroup); - ContentType.WEB -> createPostViewHolder(viewGroup); ContentType.POST -> createPostViewHolder(viewGroup); ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup); ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup); ContentType.LOCKED -> createLockedViewHolder(viewGroup); - ContentType.CHANNEL -> createChannelViewHolder(viewGroup) else -> EmptyPreviewViewHolder(viewGroup) } } @@ -138,10 +115,6 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader 0) { - _textVideoDuration.text = video.duration.toHumanTime(false); - _textVideoDuration.visibility = View.VISIBLE; - } - else - _textVideoDuration.visibility = View.GONE; - } + if(!isPlanned) + _textVideoDuration.text = video.duration.toHumanTime(false); else _textVideoDuration.text = context.getString(R.string.planned); @@ -239,7 +233,7 @@ open class PreviewVideoView : LinearLayout { } else { currentVideo = null; - _imageVideo.setImageDrawable(null); + _imageVideo.setImageResource(0); _containerDuration.visibility = GONE; _containerLive.visibility = GONE; _timeBar?.visibility = GONE; @@ -248,7 +242,7 @@ open class PreviewVideoView : LinearLayout { _textVideoMetadata.text = metadata + timeMeta; } - open suspend fun preview(video: IPlatformContentDetails?, paused: Boolean) { + open fun preview(video: IPlatformContentDetails?, paused: Boolean) { if(video == null) return; Logger.i(TAG, "Previewing"); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt index 44331bc2..9d61b6c2 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoViewHolder.kt @@ -42,7 +42,7 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder { override fun bind(content: IPlatformContent) = view.bind(content); - override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = view.preview(details, paused); + override fun preview(details: IPlatformContentDetails?, paused: Boolean) = view.preview(details, paused); override fun stopPreview() = view.stopPreview(); override fun pausePreview() = view.pausePreview(); override fun resumePreview() = view.resumePreview(); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupBarViewHolder.kt index ca6e6906..dac711c9 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupBarViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupBarViewHolder.kt @@ -47,7 +47,7 @@ class SubscriptionGroupBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda if(img != null) { img.setImageView(_image) } else { - _image.setImageDrawable(null); + _image.setImageResource(0); if(value is SubscriptionGroup.Add) _image.setBackgroundColor(Color.DKGRAY); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt index 6496fa83..4f601d26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt @@ -88,7 +88,7 @@ class SubscriptionGroupListViewHolder(private val _viewGroup: ViewGroup) : AnyAd if(img != null) img.setImageView(_image) else { - _image.setImageDrawable(null); + _image.setImageResource(0); if(value is SubscriptionGroup.Add) _image.setBackgroundColor(Color.DKGRAY); diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt index 10a88341..7e3c90c9 100644 --- a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt @@ -39,9 +39,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale class GestureControlView : LinearLayout { @@ -82,9 +79,6 @@ class GestureControlView : LinearLayout { private var _adjustingFullscreenDown: Boolean = false; private var _fullScreenFactorUp = 1.0f; private var _fullScreenFactorDown = 1.0f; - private val _layoutHoldSpeed: LinearLayout - private val _textHoldFastForward: TextView - private val _imageHoldFastForward: ImageView private var _scaleGestureDetector: ScaleGestureDetector private var _scaleFactor = 1.0f @@ -98,11 +92,6 @@ class GestureControlView : LinearLayout { private var _surfaceView: View? = null private var _layoutIndicatorFill: FrameLayout; private var _layoutIndicatorFit: FrameLayout; - private var _speedHolding = false - - private val _speedFormatter = DecimalFormat("#.##", DecimalFormatSymbols(Locale.US)).apply { - roundingMode = java.math.RoundingMode.HALF_UP - } private val _gestureController: GestureDetectorCompat; @@ -114,8 +103,6 @@ class GestureControlView : LinearLayout { val onZoom = Event1(); val onSoundAdjusted = Event1(); val onToggleFullscreen = Event0(); - val onSpeedHoldStart = Event0() - val onSpeedHoldEnd = Event0() var fullScreenGestureEnabled = true @@ -137,9 +124,6 @@ class GestureControlView : LinearLayout { _layoutControlsFullscreen = findViewById(R.id.layout_controls_fullscreen); _layoutIndicatorFill = findViewById(R.id.layout_indicator_fill); _layoutIndicatorFit = findViewById(R.id.layout_indicator_fit); - _layoutHoldSpeed = findViewById(R.id.layout_controls_increased_speed) - _textHoldFastForward = findViewById(R.id.text_holdFastForward) - _imageHoldFastForward = findViewById(R.id.image_holdFastForward) _scaleGestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScale(detector: ScaleGestureDetector): Boolean { @@ -232,21 +216,7 @@ class GestureControlView : LinearLayout { return true; } - override fun onLongPress(p0: MotionEvent) { - if (!_isControlsLocked - && !_skipping - && !_adjustingBrightness - && !_adjustingSound - && !_adjustingFullscreenUp - && !_adjustingFullscreenDown - && !_isPanning - && !_isZooming - && Settings.instance.playback.getHoldPlaybackSpeed() > 1.0) { - _speedHolding = true - showHoldSpeedControls() - onSpeedHoldStart.emit() - } - } + override fun onLongPress(p0: MotionEvent) = Unit }); _gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener { @@ -331,17 +301,6 @@ class GestureControlView : LinearLayout { onPan.emit(_translationX, _translationY) } - private fun showHoldSpeedControls() { - _layoutHoldSpeed.visibility = View.VISIBLE - _textHoldFastForward.text = _speedFormatter.format(Settings.instance.playback.getHoldPlaybackSpeed()) + "x" - (_imageHoldFastForward.drawable as? Animatable)?.start() - } - - private fun hideHoldSpeedControls() { - _layoutHoldSpeed.visibility = View.GONE - (_imageHoldFastForward.drawable as? Animatable)?.stop() - } - fun setupTouchArea(layoutControls: ViewGroup? = null, background: View? = null) { _layoutControls = layoutControls; _background = background; @@ -350,12 +309,6 @@ class GestureControlView : LinearLayout { override fun onTouchEvent(event: MotionEvent?): Boolean { val ev = event ?: return super.onTouchEvent(event); - if (ev.action == MotionEvent.ACTION_UP && _speedHolding) { - _speedHolding = false - hideHoldSpeedControls() - onSpeedHoldEnd.emit() - } - cancelHideJob(); if (_skipping) { @@ -675,12 +628,12 @@ class GestureControlView : LinearLayout { private fun fastForwardTick() { _fastForwardCounter++; - val seekOffset: Long = Settings.instance.playback.getSeekOffset(); + val seekOffset: Long = 10000; if (_rewinding) { - _textRewind.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds); + _textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); onSeek.emit(-seekOffset); } else { - _textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds); + _textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); onSeek.emit(seekOffset); } } @@ -782,43 +735,24 @@ class GestureControlView : LinearLayout { _animatorBrightness?.start(); } - fun saveBrightness() { - try { - _originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE) - - val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) - _brightnessFactor = brightness / 255.0f; - Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode") - - _originalBrightnessFactor = _brightnessFactor - } catch (e: Throwable) { - Settings.instance.gestureControls.useSystemBrightness = false - Settings.instance.save() - UIDialogs.toast(context, "useSystemBrightness disabled due to an error") - } - } - fun restoreBrightness() { - if (Settings.instance.gestureControls.restoreSystemBrightness) { - onBrightnessAdjusted.emit(_originalBrightnessFactor) - - if (android.provider.Settings.System.canWrite(context)) { - Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode") - - android.provider.Settings.System.putInt( - context.contentResolver, - android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, - _originalBrightnessMode - ) - } - } - } - fun setFullscreen(isFullScreen: Boolean) { resetZoomPan() if (isFullScreen) { if (Settings.instance.gestureControls.useSystemBrightness) { - saveBrightness() + try { + _originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE) + + val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) + _brightnessFactor = brightness / 255.0f; + Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode") + + _originalBrightnessFactor = _brightnessFactor + } catch (e: Throwable) { + Settings.instance.gestureControls.useSystemBrightness = false + Settings.instance.save() + UIDialogs.toast(context, "useSystemBrightness disabled due to an error") + } } if (Settings.instance.gestureControls.useSystemVolume) { @@ -832,7 +766,19 @@ class GestureControlView : LinearLayout { onSoundAdjusted.emit(_soundFactor); } else { if (Settings.instance.gestureControls.useSystemBrightness) { - restoreBrightness() + if (Settings.instance.gestureControls.restoreSystemBrightness) { + onBrightnessAdjusted.emit(_originalBrightnessFactor) + + if (android.provider.Settings.System.canWrite(context)) { + Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode") + + android.provider.Settings.System.putInt( + context.contentResolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, + _originalBrightnessMode + ) + } + } } else { onBrightnessAdjusted.emit(1.0f); } @@ -857,4 +803,4 @@ class GestureControlView : LinearLayout { const val EXIT_DURATION_FAST_FORWARD: Long = 600; const val TAG = "GestureControlView"; } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt index 2d1aa511..cf599176 100644 --- a/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/NonScrollingTextView.kt @@ -8,16 +8,12 @@ import android.text.Spannable import android.text.style.URLSpan import android.util.AttributeSet import android.view.MotionEvent -import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.others.PlatformLinkMovementMethod import com.futo.platformplayer.receivers.MediaControlReceiver -import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.timestampRegex -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { private var _lastTouchedLinks: Array? = null @@ -81,14 +77,12 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { val dx = event.x - downX val dy = event.y - downY if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop && isTouchInside(event)) { - for (link in _lastTouchedLinks!!) { - Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." } - val c = context - if (c is MainActivity) { - c.lifecycleScope.launch(Dispatchers.IO) { - if (c.handleUrl(link.url)) { - return@launch - } + runBlocking { + for (link in _lastTouchedLinks!!) { + Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." } + val c = context + if (c is MainActivity) { + if (c.handleUrl(link.url)) continue if (timestampRegex.matches(link.url)) { val tokens = link.url.split(':') var time_s = -1L @@ -98,30 +92,14 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { tokens[1].toLong() * 60 + tokens[2].toLong() } - if (time_s != -1L) { - withContext(Dispatchers.Main) { - MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) - } - return@launch + MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) + continue } } - - withContext(Dispatchers.Main) { - try { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to start activity.", e) - } - } - } - } else { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - try { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to start activity.", e) - } + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + } else { + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) } } } diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 161f3dc3..91631f1e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -18,19 +18,14 @@ import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.TimeBar import com.bumptech.glide.Glide import com.futo.platformplayer.R -import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.casting.AirPlayCastingDevice -import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 -import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.formatDuration -import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlayer -import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -55,21 +50,17 @@ class CastView : ConstraintLayout { private val _timeBar: DefaultTimeBar; private val _background: FrameLayout; private val _gestureControlView: GestureControlView; - private val _loaderGame: TargetTapLoaderView private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main); private var _updateTimeJob: Job? = null; private var _inPictureInPicture: Boolean = false; private var _chapters: List? = null; private var _currentChapter: IChapter? = null; - private var _speedHoldPrevRate = 1.0 - private var _speedHoldWasPlaying = false val onChapterChanged = Event2(); val onMinimizeClick = Event0(); val onSettingsClick = Event0(); val onPrevious = Event0(); val onNext = Event0(); - val onTimeJobTimeChanged_s = Event1() @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { @@ -90,25 +81,8 @@ class CastView : ConstraintLayout { _timeBar = findViewById(R.id.time_progress); _background = findViewById(R.id.layout_background); _gestureControlView = findViewById(R.id.gesture_control); - _loaderGame = findViewById(R.id.loader_overlay) - _loaderGame.visibility = View.GONE - _gestureControlView.fullScreenGestureEnabled = false _gestureControlView.setupTouchArea(); - _gestureControlView.onSpeedHoldStart.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - _speedHoldWasPlaying = d.isPlaying - _speedHoldPrevRate = d.speed - if (d.canSetSpeed) - d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) - d.resumeVideo() - } - _gestureControlView.onSpeedHoldEnd.subscribe { - val d = StateCasting.instance.activeDevice ?: return@subscribe; - if (!_speedHoldWasPlaying) d.pauseVideo() - d.changeSpeed(_speedHoldPrevRate) - } - _gestureControlView.onSeek.subscribe { val d = StateCasting.instance.activeDevice ?: return@subscribe; StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); @@ -202,12 +176,6 @@ class CastView : ConstraintLayout { _updateTimeJob = null; } - fun cancel() { - stopTimeJob() - setLoading(false) - visibility = View.GONE - } - fun stopAllGestures() { _gestureControlView.stopAllGestures(); } @@ -217,11 +185,11 @@ class CastView : ConstraintLayout { } fun setIsPlaying(isPlaying: Boolean) { - stopTimeJob() + _updateTimeJob?.cancel(); if(isPlaying) { val d = StateCasting.instance.activeDevice; - if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) { + if (d is AirPlayCastingDevice) { _updateTimeJob = _scope.launch { while (true) { val device = StateCasting.instance.activeDevice; @@ -230,9 +198,7 @@ class CastView : ConstraintLayout { } delay(1000); - val time_ms = (device.expectedCurrentTime * 1000.0).toLong() - setTime(time_ms); - onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong()) + setTime((device.expectedCurrentTime * 1000.0).toLong()); } } } @@ -290,7 +256,6 @@ class CastView : ConstraintLayout { _textDuration.text = (video.duration * 1000).formatDuration(); _timeBar.setPosition(position); _timeBar.setDuration(video.duration); - setLoading(false) } @OptIn(UnstableApi::class) @@ -307,7 +272,6 @@ class CastView : ConstraintLayout { _updateTimeJob?.cancel(); _updateTimeJob = null; _scope.cancel(); - setLoading(false) } private fun getPlaybackStateCompat(): Int { @@ -318,19 +282,4 @@ class CastView : ConstraintLayout { else -> PlaybackStateCompat.STATE_PAUSED; } } - - fun setLoading(isLoading: Boolean) { - if (isLoading) { - _loaderGame.visibility = View.VISIBLE - _loaderGame.startLoader() - } else { - _loaderGame.visibility = View.GONE - _loaderGame.stopAndResetLoader() - } - } - - fun setLoading(expectedDurationMs: Int) { - _loaderGame.visibility = View.VISIBLE - _loaderGame.startLoader(expectedDurationMs.toLong()) - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt index a7e142f8..03af1ee8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt @@ -41,8 +41,6 @@ class ButtonField : BigButton, IField { return null; }; - override var isAdvanced: Boolean = false; - //private val _title : TextView; //private val _subtitle : TextView; @@ -91,7 +89,7 @@ class ButtonField : BigButton, IField { return this; } - override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ButtonField { + override fun fromField(obj : Any, field : Field, formField: FormField?) : ButtonField { throw IllegalStateException("ButtonField should only be used for methods"); } override fun setField() { diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt index 4f01d4d5..590335d3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/DropdownField.kt @@ -40,8 +40,6 @@ class DropdownField : TableRow, IField { override var reference: Any? = null; - override var isAdvanced: Boolean = false; - override val onChanged = Event3(); override val value: Any? get() = _selected; @@ -114,7 +112,7 @@ class DropdownField : TableRow, IField { return this; } - override fun fromField(obj: Any, field: Field, formField: FormField?, advanced: Boolean) : DropdownField { + override fun fromField(obj: Any, field: Field, formField: FormField?) : DropdownField { this._field = field; this._obj = obj; @@ -135,9 +133,6 @@ class DropdownField : TableRow, IField { _description.visibility = View.GONE; } - val advancedFieldAttr = field.getAnnotation(AdvancedField::class.java) - if(advancedFieldAttr != null || advanced) - isAdvanced = true; _options = (field.getAnnotation(DropdownFieldOptions::class.java)?.options ?: field.getAnnotation(DropdownFieldOptionsId::class.java)?.optionsId?.let { resources.getStringArray(it) } ?: diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt index b11ce641..9439d46a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/Field.kt @@ -4,10 +4,6 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event3 import java.lang.reflect.Field -@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) -@Retention(AnnotationRetention.RUNTIME) -annotation class AdvancedField(); - @Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.RUNTIME) @@ -26,8 +22,6 @@ interface IField { val obj : Any?; val field : Field?; - val isAdvanced: Boolean; - val value: Any?; val onChanged : Event3; @@ -35,7 +29,7 @@ interface IField { val searchContent: String?; - fun fromField(obj : Any, field : Field, formField: FormField? = null, advanced: Boolean = false) : IField; + fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField; fun setField(); fun setValue(value: Any); diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt b/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt index 566a5024..1262e345 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/FieldForm.kt @@ -37,8 +37,6 @@ class FieldForm : LinearLayout { private var _fields : List = arrayListOf(); - private var _showAdvancedSettings: Boolean = false; - constructor(context : Context, attrs : AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.field_form, this); _containerSearch = findViewById(R.id.container_search); @@ -60,17 +58,11 @@ class FieldForm : LinearLayout { if(field is GroupField) { updateSettingsVisibility(field); } else if(field is View && field.descriptor != null) { - if(field.isAdvanced && !_showAdvancedSettings) - { - field.visibility = View.GONE; - } - else { - val txt = field.searchContent?.lowercase(); - if (txt != null) { - val visible = isGroupMatch || txt.contains(query); - field.visibility = if (visible) View.VISIBLE else View.GONE; - groupVisible = groupVisible || visible; - } + val txt = field.searchContent?.lowercase(); + if(txt != null) { + val visible = isGroupMatch || txt.contains(query); + field.visibility = if (visible) View.VISIBLE else View.GONE; + groupVisible = groupVisible || visible; } } } @@ -79,10 +71,6 @@ class FieldForm : LinearLayout { } } - fun setShowAdvancedSettings(show: Boolean) { - _showAdvancedSettings = show; - updateSettingsVisibility(); - } fun setSearchQuery(query: String) { _editSearch.setText(query); updateSettingsVisibility(); @@ -104,22 +92,13 @@ class FieldForm : LinearLayout { throw java.lang.IllegalStateException("Only views can be IFields"); } - if(field is ToggleField && field.descriptor?.id == "advancedSettings") { - _showAdvancedSettings = field.value as Boolean; - } - _fieldsContainer.addView(field as View); field.onChanged.subscribe { a1, a2, _ -> - if(field is ToggleField && field.descriptor?.id == "advancedSettings") { - setShowAdvancedSettings((a2 as Boolean)); - } - onChanged.emit(a1, a2); }; } _fields = newFields; - updateSettingsVisibility(); onLoaded?.invoke(); } } @@ -288,12 +267,10 @@ class FieldForm : LinearLayout { for(prop in objFields) { prop.first.javaField!!.isAccessible = true; - val advanced = prop.first.hasAnnotation(); - val field = when(prop.second.type) { GROUP -> GroupField(context).fromField(obj, prop.first.javaField!!, prop.second); - DROPDOWN -> DropdownField(context).fromField(obj, prop.first.javaField!!, prop.second, advanced); - TOGGLE -> ToggleField(context).fromField(obj, prop.first.javaField!!, prop.second, advanced); + DROPDOWN -> DropdownField(context).fromField(obj, prop.first.javaField!!, prop.second); + TOGGLE -> ToggleField(context).fromField(obj, prop.first.javaField!!, prop.second); READONLYTEXT -> ReadOnlyTextField(context).fromField(obj, prop.first.javaField!!, prop.second); else -> throw java.lang.IllegalStateException("Unknown field type ${prop.second.type} for ${prop.second.title}") } diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt index 9621285a..133fb788 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/GroupField.kt @@ -34,7 +34,6 @@ class GroupField : LinearLayout, IField { private val _container : LinearLayout; override var reference: Any? = null; - override var isAdvanced: Boolean = false; override val value: Any? = null; @@ -101,7 +100,7 @@ class GroupField : LinearLayout, IField { return this; } - override fun fromField(obj: Any, field: Field, formField: FormField?, advanced: Boolean) : GroupField { + override fun fromField(obj: Any, field: Field, formField: FormField?) : GroupField { this._field = field; this._obj = obj; diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt index 3fb78aeb..d0cfb7dc 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ReadOnlyTextField.kt @@ -31,7 +31,6 @@ class ReadOnlyTextField : TableRow, IField { override val onChanged = Event3(); override var reference: Any? = null; - override var isAdvanced: Boolean = false; override val value: Any? = null; @@ -46,7 +45,7 @@ class ReadOnlyTextField : TableRow, IField { override fun setValue(value: Any) {} - override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ReadOnlyTextField { + override fun fromField(obj : Any, field : Field, formField: FormField?) : ReadOnlyTextField { this._field = field; this._obj = obj; diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt index 1421f2c8..8e1cfbbb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt @@ -33,7 +33,6 @@ class ToggleField : TableRow, IField { private var _lastValue: Boolean = false; override var reference: Any? = null; - override var isAdvanced: Boolean = false; override val onChanged = Event3(); @@ -76,7 +75,7 @@ class ToggleField : TableRow, IField { return this; } - override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ToggleField { + override fun fromField(obj : Any, field : Field, formField: FormField?) : ToggleField { this._field = field; this._obj = obj; @@ -88,11 +87,6 @@ class ToggleField : TableRow, IField { else _title.text = field.name; - val advancedFieldAttr = field.getAnnotation(AdvancedField::class.java) - if(advancedFieldAttr != null || advanced) { - isAdvanced = true; - } - if(attrField == null || attrField.subtitle == -1) _description.visibility = View.GONE; else { diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 67be4058..08d32ac3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -26,7 +26,6 @@ class VideoListEditorView : FrameLayout { val onVideoOptions = Event1(); val onVideoClicked = Event1(); val isEmpty get() = _videos.isEmpty(); - val itemMoveCallback: ItemMoveCallback constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { val recyclerPlaylist = RecyclerView(context, attrs); @@ -35,14 +34,14 @@ class VideoListEditorView : FrameLayout { recyclerPlaylist.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); addView(recyclerPlaylist); - itemMoveCallback = ItemMoveCallback(); - val touchHelper = ItemTouchHelper(itemMoveCallback); + val callback = ItemMoveCallback(); + val touchHelper = ItemTouchHelper(callback); val adapterVideos = VideoListEditorAdapter(touchHelper); recyclerPlaylist.adapter = adapterVideos; recyclerPlaylist.layoutManager = LinearLayoutManager(context); touchHelper.attachToRecyclerView(recyclerPlaylist); - itemMoveCallback.onRowMoved.subscribe { fromPosition, toPosition -> + callback.onRowMoved.subscribe { fromPosition, toPosition -> synchronized(_videos) { if (fromPosition < toPosition) { for (i in fromPosition until toPosition) @@ -95,7 +94,6 @@ class VideoListEditorView : FrameLayout { synchronized(_videos) { _videos.clear(); _videos.addAll(videos ?: listOf()); - itemMoveCallback.canEdit = canEdit _adapterVideos?.setVideos(_videos, canEdit); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt index 1a4087a8..05577fcb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt @@ -1,6 +1,5 @@ package com.futo.platformplayer.views.livechat -import CSSColor import android.graphics.Color import android.graphics.drawable.LevelListDrawable import android.text.Spannable @@ -25,7 +24,6 @@ import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.overlays.LiveChatOverlay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import toAndroidColor class LiveChatDonationListItem(viewGroup: ViewGroup) : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_donation, viewGroup, false)) { @@ -57,10 +55,10 @@ class LiveChatDonationListItem(viewGroup: ViewGroup) _amount.text = event.amount.trim(); if(event.colorDonation != null && event.colorDonation.isHexColor()) { - val color = CSSColor.parseColor(event.colorDonation); - _amountContainer.background.setTint(color.toAndroidColor()); + val color = Color.parseColor(event.colorDonation); + _amountContainer.background.setTint(color); - if(color.lightness > 0.5) + if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) _amount.setTextColor(Color.BLACK); else _amount.setTextColor(Color.WHITE); diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt index 34ae1c1b..02619424 100644 --- a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt @@ -13,7 +13,6 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.live.LiveEventDonation import com.futo.platformplayer.isHexColor -import toAndroidColor class LiveChatDonationPill: LinearLayout { private val _imageAuthor: ImageView; @@ -34,10 +33,10 @@ class LiveChatDonationPill: LinearLayout { if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { - val color = CSSColor.parseColor(donation.colorDonation); - root.background.setTint(color.toAndroidColor()); + val color = Color.parseColor(donation.colorDonation); + root.background.setTint(color); - if(color.lightness > 0.5) + if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) _textAmount.setTextColor(Color.BLACK); else _textAmount.setTextColor(Color.WHITE); diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt index df742225..ffe7f1b3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt @@ -18,7 +18,6 @@ import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.overlays.LiveChatOverlay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import toAndroidColor class LiveChatMessageListItem(viewGroup: ViewGroup) : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_message, viewGroup, false)) { @@ -76,7 +75,7 @@ class LiveChatMessageListItem(viewGroup: ViewGroup) if (!event.colorName.isNullOrEmpty()) { try { - _authorName.setTextColor(CSSColor.parseColor(event.colorName).toAndroidColor()); + _authorName.setTextColor(Color.parseColor(event.colorName)); } catch (ex: Throwable) { } } else diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt index d655d7dd..472a516f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -9,7 +9,6 @@ import android.view.View import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.getDataLinkFromUrl @@ -82,14 +81,12 @@ class CreatorThumbnail : ConstraintLayout { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) - .diskCacheStrategy(DiskCacheStrategy.DATA) .crossfade() .into(_imageChannelThumbnail); } else { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) - .diskCacheStrategy(DiskCacheStrategy.DATA) .into(_imageChannelThumbnail); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt index 8c26d1e1..22a5d21f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt @@ -13,17 +13,6 @@ class RadioGroupView : FlexboxLayout { val selectedOptions = arrayListOf(); val onSelectedChange = Event1>(); - constructor(context: Context) : super(context) { - flexWrap = FlexWrap.WRAP; - _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); - - if (isInEditMode) { - setOptions(listOf("Example 1" to 1, "Example 2" to 2, "Example 3" to 3, "Example 4" to 4, "Example 5" to 5), listOf("Example 1", "Example 2"), - multiSelect = true, - atLeastOne = false - ); - } - } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { flexWrap = FlexWrap.WRAP; _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt index 97b82eca..fc3eff23 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt @@ -2,14 +2,11 @@ package com.futo.platformplayer.views.overlays import android.animation.LayoutTransition import android.content.Context -import android.content.Intent import android.graphics.Color import android.graphics.PointF -import android.net.Uri import android.util.AttributeSet import android.util.DisplayMetrics import android.view.View -import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button @@ -17,12 +14,14 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.futo.platformplayer.R -import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor import com.futo.platformplayer.api.media.models.live.ILiveEventChatMessage @@ -44,8 +43,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import toAndroidColor -import androidx.core.net.toUri class LiveChatOverlay : LinearLayout { @@ -69,11 +66,10 @@ class LiveChatOverlay : LinearLayout { private val _overlayRaid: ConstraintLayout; private val _overlayRaid_Name: TextView; - private val _overlayRaid_Message: TextView; private val _overlayRaid_Thumbnail: ImageView; private val _overlayRaid_ButtonGo: Button; - private val _overlayRaid_ButtonDismiss: Button; + private val _overlayRaid_ButtonPrevent: Button; private val _textViewers: TextView; @@ -97,7 +93,6 @@ class LiveChatOverlay : LinearLayout { val onRaidNow = Event1(); val onRaidPrevent = Event1(); - val onUrlClick = Event1() private val _argJsonSerializer = Json; @@ -122,18 +117,6 @@ class LiveChatOverlay : LinearLayout { view?.evaluateJavascript("setInterval(()=>{" + toRemoveJSInterval + "}, 1000)") {}; }; } - - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - onUrlClick.emit(request.url) - return true - } - - // API < 24 - @Suppress("DEPRECATION") - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - onUrlClick.emit(url.toUri()) - return true - } }; _chatContainer = findViewById(R.id.chatContainer); @@ -165,10 +148,9 @@ class LiveChatOverlay : LinearLayout { _overlayRaid = findViewById(R.id.overlay_raid); _overlayRaid_Name = findViewById(R.id.raid_name); - _overlayRaid_Message = findViewById(R.id.textRaidMessage); _overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail); _overlayRaid_ButtonGo = findViewById(R.id.raid_button_go); - _overlayRaid_ButtonDismiss = findViewById(R.id.raid_button_prevent); + _overlayRaid_ButtonPrevent = findViewById(R.id.raid_button_prevent); _overlayRaid.visibility = View.GONE; @@ -177,7 +159,7 @@ class LiveChatOverlay : LinearLayout { onRaidNow.emit(it); } } - _overlayRaid_ButtonDismiss.setOnClickListener { + _overlayRaid_ButtonPrevent.setOnClickListener { _currentRaid?.let { _currentRaid = null; _overlayRaid.visibility = View.GONE; @@ -220,8 +202,6 @@ class LiveChatOverlay : LinearLayout { if(viewerCount != null) _textViewers.text = viewerCount.toHumanNumber() + " " + context.getString(R.string.viewers); - else if(manager != null && manager.isVOD) - _textViewers.text = manager.viewCount.toHumanNumber() + " past viewers"; else if(manager != null) _textViewers.text = manager.viewCount.toHumanNumber() + " " + context.getString(R.string.viewers); else @@ -311,10 +291,10 @@ class LiveChatOverlay : LinearLayout { _overlayDonation_Amount.text = donation.amount.trim(); _overlayDonation.visibility = VISIBLE; if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { - val color = CSSColor.parseColor(donation.colorDonation); - _overlayDonation_AmountContainer.background.setTint(color.toAndroidColor()); + val color = Color.parseColor(donation.colorDonation); + _overlayDonation_AmountContainer.background.setTint(color); - if(color.lightness > 0.5) + if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) _overlayDonation_Amount.setTextColor(Color.BLACK) else _overlayDonation_Amount.setTextColor(Color.WHITE); @@ -392,15 +372,6 @@ class LiveChatOverlay : LinearLayout { } else _overlayRaid.visibility = View.GONE; - - if(raid?.isOutgoing ?: false) { - _overlayRaid_ButtonGo.visibility = View.VISIBLE - _overlayRaid_Message.text = "Viewers are raiding"; - } - else { - _overlayRaid_ButtonGo.visibility = View.GONE; - _overlayRaid_Message.text = "Raid incoming from"; - } } } fun setViewCount(viewCount: Int) { diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt index 0dba3c4f..27befb1e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt @@ -19,9 +19,7 @@ class WebviewOverlay : LinearLayout { inflate(context, R.layout.overlay_webview, this) _topbar = findViewById(R.id.topbar); _webview = findViewById(R.id.webview); - if (!isInEditMode){ - _webview.settings.javaScriptEnabled = true; - } + _webview.settings.javaScriptEnabled = true; _topbar.onClose.subscribe(this, onClose::emit); } diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt index d30a4795..78031ec0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt @@ -31,7 +31,7 @@ class SlideUpMenuButtonList : LinearLayout { fun setButtons(texts: List, activeText: String? = null) { _root.removeAllViews(); - val marginLeft = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.5f, resources.displayMetrics).toInt(); + val marginLeft = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.0f, resources.displayMetrics).toInt(); val marginRight = marginLeft; buttons.clear(); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt index 9180a3f1..75b50a26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuFilters.kt @@ -28,14 +28,17 @@ class SlideUpMenuFilters { private var _changed: Boolean = false; private val _lifecycleScope: CoroutineScope; + private var _isChannelSearch = false; + var commonCapabilities: ResultCapabilities? = null; - constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>) { + constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List, filterValues: HashMap>, isChannelSearch: Boolean = false) { _lifecycleScope = lifecycleScope; _container = container; _enabledClientsIds = enabledClientsIds; _filterValues = filterValues; + _isChannelSearch = isChannelSearch; _slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf()); _slideUpMenuOverlay.onOK.subscribe { onOK.emit(_enabledClientsIds, _changed); @@ -48,7 +51,10 @@ class SlideUpMenuFilters { private fun updateCommonCapabilities() { _lifecycleScope.launch(Dispatchers.IO) { try { - val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); + val caps = if(!_isChannelSearch) + StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds); + else + StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds); synchronized(_filterValues) { if (caps != null) { val keysToRemove = arrayListOf(); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt index 72500a49..58850998 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -13,7 +13,6 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.animation.doOnEnd import androidx.core.view.children -import androidx.core.view.isVisible import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event0 @@ -43,14 +42,10 @@ class SlideUpMenuOverlay : RelativeLayout { constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List, hideButtons: Boolean = false): super(context){ init(animated, okText); _container = parent; - _container!!.removeAllViews(); - _container!!.addView(this); - if (_container!!.isVisible) { - isVisible = true - _viewBackground.alpha = 1.0f; - _viewOverlayContainer.translationY = 0.0f; + if(!_container!!.children.contains(this)) { + _container!!.removeAllViews(); + _container!!.addView(this); } - _textTitle.text = titleText; groupItems = items; @@ -61,12 +56,6 @@ class SlideUpMenuOverlay : RelativeLayout { } setItems(items); - - if (!isVisible) { - _viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); - _viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat() - _viewBackground.alpha = 0f; - } } @@ -157,9 +146,16 @@ class SlideUpMenuOverlay : RelativeLayout { } isVisible = true; - _container?.visibility = View.VISIBLE; + _container?.post { + _container?.visibility = View.VISIBLE; + _container?.bringToFront(); + } if (_animated) { + _viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + _viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat() + _viewBackground.alpha = 0f; + val animations = arrayListOf(); animations.add(ObjectAnimator.ofFloat(_viewBackground, "alpha", 0.0f, 1.0f).setDuration(ANIMATION_DURATION_MS)); animations.add(ObjectAnimator.ofFloat(_viewOverlayContainer, "translationY", _viewOverlayContainer.measuredHeight.toFloat(), 0.0f).setDuration(ANIMATION_DURATION_MS)); diff --git a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt index 2ac4c2ee..8ef8520c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt +++ b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt @@ -9,28 +9,17 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView { } fun clearPlatform() { - setImageDrawable(null); + setImageResource(0); } fun setPlatformFromClientID(platformType : String?) { if(platformType == null) - setImageDrawable(null); + setImageResource(0); else { val result = StatePlatform.instance.getPlatformIcon(platformType); if (result != null) result.setImageView(this); else - setImageDrawable(null); - } - } - fun setPlatformFromClientName(name: String?) { - if(name == null) - setImageDrawable(null); - else { - val result = StatePlatform.instance.getPlatformIconByName(name); - if (result != null) - result.setImageView(this); - else - setImageDrawable(null); + setImageResource(0); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt index 7a3d5440..a1ccd142 100644 --- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -68,7 +68,15 @@ class CommentsList : ConstraintLayout { UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() }); }; - private val _scrollListener: RecyclerView.OnScrollListener + private val _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + onScrolled(); + + val totalScrollDistance = recyclerView.computeVerticalScrollOffset() + _layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE + } + }; private var _loader: (suspend () -> IPager)? = null; private val _adapterComments: InsertedViewAdapterWithLoader; @@ -123,14 +131,6 @@ class CommentsList : ConstraintLayout { _llmReplies = LinearLayoutManager(context); _recyclerComments.layoutManager = _llmReplies; _recyclerComments.adapter = _adapterComments; - _scrollListener = object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy); - onScrolled(); - - _layoutScrollToTop.visibility = if (_llmReplies.findFirstCompletelyVisibleItemPosition() > 5) View.VISIBLE else View.GONE - } - }; _recyclerComments.addOnScrollListener(_scrollListener); } diff --git a/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt b/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt index c091d3cd..6bdcd3ed 100644 --- a/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt @@ -43,13 +43,13 @@ class SyncDeviceView : ConstraintLayout { _layoutLinkType.visibility = View.VISIBLE _imageLinkType.setImageResource(when (linkType) { - LinkType.Relayed -> R.drawable.ic_internet - LinkType.Direct -> R.drawable.ic_lan + LinkType.Proxied -> R.drawable.ic_internet + LinkType.Local -> R.drawable.ic_lan else -> 0 }) _textLinkType.text = when(linkType) { - LinkType.Relayed -> "Relayed" - LinkType.Direct -> "Direct" + LinkType.Proxied -> "Proxied" + LinkType.Local -> "Local" else -> null } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt deleted file mode 100644 index cb5f1240..00000000 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.futo.platformplayer.views.video - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.animation.LinearInterpolator -import androidx.annotation.OptIn -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView -import androidx.media3.ui.TimeBar -import com.futo.platformplayer.R -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.states.StatePlayer -import com.futo.platformplayer.video.PlayerManager - -@UnstableApi -class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : - FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) { - - companion object { - private const val TAG = "FutoShortVideoPlayer" - private const val PLAYER_STATE_NAME: String = "ShortPlayer" - } - - private var playerAttached = false - private val videoView: PlayerView - private val progressBar: DefaultTimeBar - private lateinit var player: PlayerManager - private var progressAnimator: ValueAnimator = createProgressBarAnimator() - - val onPlaybackStateChanged = Event1(); - - private var playerEventListener = object : Player.Listener { - override fun onEvents(player: Player, events: Player.Events) { - if (events.containsAny( - Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED - ) - ) { - progressAnimator.cancel() - if (player.duration >= 0) { - progressAnimator.duration = player.duration - setProgressBarDuration(player.duration) - progressAnimator.currentPlayTime = player.currentPosition - } - - if (player.isPlaying) { - progressAnimator.start() - } - } - - if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { - onPlaybackStateChanged.emit(player.playbackState) - } - } - } - - init { - LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) - videoView = findViewById(R.id.short_player_view) - progressBar = findViewById(R.id.short_player_progress_bar) - - if (!isInEditMode) { - player = StatePlayer.instance.getShortPlayerOrCreate(context) - player.player.repeatMode = Player.REPEAT_MODE_ONE - } - - progressBar.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) { - progressAnimator.cancel() - } - - override fun onScrubMove(timeBar: TimeBar, position: Long) {} - - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (canceled) { - progressAnimator.currentPlayTime = player.player.currentPosition - progressAnimator.duration = player.player.duration - progressAnimator.start() - return - } - - // the progress bar should never be available to the user without the player being attached to this view - assert(playerAttached) - seekTo(position) - } - }) - } - - @OptIn(UnstableApi::class) - private fun createProgressBarAnimator(): ValueAnimator { - return ValueAnimator.ofFloat(0f, 1f).apply { - interpolator = LinearInterpolator() - - addUpdateListener { animation -> - progressBar.setPosition(animation.currentPlayTime) - } - } - } - - fun setProgressBarDuration(duration: Long) { - progressBar.setDuration(duration) - } - - /** - * Attaches this short player instance to the exo player instance for shorts - */ - fun attach() { - // connect the exo player for shorts to the view for this instance - player.attach(videoView, PLAYER_STATE_NAME) - - // direct the base player what exo player instance to use - changePlayer(player) - - playerAttached = true - - player.player.addListener(playerEventListener) - } - - fun detach() { - playerAttached = false - player.player.removeListener(playerEventListener) - player.detach() - } - - @OptIn(UnstableApi::class) - fun setArtwork(drawable: Drawable?) { - if (drawable != null) { - videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL - videoView.defaultArtwork = drawable - } else { - videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF - videoView.defaultArtwork = null - } - } - - fun getPlaybackRate(): Float { - return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f - } - - fun setPlaybackRate(playbackRate: Float) { - val exoPlayer = exoPlayer?.player - Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}") - - val param = PlaybackParameters(playbackRate) - exoPlayer?.playbackParameters = param - } -} diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt index 80175ab8..1b75050b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt @@ -126,7 +126,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { _evMuteChanged.add(callback); } - suspend fun setPreview(video: IPlatformVideoDetails) { + fun setPreview(video: IPlatformVideoDetails) { if (video.live != null) { setSource(video.live, null,true, false); } else { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index f6432752..1daa7808 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -1,13 +1,9 @@ package com.futo.platformplayer.views.video -import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.content.res.Resources -import android.graphics.Bitmap import android.graphics.Color -import android.graphics.Rect -import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.media.AudioManager import android.net.Uri @@ -32,9 +28,6 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerView import androidx.media3.ui.TimeBar -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -42,7 +35,6 @@ import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource -import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -51,13 +43,9 @@ import com.futo.platformplayer.formatDuration import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlayer -import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView -import com.futo.platformplayer.views.others.ProgressBar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.Executors @@ -129,9 +117,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { private var _isControlsLocked: Boolean = false; - private var _speedHoldPrevRate = 1f - private var _speedHoldWasPlaying = false - private val _time_bar_listener: TimeBar.OnScrubListener; var isFitMode : Boolean = false @@ -162,8 +147,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onChapterClicked = Event1(); - private val _loaderGame: TargetTapLoaderView - @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.video_view, this, true); @@ -204,9 +187,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); - _loaderGame = findViewById(R.id.loader_overlay) - _loaderGame.visibility = View.GONE - _control_chapter.setOnClickListener { _currentChapter?.let { onChapterClicked.emit(it); @@ -274,20 +254,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { gestureControl = findViewById(R.id.gesture_control); gestureControl.setupTouchArea(_layoutControls, background); - gestureControl.onSpeedHoldStart.subscribe { - exoPlayer?.player?.let { player -> - _speedHoldWasPlaying = player.isPlaying - _speedHoldPrevRate = getPlaybackRate() - setPlaybackRate(Settings.instance.playback.getHoldPlaybackSpeed().toFloat()) - player.play() - } - } - gestureControl.onSpeedHoldEnd.subscribe { - exoPlayer?.player?.let { player -> - if (!_speedHoldWasPlaying) player.pause() - setPlaybackRate(_speedHoldPrevRate) - } - } gestureControl.onSeek.subscribe { seekFromCurrent(it); }; gestureControl.onSoundAdjusted.subscribe { if (Settings.instance.gestureControls.useSystemVolume) { @@ -498,13 +464,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_autoplay_fullscreen.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white)) } - fun getVideoRect(): Rect { - val r = Rect() - // this is the only way i could reliably get a reference to a view that matches perfectly with the video playback - _videoView.subtitleView?.getGlobalVisibleRect(r) - return r - } - private fun setSystemBrightness(brightness: Float) { Log.i(TAG, "setSystemBrightness $brightness") if (android.provider.Settings.System.canWrite(context)) { @@ -572,8 +531,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase { fun setLoopVisible(visible: Boolean) { _control_loop.visibility = if (visible) View.VISIBLE else View.GONE; _control_loop_fullscreen.visibility = if (visible) View.VISIBLE else View.GONE; - if (StatePlayer.instance.loopVideo && !visible) - StatePlayer.instance.loopVideo = false } fun stopAllGestures() { @@ -889,44 +846,4 @@ class FutoVideoPlayer : FutoVideoPlayerBase { override fun onSurfaceSizeChanged(width: Int, height: Int) { gestureControl.resetZoomPan() } - - override fun setLoading(isLoading: Boolean) { - if (isLoading) { - _loaderGame.visibility = View.VISIBLE - _loaderGame.startLoader() - } else { - _loaderGame.visibility = View.GONE - _loaderGame.stopAndResetLoader() - } - } - - override fun setLoading(expectedDurationMs: Int) { - _loaderGame.visibility = View.VISIBLE - _loaderGame.startLoader(expectedDurationMs.toLong()) - } - - override fun switchToVideoMode() { - super.switchToVideoMode() - setArtwork(null) - } - - override fun switchToAudioMode(video: IPlatformVideoDetails?) { - super.switchToAudioMode(video) - val thumbnail = video?.thumbnails?.getHQThumbnail() - if (!thumbnail.isNullOrBlank()) { - Glide.with(context).asBitmap().load(thumbnail) - .into(object : CustomTarget() { - override fun onResourceReady( - resource: Bitmap, - transition: Transition? - ) { - setArtwork(BitmapDrawable(resources, resource)); - } - - override fun onLoadCleared(placeholder: Drawable?) { - setArtwork(null); - } - }) - } - } } \ No newline at end of file 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 ff147b65..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 @@ -1,18 +1,12 @@ package com.futo.platformplayer.views.video import android.content.Context -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet -import android.view.LayoutInflater import android.widget.RelativeLayout import androidx.annotation.OptIn -import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -34,13 +28,7 @@ import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition -import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -64,36 +52,27 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource -import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException -import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException -import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment -import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.views.video.datasources.PluginMediaDrmCallback import com.futo.platformplayer.views.video.datasources.JSHttpDataSource import getHttpDataSourceFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.File -import java.util.concurrent.atomic.AtomicInteger import kotlin.math.abs abstract class FutoVideoPlayerBase : RelativeLayout { private val TAG = "FutoVideoPlayerBase" + private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); + private var _mediaSource: MediaSource? = null; var lastVideoSource: IVideoSource? = null @@ -129,12 +108,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val onPositionDiscontinuity = Event1(); val onDatasourceError = Event1(); - val onReloadRequired = Event0(); - private var _didCallSourceChange = false; private var _lastState: Int = -1; - private val _swapIdAudio = AtomicInteger(0) - private val _swapIdVideo = AtomicInteger(0) + var targetTrackVideoHeight = -1 private set @@ -273,25 +249,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout { StateApp.instance.onConnectionAvailable.remove(_referenceObject); } - open fun switchToVideoMode() { + fun switchToVideoMode() { Logger.i(TAG, "Switching to Video Mode"); isAudioMode = false; - val player = exoPlayer ?: return - player.player.trackSelectionParameters = - player.player.trackSelectionParameters - .buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode) - .build() + loadSelectedSources(playing, true); } - open fun switchToAudioMode(video: IPlatformVideoDetails?) { + fun switchToAudioMode() { Logger.i(TAG, "Switching to Audio Mode"); isAudioMode = true; - val player = exoPlayer ?: return - player.player.trackSelectionParameters = - player.player.trackSelectionParameters - .buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode) - .build() + loadSelectedSources(playing, true); } fun seekTo(ms: Long) { @@ -365,63 +331,46 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return _chapters?.let { chaps -> chaps.find { pos.toDouble() / 1000 > it.timeStart && pos.toDouble() / 1000 < it.timeEnd && (toIgnore.isEmpty() || !toIgnore.contains(it)) } }; } - suspend fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false, resume: Boolean = false) { + fun setSource(videoSource: IVideoSource?, audioSource: IAudioSource? = null, play: Boolean = false, keepSubtitles: Boolean = false, resume: Boolean = false) { swapSources(videoSource, audioSource,resume, play, keepSubtitles); } - suspend fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { - val didSet = withContext(Dispatchers.IO) { - var videoSourceUsed = videoSource; - var audioSourceUsed = audioSource; - if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ - videoSource.getUnderlyingPlugin()?.busy { - videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); - audioSourceUsed = null; - } - } - - val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume); - val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume); - if(!keepSubtitles) - _lastSubtitleMediaSource = null; - - return@withContext didSetVideo && didSetAudio + fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean { + var videoSourceUsed = videoSource; + var audioSourceUsed = audioSource; + if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); + audioSourceUsed = null; } - return withContext(Dispatchers.Main) { - if (didSet) - return@withContext loadSelectedSources(play, resume) - else - return@withContext true - } + val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume); + val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume); + if(!keepSubtitles) + _lastSubtitleMediaSource = null; + if(didSetVideo && didSetAudio) + return loadSelectedSources(play, resume); + else + return true; } - suspend fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { - val didSet = withContext(Dispatchers.IO) { - var videoSourceUsed = videoSource; - if (videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource) - videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio); - return@withContext swapSourceInternal(videoSourceUsed, play, resume); - } - return withContext(Dispatchers.Main) { - if (didSet) - return@withContext loadSelectedSources(play, resume); - else - return@withContext true; - } + fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean { + var videoSourceUsed = videoSource; + if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource) + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio); + val didSet = swapSourceInternal(videoSourceUsed, play, resume); + if(didSet) + return loadSelectedSources(play, resume); + else + return true; } - suspend fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { - withContext(Dispatchers.IO) { - if (audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource) - swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume); - else - swapSourceInternal(audioSource, play, resume); - } - return withContext(Dispatchers.Main) { - return@withContext loadSelectedSources(play, resume); - } + fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean { + if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource) + swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume); + else + swapSourceInternal(audioSource, play, resume); + return loadSelectedSources(play, resume); } @OptIn(UnstableApi::class) - suspend fun swapSubtitles(subtitles: ISubtitleSource?) { + fun swapSubtitles(scope: CoroutineScope, subtitles: ISubtitleSource?) { if(subtitles == null) clearSubtitles(); else { @@ -435,9 +384,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { C.TIME_UNSET); loadSelectedSources(true, true); } else { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { try { - val subUri = subtitles.getSubtitlesURI() ?: return@withContext; + val subUri = subtitles.getSubtitlesURI() ?: return@launch; withContext(Dispatchers.Main) { try { _lastSubtitleMediaSource = SingleSampleMediaSource.Factory(DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT))) @@ -468,15 +417,13 @@ abstract class FutoVideoPlayerBase : RelativeLayout { private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean { - setLoading(false) - val swapId = _swapIdVideo.incrementAndGet() _lastGeneratedDash = null; val didSet = when(videoSource) { is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; } is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; } is IDashManifestWidevineSource -> { swapVideoSourceDashWidevine(videoSource); true } is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;} - is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume, swapId); + is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume); is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; } is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; } is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; } @@ -487,13 +434,11 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return didSet; } private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean { - setLoading(false) - val swapId = _swapIdAudio.incrementAndGet() val didSet = when(audioSource) { is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; } is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; } is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; } - is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId); + is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume); is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; } is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; } null -> { _lastAudioMediaSource = null; true; } @@ -600,41 +545,22 @@ abstract class FutoVideoPlayerBase : RelativeLayout { }.createMediaSource(MediaItem.fromUri(videoSource.url)) } @OptIn(UnstableApi::class) - private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean, swapId: Int): Boolean { + private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean { Logger.i(TAG, "Loading VideoSource [Dash]"); if(videoSource.hasGenerate) { findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { - val scope = this; - var startId = -1; try { - val plugin = videoSource.getUnderlyingPlugin() ?: return@launch; - startId = plugin.getUnderlyingPlugin()?.runtimeId ?: -1; - val generatedDef = plugin.busy { videoSource.generateAsync(scope); }; - withContext(Dispatchers.Main) { - if (generatedDef.estDuration >= 0) { - setLoading(generatedDef.estDuration) - } else { - setLoading(true) - } - } - val generated = generatedDef.await(); - if (_swapIdVideo.get() != swapId) { - return@launch - } - - withContext(Dispatchers.Main) { - setLoading(false) - } + val generated = videoSource.generate(); if (generated != null) { withContext(Dispatchers.Main) { val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) - withContext(Dispatchers.IO) { videoSource.getHttpDataSourceFactory() } + videoSource.getHttpDataSourceFactory() else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource) - dataSource.setRequestExecutor2(withContext(Dispatchers.IO){videoSource.audio.getRequestExecutor()}); + dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor()); _lastVideoMediaSource = DashMediaSource.Factory(dataSource) .createMediaSource( DashManifestParser().parse( @@ -649,23 +575,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } } } - catch(reloadRequired: ScriptReloadRequiredException) { - Logger.i(TAG, "Reload required detected"); - val plugin = videoSource.getUnderlyingPlugin(); - if(plugin == null) - return@launch; - if(startId != -1 && plugin.getUnderlyingPlugin()?.runtimeId != startId) - return@launch; - StatePlatform.instance.handleReloadRequired(reloadRequired, { - onReloadRequired.emit(); - }); - } catch(ex: Throwable) { Logger.e(TAG, "DashRaw generator failed", ex); - } finally { - withContext(Dispatchers.Main) { - setLoading(false) - } } } return false; @@ -748,69 +659,27 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } @OptIn(UnstableApi::class) - private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean, swapId: Int): Boolean { + private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean { Logger.i(TAG, "Loading AudioSource [DashRaw]"); + val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource)) + audioSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); if(audioSource.hasGenerate) { findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { - val scope = this; - var startId = -1; - try { - val plugin = audioSource.getUnderlyingPlugin() ?: return@launch; - startId = audioSource.getUnderlyingPlugin()?.getUnderlyingPlugin()?.runtimeId ?: -1; - val generatedDef = plugin.busy { audioSource.generateAsync(scope); } + val generated = audioSource.generate(); + if(generated != null) { withContext(Dispatchers.Main) { - if (generatedDef.estDuration >= 0) { - setLoading(generatedDef.estDuration) - } else { - setLoading(true) - } - } - val generated = generatedDef.await(); - if (_swapIdAudio.get() != swapId) { - return@launch - } - withContext(Dispatchers.Main) { - setLoading(false) - } - if(generated != null) { - val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource)) - audioSource.getHttpDataSourceFactory() - else - DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); - withContext(Dispatchers.Main) { - _lastVideoMediaSource = DashMediaSource.Factory(dataSource) - .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), - ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0)))); - loadSelectedSources(play, resume); - } - } - } - catch(reloadRequired: ScriptReloadRequiredException) { - Logger.i(TAG, "Reload required detected"); - val plugin = audioSource.getUnderlyingPlugin(); - if(plugin == null) - return@launch; - if(startId != -1 && plugin.getUnderlyingPlugin()?.runtimeId != startId) - return@launch; - StatePlatform.instance.reEnableClient(plugin.id, { - onReloadRequired.emit(); - }); - } - catch(ex: Throwable) { - - } finally { - withContext(Dispatchers.Main) { - setLoading(false) + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), + ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0)))); + loadSelectedSources(play, resume); } } } return false; } else { - val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource)) - audioSource.getHttpDataSourceFactory() - else - DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); _lastVideoMediaSource = DashMediaSource.Factory(dataSource) .createMediaSource( DashManifestParser().parse( @@ -932,9 +801,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout { fun clear() { exoPlayer?.player?.stop(); exoPlayer?.player?.clearMediaItems(); - setLoading(false) - _swapIdVideo.incrementAndGet() - _swapIdAudio.incrementAndGet() _lastVideoMediaSource = null; _lastAudioMediaSource = null; _lastSubtitleMediaSource = null; @@ -1014,9 +880,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } } - protected open fun setLoading(isLoading: Boolean) { } - protected open fun setLoading(expectedDurationMs: Int) { } - companion object { val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; diff --git a/app/src/main/res/drawable/button_shadow.xml b/app/src/main/res/drawable/button_shadow.xml deleted file mode 100644 index 8b2e08a8..00000000 --- a/app/src/main/res/drawable/button_shadow.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/desktop_comments.xml b/app/src/main/res/drawable/desktop_comments.xml deleted file mode 100644 index acdb15b4..00000000 --- a/app/src/main/res/drawable/desktop_comments.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_gear.xml b/app/src/main/res/drawable/desktop_gear.xml deleted file mode 100644 index 2001c903..00000000 --- a/app/src/main/res/drawable/desktop_gear.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_refresh.xml b/app/src/main/res/drawable/desktop_refresh.xml deleted file mode 100644 index 9625ff95..00000000 --- a/app/src/main/res/drawable/desktop_refresh.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_share.xml b/app/src/main/res/drawable/desktop_share.xml deleted file mode 100644 index a98111ad..00000000 --- a/app/src/main/res/drawable/desktop_share.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_thumb_down.xml b/app/src/main/res/drawable/desktop_thumb_down.xml deleted file mode 100644 index ca85aa53..00000000 --- a/app/src/main/res/drawable/desktop_thumb_down.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_thumb_down_filled.xml b/app/src/main/res/drawable/desktop_thumb_down_filled.xml deleted file mode 100644 index 7939d8b8..00000000 --- a/app/src/main/res/drawable/desktop_thumb_down_filled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_thumb_up.xml b/app/src/main/res/drawable/desktop_thumb_up.xml deleted file mode 100644 index 8a8eb280..00000000 --- a/app/src/main/res/drawable/desktop_thumb_up.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/desktop_thumb_up_filled.xml b/app/src/main/res/drawable/desktop_thumb_up_filled.xml deleted file mode 100644 index 5e4a7790..00000000 --- a/app/src/main/res/drawable/desktop_thumb_up_filled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml deleted file mode 100644 index f67f9d5c..00000000 --- a/app/src/main/res/drawable/ic_comment.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_high_quality.xml b/app/src/main/res/drawable/ic_high_quality.xml deleted file mode 100644 index 4afa3e96..00000000 --- a/app/src/main/res/drawable/ic_high_quality.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index 405bd330..00000000 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml index f9ae21c4..68758978 100644 --- a/app/src/main/res/drawable/ic_smart_display.xml +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23L150.77,729.23Z"/> diff --git a/app/src/main/res/drawable/ic_smart_display_filled.xml b/app/src/main/res/drawable/ic_smart_display_filled.xml deleted file mode 100644 index 14245c9c..00000000 --- a/app/src/main/res/drawable/ic_smart_display_filled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml index 3a80a3e3..8de5f492 100644 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -1,9 +1,9 @@ + android:viewportWidth="48" + android:viewportHeight="48"> + android:pathData="M13.15,9.6H33.3V31.45L20.8,44L20.2,43.55Q19.9,43.3 19.75,42.95Q19.6,42.6 19.6,42.2V42L21.7,31.45H6.85Q5.75,31.45 4.9,30.6Q4.05,29.75 4.05,28.7V26.15Q4.05,25.9 4.05,25.575Q4.05,25.25 4.15,24.95L9.75,11.85Q10.1,10.9 11.1,10.25Q12.1,9.6 13.15,9.6ZM31.75,11.15H12.85Q12.4,11.15 11.975,11.375Q11.55,11.6 11.3,12.15L5.6,25.6V28.7Q5.6,29.2 5.95,29.55Q6.3,29.9 6.85,29.9H23.55L21.25,41.45L31.75,30.75ZM31.75,30.75V29.9Q31.75,29.9 31.75,29.55Q31.75,29.2 31.75,28.7V25.6V12.15Q31.75,11.6 31.75,11.375Q31.75,11.15 31.75,11.15ZM33.3,31.45V29.9H40.45V11.15H33.3V9.6H42V31.45Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_down_filled.xml b/app/src/main/res/drawable/ic_thumb_down_filled.xml deleted file mode 100644 index 5517d2c6..00000000 --- a/app/src/main/res/drawable/ic_thumb_down_filled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml index 489f31d9..fdcf53d4 100644 --- a/app/src/main/res/drawable/ic_thumb_up.xml +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -1,9 +1,9 @@ + android:viewportWidth="48" + android:viewportHeight="48"> + android:pathData="M34.85,40H14.7V18.15L27.15,5.6L27.8,6.05Q28.05,6.3 28.2,6.65Q28.35,7 28.35,7.4V7.6L26.25,18.15H41.15Q42.2,18.15 43.05,19Q43.9,19.85 43.9,20.95V23.45Q43.9,23.75 43.9,24.05Q43.9,24.35 43.8,24.65L38.2,37.75Q37.8,38.7 36.8,39.35Q35.8,40 34.85,40ZM16.2,38.45H35.15Q35.55,38.45 36,38.225Q36.45,38 36.7,37.45L42.35,24V20.95Q42.35,20.45 42,20.075Q41.65,19.7 41.15,19.7H24.45L26.7,8.15L16.2,18.85ZM16.2,18.85V19.7Q16.2,19.7 16.2,20.075Q16.2,20.45 16.2,20.95V24V37.45Q16.2,38 16.2,38.225Q16.2,38.45 16.2,38.45ZM14.7,18.15V19.7H7.55V38.45H14.7V40H6V18.15Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up_filled.xml b/app/src/main/res/drawable/ic_thumb_up_filled.xml deleted file mode 100644 index 03a4da48..00000000 --- a/app/src/main/res/drawable/ic_thumb_up_filled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/keep_24px.xml b/app/src/main/res/drawable/keep_24px.xml deleted file mode 100644 index 767284d6..00000000 --- a/app/src/main/res/drawable/keep_24px.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/progress_bar.xml b/app/src/main/res/drawable/progress_bar.xml deleted file mode 100644 index d459af14..00000000 --- a/app/src/main/res/drawable/progress_bar.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_down_selector.xml b/app/src/main/res/drawable/thumb_down_selector.xml deleted file mode 100644 index 4ae564a7..00000000 --- a/app/src/main/res/drawable/thumb_down_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_up_selector.xml b/app/src/main/res/drawable/thumb_up_selector.xml deleted file mode 100644 index 97d02623..00000000 --- a/app/src/main/res/drawable/thumb_up_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index df3abc69..1708eeb4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,10 +1,9 @@ - - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_polycentric_backup.xml b/app/src/main/res/layout/activity_polycentric_backup.xml index d6579dd7..e31e8584 100644 --- a/app/src/main/res/layout/activity_polycentric_backup.xml +++ b/app/src/main/res/layout/activity_polycentric_backup.xml @@ -76,15 +76,4 @@ app:buttonIcon="@drawable/ic_copy" android:layout_marginTop="8dp" /> - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_sync_pair.xml b/app/src/main/res/layout/activity_sync_pair.xml index 2e5e5651..e5355ecc 100644 --- a/app/src/main/res/layout/activity_sync_pair.xml +++ b/app/src/main/res/layout/activity_sync_pair.xml @@ -233,7 +233,7 @@ android:isScrollContainer="true" android:scrollbars="vertical" android:maxHeight="200dp" - android:text="An error has occurred" /> + android:text="An error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurred" /> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_test.xml b/app/src/main/res/layout/activity_test.xml index 8c9bf301..bc4ebda9 100644 --- a/app/src/main/res/layout/activity_test.xml +++ b/app/src/main/res/layout/activity_test.xml @@ -5,8 +5,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:background="@color/black"> - + android:layout_height="200dp" + app:progress="0%" + app:strokeWidth="20dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index a6153053..e76b8c65 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -5,54 +5,31 @@ android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="#101010"> + android:background="@color/gray_1d"> + android:orientation="horizontal"> - + android:text="@string/discovered_devices" + android:layout_marginStart="20dp" + android:textSize="14dp" + android:textColor="@color/white" + android:fontFamily="@font/inter_regular" /> - - - - - - - - - + + android:layout_marginEnd="20dp" /> - - - - + android:orientation="horizontal"> + + + + - + + + + + + android:textColor="@color/gray_e0" /> - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_casting_connected.xml b/app/src/main/res/layout/dialog_casting_connected.xml index dc9aaed8..027db929 100644 --- a/app/src/main/res/layout/dialog_casting_connected.xml +++ b/app/src/main/res/layout/dialog_casting_connected.xml @@ -97,7 +97,27 @@ app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintLeft_toRightOf="@id/image_device" /> + + + @@ -233,30 +253,4 @@ android:gravity="center_vertical" android:paddingBottom="15dp"> - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_buy.xml b/app/src/main/res/layout/fragment_buy.xml index 27464791..d12534bd 100644 --- a/app/src/main/res/layout/fragment_buy.xml +++ b/app/src/main/res/layout/fragment_buy.xml @@ -76,7 +76,7 @@ android:id="@+id/button_buy_text" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="$19 + Tax" + android:text="$9.99 + Tax" android:textSize="14dp" android:textColor="@color/white" android:fontFamily="@font/inter_regular" diff --git a/app/src/main/res/layout/fragment_channel.xml b/app/src/main/res/layout/fragment_channel.xml index 29292034..ea9d4f52 100644 --- a/app/src/main/res/layout/fragment_channel.xml +++ b/app/src/main/res/layout/fragment_channel.xml @@ -173,7 +173,7 @@ android:background="#77000000" android:gravity="center"> - - - - diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml index a03f36ac..b8ca23df 100644 --- a/app/src/main/res/layout/fragment_playlists.xml +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -144,9 +144,6 @@ android:layout_marginTop="10dp" android:layout_marginLeft="15dp" android:layout_marginRight="15dp" - android:inputType="text" - android:imeOptions="actionDone" - android:singleLine="true" android:background="@drawable/background_button_round" android:hint="Search.." /> diff --git a/app/src/main/res/layout/fragment_shorts.xml b/app/src/main/res/layout/fragment_shorts.xml deleted file mode 100644 index 76c1a426..00000000 --- a/app/src/main/res/layout/fragment_shorts.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_sources.xml b/app/src/main/res/layout/fragment_sources.xml index ccd87712..80940400 100644 --- a/app/src/main/res/layout/fragment_sources.xml +++ b/app/src/main/res/layout/fragment_sources.xml @@ -8,7 +8,7 @@ android:orientation="vertical" android:paddingTop="10dp" android:animateLayoutChanges="true"> - - + diff --git a/app/src/main/res/layout/fragment_suggestion_list.xml b/app/src/main/res/layout/fragment_suggestion_list.xml index ef241b7b..3fec1999 100644 --- a/app/src/main/res/layout/fragment_suggestion_list.xml +++ b/app/src/main/res/layout/fragment_suggestion_list.xml @@ -1,56 +1,14 @@ - - - - - - - - - - - - - - + android:orientation="vertical" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_article_detail.xml b/app/src/main/res/layout/fragview_article_detail.xml deleted file mode 100644 index 9476c0de..00000000 --- a/app/src/main/res/layout/fragview_article_detail.xml +++ /dev/null @@ -1,317 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -