diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/1-bug_report.yml similarity index 54% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/1-bug_report.yml index 776ef91f..e2108e3e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug_report.yml @@ -1,6 +1,9 @@ name: Bug Report description: Let us know about an unexpected error, a crash, or an incorrect behavior. -labels: ["Bug"] +labels: ["Bug", "Android"] +title: "Bug: " +type: bug +projects: ["futo-org/19"] body: - type: markdown attributes: @@ -18,11 +21,33 @@ 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: what-happened + id: reproduction-steps attributes: - label: What happened? - description: What did you expect to happen? - placeholder: Tell us what you see! + 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! validations: required: true @@ -31,7 +56,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: "242" + placeholder: "311" validations: required: true @@ -42,19 +67,23 @@ body: multiple: true options: - "All" - - "Youtube" - - "Odysee" - - "Rumble" - - "Kick" - - "Twitch" - - "PeerTube" - - "Patreon" - - "Nebula" + - "Apple Podcasts" - "BiliBili (CN)" - "Bitchute" - - "SoundCloud" + - "Crunchyroll" + - "CuriosityStream" - "Dailymotion" - - "Apple Podcasts" + - "Kick" + - "Nebula" + - "Odysee" + - "Patreon" + - "PeerTube" + - "Rumble" + - "SoundCloud" + - "Spotify" + - "TedTalks" + - "Twitch" + - "Youtube" - "Other" validations: required: true @@ -66,6 +95,30 @@ 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: @@ -86,9 +139,28 @@ 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/feature_request.yml b/.github/ISSUE_TEMPLATE/2-feature_request.yml similarity index 91% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/2-feature_request.yml index ebba5241..2058150f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/2-feature_request.yml @@ -1,13 +1,16 @@ name: Feature Request description: Suggest a new feature or other enhancement. -labels: ["Enhancement"] +labels: ["Enhancement", "Android"] +title: "Feature request: " +type: feature +projects: ["futo-org/19"] 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 relating to the Grayjay Android Application + 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 For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) diff --git a/.github/ISSUE_TEMPLATE/documentation_issue.yml b/.github/ISSUE_TEMPLATE/3-documentation_issue.yml similarity index 95% rename from .github/ISSUE_TEMPLATE/documentation_issue.yml rename to .github/ISSUE_TEMPLATE/3-documentation_issue.yml index c416d012..40d245dc 100644 --- a/.github/ISSUE_TEMPLATE/documentation_issue.yml +++ b/.github/ISSUE_TEMPLATE/3-documentation_issue.yml @@ -1,13 +1,16 @@ 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/app/build.gradle b/app/build.gradle index c20ba29f..25d458d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -181,6 +181,7 @@ 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' diff --git a/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt new file mode 100644 index 00000000..66686260 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt @@ -0,0 +1,38 @@ +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 index 7607a2c9..f3e12645 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt @@ -11,7 +11,7 @@ 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" @@ -335,4 +335,4 @@ class SyncServerTests { class AlwaysAuthorized : IAuthorizable { override val isAuthorized: Boolean get() = true -} \ No newline at end of file +}*/ \ 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 index 1b9f19cd..d34bfad4 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt @@ -13,7 +13,7 @@ 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, @@ -509,4 +509,4 @@ class Authorized : IAuthorizable { class Unauthorized : IAuthorizable { override val isAuthorized: Boolean = false -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 176dc044..ea6f3e5b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ - 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_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index 9fe21ec8..fca7deda 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -219,9 +219,7 @@ 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")})"); @@ -243,8 +241,11 @@ fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? return null; } + val sortedAddresses: List = addresses + .sortedBy { addr -> addressScore(addr) } + val sockets: ArrayList = arrayListOf(); - for (i in addresses.indices) { + for (i in sortedAddresses.indices) { sockets.add(Socket()); } @@ -252,7 +253,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 = addresses[i]; + val address = sortedAddresses[i]; val socket = sockets[i]; val thread = Thread { try { 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 e31d3dac..d8427dc6 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt @@ -2,10 +2,30 @@ 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 @@ -24,6 +44,10 @@ 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}"); @@ -89,7 +113,29 @@ 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 -> { @@ -146,4 +192,119 @@ 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); + + 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)); } \ 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 7f827f66..da414d8f 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -29,6 +29,7 @@ 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 @@ -175,6 +176,10 @@ 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 @@ -221,10 +226,11 @@ 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; @@ -253,9 +259,11 @@ 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; @@ -277,6 +285,7 @@ class Settings : FragmentedStorageFileJson() { @Serializable class ChannelSettings { + @AdvancedField @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) var progressBar: Boolean = true; } @@ -302,16 +311,20 @@ 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; @@ -342,13 +355,16 @@ 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; @@ -425,9 +441,11 @@ 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 @@ -438,6 +456,7 @@ 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; @@ -464,14 +483,10 @@ 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; @@ -497,6 +512,7 @@ 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; @@ -515,6 +531,78 @@ class Settings : FragmentedStorageFileJson() { 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) @@ -530,6 +618,7 @@ 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; @@ -566,10 +655,12 @@ 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; @@ -599,15 +690,21 @@ 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; + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) @@ -675,9 +772,11 @@ 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; @@ -878,7 +977,23 @@ 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.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2) + @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) fun clearPayment() { SettingsActivity.getActivity()?.let { context -> UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", { @@ -896,15 +1011,20 @@ 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.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4) + @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) var polycentricEnabled: Boolean = true; - @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5) + @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7) var polycentricLocalCache: Boolean = true; } diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 3db07410..83387081 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -1151,6 +1151,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)) }), ) ); diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index bfa7925b..0875aadb 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -339,6 +339,33 @@ 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() @@ -407,7 +434,7 @@ private fun interfaceScore(nif: NetworkInterface): Int { } } -private fun addressScore(addr: InetAddress): Int { +fun addressScore(addr: InetAddress): Int { return when (addr) { is Inet4Address -> { val octets = addr.address.map { it.toInt() and 0xFF } 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 199598bd..ec89eac1 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -116,6 +116,7 @@ import java.io.StringWriter import java.lang.reflect.InvocationTargetException import java.util.LinkedList import java.util.Queue +import java.util.UUID import java.util.concurrent.ConcurrentLinkedQueue @@ -186,7 +187,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragVideoDetail: VideoDetailFragment; //State - private val _queue: Queue> = LinkedList(); + private val _queue: LinkedList> = LinkedList(); lateinit var fragCurrent: MainFragment private set; private var _parameterCurrent: Any? = null; @@ -220,6 +221,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } + val mainId = UUID.randomUUID().toString().substring(0, 5) + constructor() : super() { if (BuildConfig.DEBUG) { StrictMode.setVmPolicy( @@ -271,8 +274,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { @UnstableApi override fun onCreate(savedInstanceState: Bundle?) { - Logger.i(TAG, "MainActivity Starting"); - StateApp.instance.setGlobalContext(this, lifecycleScope); + Logger.w(TAG, "MainActivity Starting [$mainId]"); + StateApp.instance.setGlobalContext(this, lifecycleScope, mainId); StateApp.instance.mainAppStarting(this); super.onCreate(savedInstanceState); @@ -678,13 +681,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { override fun onResume() { super.onResume(); - Logger.v(TAG, "onResume") + Logger.w(TAG, "onResume [$mainId]") _isVisible = true; } override fun onPause() { super.onPause(); - Logger.v(TAG, "onPause") + Logger.w(TAG, "onPause [$mainId]") _isVisible = false; _qrCodeLoadingDialog?.dismiss() @@ -693,7 +696,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { override fun onStop() { super.onStop() - Logger.v(TAG, "_wasStopped = true"); + Logger.w(TAG, "onStop [$mainId]"); _wasStopped = true; } @@ -1110,8 +1113,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { override fun onDestroy() { super.onDestroy(); - Logger.v(TAG, "onDestroy") - StateApp.instance.mainAppDestroyed(this); + Logger.w(TAG, "onDestroy [$mainId]") + StateApp.instance.mainAppDestroyed(this, mainId); } inline fun isFragmentActive(): Boolean { @@ -1192,7 +1195,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) fragBeforeOverlay = fragCurrent; - fragCurrent = segment; _parameterCurrent = parameter; } 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 a0a0fac1..9cf58134 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -14,10 +14,12 @@ 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 @@ -29,6 +31,9 @@ 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 @@ -39,6 +44,7 @@ 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)) @@ -49,24 +55,47 @@ 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); + _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) findViewById(R.id.button_back).setOnClickListener { finish(); }; - _exportBundle = createExportBundle(); + _imageQR.visibility = View.INVISIBLE + _textQR.visibility = View.INVISIBLE + _loader.visibility = View.VISIBLE + _buttonShare.visibility = View.INVISIBLE + _buttonCopy.visibility = View.INVISIBLE - 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; + 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 + } } _buttonShare.onClick.subscribe { 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 211f83a6..ce3a720e 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 @@ -34,8 +34,10 @@ class PlatformClientPool { isDead = true; onDead.emit(parentClient, this); - for(clientPair in _pool) { - clientPair.key.disable(); + synchronized(_pool) { + for (clientPair in _pool) { + clientPair.key.disable(); + } } }; } 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 9b063c9b..4ff3a549 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,6 +2,7 @@ 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 @@ -44,6 +45,7 @@ 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/models/PlatformAuthorLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt index 330597a4..831f8ef7 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 @@ -6,6 +6,7 @@ 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 @@ -33,6 +34,7 @@ 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); 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 03abad1a..6b73842f 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,6 +3,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.platforms.js.SourcePluginConfig +import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -20,6 +21,7 @@ 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 fd24de30..e95b3fe0 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,6 +5,7 @@ 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 @@ -46,6 +47,7 @@ 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"); }, @@ -69,6 +71,7 @@ class FilterGroup( companion object { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup { + value.ensureIsBusy(); return FilterGroup( value.getString("name"), value.getOrDefault(config, "filters", "FilterGroup", null) @@ -90,6 +93,7 @@ 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 a30d31c9..b25936a0 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,6 +4,7 @@ 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 @@ -31,6 +32,7 @@ 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/live/IPlatformLiveEvent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/IPlatformLiveEvent.kt index 89826b01..19b4bbb9 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,6 +2,7 @@ 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 { @@ -10,6 +11,7 @@ interface IPlatformLiveEvent { 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 28bbe15a..8b9883ef 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,6 +4,7 @@ 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 @@ -27,6 +28,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage { companion object { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment { + obj.ensureIsBusy(); + val contextName = "LiveEventComment" val colorName = obj.getOrDefault(config, "colorName", contextName, null); 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 a4ac5d47..f8cbafe6 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,6 +3,7 @@ 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 @@ -37,6 +38,7 @@ 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 6e29bac5..ebd75b44 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,6 +2,7 @@ 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 { @@ -15,9 +16,9 @@ class LiveEventEmojis: IPlatformLiveEvent { 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 ff5dd36f..6663852d 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,6 +2,8 @@ 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 { @@ -10,20 +12,24 @@ class LiveEventRaid: IPlatformLiveEvent { val targetName: String; val targetThumbnail: String; val targetUrl: String; + val isOutgoing: Boolean; - constructor(name: String, url: String, thumbnail: String) { + constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) { 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.getOrThrow(config, "targetThumbnail", contextName), + obj.getOrDefault(config, "isOutgoing", contextName, true) ?: true); } } } \ 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 adcfb883..5e48e984 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,6 +2,7 @@ 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 { @@ -15,6 +16,7 @@ class LiveEventViewCount: IPlatformLiveEvent { 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/ratings/IRating.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/IRating.kt index 75286b44..1fdbb442 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,6 +3,7 @@ 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 @@ -13,8 +14,12 @@ interface IRating { companion object { - fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) }; + fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating): IRating { + obj?.ensureIsBusy(); + return 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 6d0e787b..8ccc6b2e 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,6 +2,7 @@ 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,6 +15,7 @@ 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 e40169f2..0a45f15b 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,6 +2,7 @@ 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 /** @@ -13,6 +14,7 @@ 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 7646cf24..d656df5f 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,6 +2,7 @@ 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 /** @@ -13,6 +14,7 @@ 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/platforms/js/DevJSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt index b26abe45..1f29bf2a 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 @@ -56,6 +56,7 @@ class DevJSClient : JSClient { 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 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 1ac4c13e..fd3c9dde 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 @@ -61,9 +61,13 @@ 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 @@ -85,6 +89,8 @@ 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; @@ -100,14 +106,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() = _busyCounter > 0; + val isBusy: Boolean get() = _plugin.isBusy; val isBusyAction: String get() { return _busyAction; } + val declareOnEnable = HashMap(); + val settings: HashMap get() = descriptor.settings; val flags: Array; @@ -200,6 +206,7 @@ open class JSClient : IPlatformClient { 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 @@ -216,14 +223,31 @@ 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; @@ -263,19 +287,28 @@ open class JSClient : IPlatformClient { } @JSDocs(0, "source.enable()", "Called when the plugin is enabled/started") - fun enable() { + fun enable() = isBusyWith("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? { + fun saveState(): String? = isBusyWith("saveState") { ensureEnabled(); if(!capabilities.hasSaveState) - return null; + return@isBusyWith null; val resp = plugin.executeTyped("source.saveState()").value; - return resp; + return@isBusyWith resp; } @JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped") @@ -323,8 +356,10 @@ open class JSClient : IPlatformClient { return _searchCapabilities!!; } - _searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()")); - return _searchCapabilities!!; + return busy { + _searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()")); + return@busy _searchCapabilities!!; + } } catch(ex: Throwable) { announcePluginUnhandledException("getSearchCapabilities", ex); @@ -352,8 +387,10 @@ open class JSClient : IPlatformClient { if (_searchChannelContentsCapabilities != null) return _searchChannelContentsCapabilities!!; - _searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()")); - return _searchChannelContentsCapabilities!!; + return busy { + _searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()")); + return@busy _searchChannelContentsCapabilities!!; + } } @JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform") @JSDocsParameter("channelUrl", "Channel url to search") @@ -385,14 +422,14 @@ open class JSClient : IPlatformClient { @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 { + override fun isChannelUrl(url: String): Boolean = isBusyWith("isChannelUrl") { try { - return plugin.executeTyped("source.isChannelUrl(${Json.encodeToString(url)})") + return@isBusyWith plugin.executeTyped("source.isChannelUrl(${Json.encodeToString(url)})") .value; } catch(ex: Throwable) { announcePluginUnhandledException("isChannelUrl", ex); - return false; + return@isBusyWith false; } } @JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url") @@ -410,9 +447,10 @@ open class JSClient : IPlatformClient { if (_channelCapabilities != null) { return _channelCapabilities!!; } - - _channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()")); - return _channelCapabilities!!; + return busy { + _channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()")); + return@busy _channelCapabilities!!; + }; } catch(ex: Throwable) { announcePluginUnhandledException("getChannelCapabilities", ex); @@ -523,14 +561,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 { + override fun isContentDetailsUrl(url: String): Boolean = isBusyWith("isContentDetailsUrl") { try { - return plugin.executeTyped("source.isContentDetailsUrl(${Json.encodeToString(url)})") + return@isBusyWith plugin.executeTyped("source.isContentDetailsUrl(${Json.encodeToString(url)})") .value; } catch(ex: Throwable) { announcePluginUnhandledException("isContentDetailsUrl", ex); - return false; + return@isBusyWith false; } } @JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url") @@ -562,7 +600,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(config, tracker); + return@isBusyWith JSPlaybackTracker(this, tracker); else return@isBusyWith null; } @@ -604,7 +642,6 @@ 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") { @@ -632,17 +669,19 @@ 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 { + override fun isPlaylistUrl(url: String): Boolean = isBusyWith("isPlaylistUrl") { if (!capabilities.hasGetPlaylist) - return false; + return@isBusyWith false; try { - return plugin.executeTyped("source.isPlaylistUrl(${Json.encodeToString(url)})") - .value; + return@isBusyWith busy { + return@busy plugin.executeTyped("source.isPlaylistUrl(${Json.encodeToString(url)})") + .value; + } } catch(ex: Throwable) { announcePluginUnhandledException("isPlaylistUrl", ex); - return false; + return@isBusyWith false; } } @JSOptional @@ -744,19 +783,29 @@ open class JSClient : IPlatformClient { return urls; } - - private fun isBusyWith(actionName: String, handle: ()->T): T { - try { - synchronized(_busyLock) { - _busyCounter++; - } - _busyAction = actionName; - return handle(); + fun busy(handle: ()->T): T { + return _plugin.busy { + return@busy handle(); } - finally { - _busyAction = ""; - synchronized(_busyLock) { - _busyCounter--; + } + 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(); + + } + finally { + _busyAction = ""; } } } 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 ff67e56e..8d5675b6 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,6 +4,7 @@ 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 @@ -169,12 +170,17 @@ class SourcePluginConfig( } fun validate(text: String): Boolean { - if(scriptPublicKey.isNullOrEmpty()) - throw IllegalStateException("No public key present"); - if(scriptSignature.isNullOrEmpty()) - throw IllegalStateException("No signature present"); + try { + if (scriptPublicKey.isNullOrEmpty()) + throw IllegalStateException("No public key present"); + if (scriptSignature.isNullOrEmpty()) + throw IllegalStateException("No signature present"); - return SignatureProvider.verify(text, scriptSignature, scriptPublicKey); + return SignatureProvider.verify(text, scriptSignature, scriptPublicKey); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to verify due to an unhandled exception", e) + return false + } } fun isUrlAllowed(url: String): Boolean { @@ -205,6 +211,8 @@ 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/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index 6f835304..03c5c2c6 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,6 +67,25 @@ 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)) }) 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 777981bf..326b4086 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 @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,7 @@ 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); 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 21b475ff..16470c17 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,12 +6,14 @@ 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); 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 27731fea..dc2ba7b2 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() { + override fun nextPage() = plugin.isBusyWith("JSLiveEventPager.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 8782b742..e81a288d 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 @@ -29,7 +29,9 @@ abstract class JSPager : IPager { this.pager = pager; this.config = config; - _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; + plugin.busy { + _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; + } getResults(); } @@ -44,11 +46,14 @@ abstract class JSPager : IPager { override fun nextPage() { warnIfMainThread("JSPager.nextPage"); - pager = plugin.getUnderlyingPlugin().catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { - pager.invoke("nextPage", arrayOf()); - }; - _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; - _resultChanged = true; + val pluginV8 = plugin.getUnderlyingPlugin(); + pluginV8.busy { + pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { + pager.invoke("nextPage", arrayOf()); + }; + _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; + _resultChanged = true; + } /* try { } @@ -70,15 +75,18 @@ abstract class JSPager : IPager { return previousResults; warnIfMainThread("JSPager.getResults"); - 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; + + 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; + } } 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 e5ee7b68..15a7d854 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,37 +2,50 @@ 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.logging.Logger +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.warnIfMainThread class JSPlaybackTracker: IPlaybackTracker { - private val _config: IV8PluginConfig; - private val _obj: V8ValueObject; + private lateinit var _client: JSClient; + private lateinit var _config: IV8PluginConfig; + private lateinit var _obj: V8ValueObject; private var _hasCalledInit: Boolean = false; - private val _hasInit: Boolean; + private var _hasInit: Boolean = false; private var _lastRequest: Long = Long.MIN_VALUE; - private val _hasOnConcluded: Boolean; + private var _hasOnConcluded: Boolean = false; override var nextRequest: Int = 1000 private set; - constructor(config: IV8PluginConfig, obj: V8ValueObject) { + constructor(client: JSClient, 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"); - this._config = config; - this._obj = obj; - this._hasInit = obj.has("onInit"); + 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"); + } } override fun onInit(seconds: Double) { @@ -40,12 +53,15 @@ class JSPlaybackTracker: IPlaybackTracker { synchronized(_obj) { if(_hasCalledInit) return; - if (_hasInit) { - Logger.i("JSPlaybackTracker", "onInit (${seconds})"); - _obj.invokeVoid("onInit", seconds); + + _client.busy { + if (_hasInit) { + Logger.i("JSPlaybackTracker", "onInit (${seconds})"); + _obj.invokeVoid("onInit", seconds); + } + nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); + _hasCalledInit = true; } - nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); - _hasCalledInit = true; } } @@ -55,10 +71,12 @@ class JSPlaybackTracker: IPlaybackTracker { if(!_hasCalledInit && _hasInit) onInit(seconds); else { - 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(); + _client.busy { + 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(); + } } } } @@ -67,7 +85,9 @@ class JSPlaybackTracker: IPlaybackTracker { if(_hasOnConcluded) { synchronized(_obj) { Logger.i("JSPlaybackTracker", "onConcluded"); - _obj.invokeVoid("onConcluded", -1); + _client.busy { + _obj.invokeVoid("onConcluded", -1); + } } } } 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 70dfecfd..36cfc7db 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 @@ -46,16 +46,18 @@ class JSRequestExecutor { if (_executor.isClosed) throw IllegalStateException("Executor object is closed"); - 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; - } + 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.invoke("executeRequest", url, headers, method, body); + } as V8Value; + } else V8Plugin.catchScriptErrors( _config, "[${_config.name}] JSRequestExecutor", @@ -64,34 +66,35 @@ class JSRequestExecutor { _executor.invoke("executeRequest", url, headers, method, body); } as V8Value; - try { - if(result is V8ValueString) { - val base64Result = Base64.getDecoder().decode(result.value); - return 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 bytesResult; - } - if(result is V8ValueObject && result.has("type")) { - val type = result.getOrThrow(_config, "type", "JSRequestModifier"); - when(type) { - //TODO: Buffer type? + 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); } - 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); + finally { + result.close(); } - throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name); - } - finally { - result.close(); } } @@ -99,24 +102,25 @@ class JSRequestExecutor { open fun cleanup() { if (!hasCleanup || _executor.isClosed) return; - - 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); - }; + _plugin.busy { + 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 150189e7..f7d169af 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 @@ -16,7 +16,7 @@ class JSRequestModifier: IRequestModifier { private val _plugin: JSClient; private val _config: IV8PluginConfig; private var _modifier: V8ValueObject; - override var allowByteSkip: Boolean; + override var allowByteSkip: Boolean = false; constructor(plugin: JSClient, modifier: V8ValueObject) { this._plugin = plugin; @@ -24,10 +24,13 @@ class JSRequestModifier: IRequestModifier { this._config = plugin.config; val config = plugin.config; - allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true; + plugin.busy { + allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true; + + if(!modifier.has("modifyRequest")) + throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null); + } - if(!modifier.has("modifyRequest")) - throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null); } override fun modifyRequest(url: String, headers: Map): IRequest { @@ -35,13 +38,15 @@ class JSRequestModifier: IRequestModifier { return Request(url, headers); } - val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") { - _modifier.invoke("modifyRequest", url, headers); - } as V8ValueObject; + return _plugin.busy { + 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 req; + val req = JSRequest(_plugin, result, url, headers); + result.close(); + return@busy 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 bb4650f6..259a89e4 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,6 +6,7 @@ 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.states.StateApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -35,8 +36,11 @@ class JSSubtitleSource : ISubtitleSource { override fun getSubtitles(): String { if(!hasFetch) throw IllegalStateException("This subtitle doesn't support getSubtitles.."); - val v8String = _obj.invoke("getSubtitles", arrayOf()); - return v8String.value; + + return _obj.getSourcePlugin()?.busy { + val v8String = _obj.invoke("getSubtitles", arrayOf()); + return@busy v8String.value; + } ?: ""; } override suspend fun getSubtitlesURI(): Uri? { 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 da495498..cecb2913 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 @@ -27,6 +27,7 @@ import com.futo.platformplayer.getOrThrowNullable import com.futo.platformplayer.states.StateDeveloper class JSVideoDetails : JSVideo, IPlatformVideoDetails { + private val _plugin: JSClient; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; private val _hasGetPlaybackTracker: Boolean; @@ -48,6 +49,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { 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)); @@ -82,14 +84,16 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { return getPlaybackTrackerJS(); } private fun getPlaybackTrackerJS(): IPlaybackTracker? { - 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; - }; + return _plugin.busy { + V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") { + val tracker = _content.invoke("getPlaybackTracker", arrayOf()) + ?: return@catchScriptErrors null; + if(tracker is V8ValueObject) + return@catchScriptErrors JSPlaybackTracker(_plugin, tracker); + else + return@catchScriptErrors null; + } + } } override fun getContentRecommendations(client: IPlatformClient): IPager? { @@ -106,8 +110,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { return null; } private fun getContentRecommendationsJS(client: JSClient): JSContentPager { - val contentPager = _content.invoke("getContentRecommendations", arrayOf()); - return JSContentPager(_pluginConfig, client, contentPager); + return _plugin.busy { + val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + return@busy JSContentPager(_pluginConfig, client, contentPager); + } } override fun getComments(client: IPlatformClient): IPager? { @@ -123,10 +129,12 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails { } private fun getCommentsJS(client: JSClient): IPager? { - val commentPager = _content.invoke("getComments", arrayOf()); - if (commentPager !is V8ValueObject) //TODO: Maybe handle this better? - return null; + return _plugin.busy { + val commentPager = _content.invoke("getComments", arrayOf()); + if (commentPager !is V8ValueObject) //TODO: Maybe handle this better? + return@busy null; - return JSCommentPager(_pluginConfig, client, commentPager); + return@busy 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/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index ae35207b..7b6388cd 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,6 +1,8 @@ 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 @@ -13,8 +15,13 @@ 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; @@ -50,6 +57,44 @@ 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; @@ -62,21 +107,27 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS if(_plugin is DevJSClient) result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { - _obj.invokeString("generate"); + _plugin.isBusyWith("dashAudio.generate") { + _obj.invokeV8("generate").value; + } } } else result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") { - _obj.invokeString("generate"); + _plugin.isBusyWith("dashAudio.generate") { + _obj.invokeV8("generate").value; + } } if(result != null){ - 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); + 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 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 d6ff7455..aebaab23 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,6 +3,7 @@ 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 @@ -15,11 +16,18 @@ 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 { @@ -32,7 +40,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo override val duration: Long; override val priority: Boolean; - var url: String?; + val url: String?; override var manifest: String?; override val hasGenerate: Boolean; @@ -57,6 +65,45 @@ 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; @@ -67,22 +114,28 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo if(_plugin is DevJSClient) { result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") { _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { - _obj.invokeString("generate"); + _plugin.isBusyWith("dashVideo.generate") { + _obj.invokeV8("generate").value; + } }); } } else result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", { - _obj.invokeString("generate"); + _plugin.isBusyWith("dashVideo.generate") { + _obj.invokeV8("generate").value; + } }); if(result != null){ - 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); + _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 result; @@ -110,6 +163,32 @@ 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/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index 9e328df3..18cd71fc 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,6 +7,7 @@ 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 @@ -38,7 +39,13 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { companion object { - fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; - fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(plugin, obj); + 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) + }; } } \ 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 3c76e23d..22bf2a60 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,6 +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.logging.Logger import com.futo.platformplayer.orNull @@ -53,36 +54,39 @@ abstract class JSSource { hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor"); } - fun getRequestModifier(): IRequestModifier? { + fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") { if(_requestModifier != null) - return AdhocRequestModifier { url, headers -> + return@isBusyWith AdhocRequestModifier { url, headers -> return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers); }; if (!hasRequestModifier || _obj.isClosed) - return null; + return@isBusyWith null; val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") { _obj.invoke("getRequestModifier", arrayOf()); }; if (result !is V8ValueObject) - return null; + return@isBusyWith null; - return JSRequestModifier(_plugin, result) + return@isBusyWith JSRequestModifier(_plugin, result) } - open fun getRequestExecutor(): JSRequestExecutor? { + open fun getRequestExecutor(): JSRequestExecutor? = _plugin.isBusyWith("getRequestExecutor") { if (!hasRequestExecutor || _obj.isClosed) - return null; + return@isBusyWith null; + Logger.v("JSSource", "Request executor for [${type}] requesting"); val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") { _obj.invoke("getRequestExecutor", arrayOf()); }; - if (result !is V8ValueObject) - return null; + Logger.v("JSSource", "Request executor for [${type}] received"); - return JSRequestExecutor(_plugin, result) + if (result !is V8ValueObject) + return@isBusyWith null; + + return@isBusyWith JSRequestExecutor(_plugin, result) } fun getUnderlyingPlugin(): JSClient? { @@ -105,8 +109,12 @@ abstract class JSSource { const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource" const val TYPE_VIDEOURL_WIDEVINE = "VideoUrlWidevineSource" - fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) }; + fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? { + obj?.ensureIsBusy(); + return 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); @@ -123,13 +131,26 @@ abstract class JSSource { } } fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) }; - 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 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 fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }; - fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj); + fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource { + obj.ensureIsBusy(); + return 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 e68f0ae0..e7c0fe50 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,6 +6,7 @@ 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 { @@ -31,6 +32,7 @@ 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/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 3d362efd..226a0a66 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -35,7 +35,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() = false; //TODO: Implement + override val canSetSpeed: Boolean get() = true; var addresses: Array? = null; var port: Int = 0; @@ -144,6 +144,23 @@ 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; 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 58bd772c..1e8e1830 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -166,10 +166,11 @@ class StateCasting { Logger.i(TAG, "CastingService started."); _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + startDiscovering() } @Synchronized - fun startDiscovering() { + private fun startDiscovering() { _nsdManager?.apply { _discoveryListeners.forEach { discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) @@ -178,7 +179,7 @@ class StateCasting { } @Synchronized - fun stopDiscovering() { + private fun stopDiscovering() { _nsdManager?.apply { _discoveryListeners.forEach { try { @@ -1220,9 +1221,16 @@ class StateCasting { private fun getLocalUrl(ad: CastingDevice): String { var address = ad.localAddress!! - if (address.isLinkLocalAddress) { - address = findPreferredAddress() ?: address - Logger.i(TAG, "Selected casting address: $address") + 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}"; } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index 1691b9df..5e2b72de 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -7,9 +7,7 @@ import android.app.PendingIntent.getBroadcast import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller -import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED import android.graphics.drawable.Animatable -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -157,9 +155,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { val packageInstaller: PackageInstaller = context.packageManager.packageInstaller; val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) - } val sessionId = packageInstaller.createSession(params); session = packageInstaller.openSession(sessionId) 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 f00bd191..87375779 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -103,7 +103,6 @@ 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) { @@ -148,7 +147,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { override fun dismiss() { super.dismiss() (_imageLoader.drawable as Animatable?)?.stop() - StateCasting.instance.stopDiscovering() StateCasting.instance.onDeviceAdded.remove(this) StateCasting.instance.onDeviceChanged.remove(this) StateCasting.instance.onDeviceRemoved.remove(this) 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 2e79b0b4..4c0ccb7a 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ImportOptionsDialog.kt @@ -6,12 +6,16 @@ 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; @@ -41,8 +45,17 @@ class ImportOptionsDialog: AlertDialog { _button_import_zip.onClick.subscribe { dismiss(); StateApp.instance.requestFileReadAccess(_context, null, "application/zip") { - val zipBytes = it?.readBytes(context) ?: return@requestFileReadAccess; - StateBackup.importZipBytes(_context, StateApp.instance.scope, zipBytes); + 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); + } + } + } }; } _button_import_ezip.setOnClickListener { @@ -51,17 +64,35 @@ class ImportOptionsDialog: AlertDialog { _button_import_txt.onClick.subscribe { dismiss(); StateApp.instance.requestFileReadAccess(_context, null, "text/plain") { - val txtBytes = it?.readBytes(context) ?: return@requestFileReadAccess; - val txt = String(txtBytes); - StateBackup.importTxt(_context, txt); + 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); + } + } + } }; } _button_import_newpipe_subs.onClick.subscribe { dismiss(); StateApp.instance.requestFileReadAccess(_context, null, "application/json") { - val jsonBytes = it?.readBytes(context) ?: return@requestFileReadAccess; - val json = String(jsonBytes); - StateBackup.importNewPipeSubs(_context, 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); + } + } + } }; }; _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 f5cf534a..5e64c3e3 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -724,7 +724,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()); 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 15412fd9..9b888bff 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,6 +26,7 @@ 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 @@ -38,8 +39,18 @@ 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; @@ -47,10 +58,14 @@ 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); @@ -67,10 +82,8 @@ class V8Plugin { var isStopped = true; val onStopped = Event1(); - //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 }; + private val _busyLock = ReentrantLock() + val isBusy get() = _busyLock.isLocked; var allowDevSubmit: Boolean = false private set(value) { @@ -140,6 +153,7 @@ 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(); @@ -148,6 +162,8 @@ class V8Plugin { if (!host.isIsolateCreated) throw IllegalStateException("Isolate not created"); + _runtimeMap.put(_runtime!!, this); + //Setup bridge _runtime?.let { it.converter = V8Converter(); @@ -184,10 +200,13 @@ class V8Plugin { } fun stop(){ Logger.i(TAG, "Stopping plugin [${config.name}]"); - isStopped = true; - whenNotBusy { + busy { + Logger.i(TAG, "Plugin stopping"); synchronized(_runtimeLock) { + if(isStopped) + return@busy; isStopped = true; + runtimeId = runtimeId + 1; //Cleanup http for(pack in _depsPackages) { @@ -197,6 +216,7 @@ class V8Plugin { } _runtime?.let { + _runtimeMap.remove(it); _runtime = null; if(!it.isClosed && !it.isDead) { try { @@ -211,62 +231,147 @@ 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); - synchronized(_busyCounterLock) { - _busyCounter++; - } - - val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); - try { - return catchScriptErrors("Plugin[${config.name}]", js) { + val result = busy { + val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet"); + return@busy catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() }; + }; + if(result is V8ValuePromise) { + return result.toV8ValueBlocking(this@V8Plugin); } - 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--; - } - } + return result as T; } - 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 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 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); - } - } + + 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); } + 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) {} } } + private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? { //TODO: Auto get all package types? return when(packageName) { @@ -292,8 +397,14 @@ 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 @@ -327,14 +438,23 @@ 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) { - if(executeEx.scriptingError?.context?.containsKey("plugin_type") == true) { - val pluginType = executeEx.scriptingError.context["plugin_type"].toString(); + val obj = executeEx.scriptingError?.context + if(obj != null && obj.containsKey("plugin_type") == true) { + val pluginType = obj["plugin_type"].toString(); //Captcha if (pluginType == "CaptchaRequiredException") { throw ScriptCaptchaRequiredException(config, - executeEx.scriptingError.context["url"]?.toString(), - executeEx.scriptingError.context["body"]?.toString(), + 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, executeEx.scriptingError?.stack, codeStripped); } @@ -348,6 +468,41 @@ 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) { @@ -398,9 +553,4 @@ 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/exceptions/NoInternetException.kt b/app/src/main/java/com/futo/platformplayer/engine/exceptions/NoInternetException.kt index bce39025..4011b0a8 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,6 +2,7 @@ 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) { @@ -11,6 +12,7 @@ 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 ef1ca13f..48c3142f 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,6 +2,7 @@ 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) { @@ -11,6 +12,7 @@ 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 8aa7f2c8..6bbf536b 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,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ 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 2db245d3..26b2eebc 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,12 +2,14 @@ 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 6581ec25..d8eda509 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,6 +2,7 @@ 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) { @@ -11,6 +12,7 @@ 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 cf038a23..de777a9f 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,6 +2,7 @@ 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) { @@ -11,6 +12,7 @@ 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 28b9b0e9..8bfd49d6 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,6 +2,7 @@ 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) { @@ -11,6 +12,7 @@ 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 dd2aaf7a..943b4fe9 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,12 +2,14 @@ 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 423d5786..4acf0c55 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,12 +2,14 @@ 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 new file mode 100644 index 00000000..6c792a32 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/exceptions/ScriptReloadRequiredException.kt @@ -0,0 +1,22 @@ +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 6f883854..17d02073 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,11 +2,13 @@ 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 5d331b8b..feb47c35 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,12 +2,14 @@ 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 4e861b72..fd30af6f 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 d2d7cf04..db44c1fc 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,6 +4,7 @@ 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 @@ -26,6 +27,7 @@ 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 @@ -78,6 +80,15 @@ class PackageBridge : V8Package { return "android"; } + @V8Property + fun supportedFeatures(): Array { + return arrayOf( + "ReloadRequiredException", + "HttpBatchClient", + "Async" + ); + } + @V8Property fun supportedContent(): Array { return arrayOf( @@ -101,45 +112,54 @@ class PackageBridge : V8Package { } var timeoutCounter = 0; - var timeoutMap = HashSet(); + var timeoutMap = ConcurrentHashMap(); @V8Function fun setTimeout(func: V8ValueFunction, timeout: Long): Int { val id = timeoutCounter++; - val funcClone = func.toClone() StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { delay(timeout); - synchronized(timeoutMap) { - if(!timeoutMap.contains(id)) { - JavetResourceUtils.safeClose(funcClone); - return@launch; + if (_plugin.isStopped) + return@launch; + if (!timeoutMap.containsKey(id)) { + _plugin.busy { + if (!_plugin.isStopped) + JavetResourceUtils.safeClose(funcClone); } - timeoutMap.remove(id); + return@launch; } + timeoutMap.remove(id); try { - _plugin.whenNotBusy { - funcClone.callVoid(null, arrayOf()); + 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"); } - } - catch(ex: Throwable) { + } catch (ex: Throwable) { Logger.e(TAG, "Failed timeout callback", ex); - } - finally { - JavetResourceUtils.safeClose(funcClone); + } finally { + _plugin.busy { + if (!_plugin.isStopped) + JavetResourceUtils.safeClose(funcClone); + } + //_plugin.whenNotBusy { + //} } }; - synchronized(timeoutMap) { - timeoutMap.add(id); - } + timeoutMap.put(id, true); return id; } @V8Function fun clearTimeout(id: Int) { - synchronized(timeoutMap) { - if(timeoutMap.contains(id)) - timeoutMap.remove(id); - } + if (timeoutMap.containsKey(id)) + timeoutMap.remove(id); + } + @V8Function + fun sleep(length: Int) { + Thread.sleep(length.toLong()); } @V8Function @@ -147,7 +167,7 @@ class PackageBridge : V8Package { Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}"); StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { try { - UIDialogs.toast(str); + UIDialogs.appToast(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 900eb6f0..82edb023 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 @@ -44,6 +44,17 @@ class PackageHttp: V8Package { 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; @@ -112,6 +123,8 @@ class PackageHttp: V8Package { _plugin.registerHttpClient(httpClient); val client = PackageHttpClient(this, httpClient); + _clients.put(client.clientId() ?: "", client); + return client; } @V8Function @@ -246,18 +259,18 @@ class PackageHttp: V8Package { @V8Function fun request(method: String, url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder { - return clientRequest(_package.getDefaultClient(useAuth), method, url, headers); + return clientRequest(_package.getDefaultClient(useAuth).clientId(), method, url, headers); } @V8Function fun requestWithBody(method: String, url: String, body:String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder { - return clientRequestWithBody(_package.getDefaultClient(useAuth), method, url, body, headers); + return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers); } @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder - = clientGET(_package.getDefaultClient(useAuth), url, headers); + = clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers); @V8Function fun POST(url: String, body: String, headers: MutableMap = HashMap(), useAuth: Boolean = false) : BatchBuilder - = clientPOST(_package.getDefaultClient(useAuth), url, body, headers); + = clientPOST(_package.getDefaultClient(useAuth).clientId(), url, body, headers); @V8Function fun DUMMY(): BatchBuilder { @@ -268,21 +281,21 @@ class PackageHttp: V8Package { //Client-specific @V8Function - fun clientRequest(client: PackageHttpClient, method: String, url: String, headers: MutableMap = HashMap()) : BatchBuilder { - _reqs.add(Pair(client, RequestDescriptor(method, url, headers))); + fun clientRequest(clientId: String?, method: String, url: String, headers: MutableMap = HashMap()) : BatchBuilder { + _reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers))); return BatchBuilder(_package, _reqs); } @V8Function - fun clientRequestWithBody(client: PackageHttpClient, method: String, url: String, body:String, headers: MutableMap = HashMap()) : BatchBuilder { - _reqs.add(Pair(client, RequestDescriptor(method, url, headers, body))); + 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))); return BatchBuilder(_package, _reqs); } @V8Function - fun clientGET(client: PackageHttpClient, url: String, headers: MutableMap = HashMap()) : BatchBuilder - = clientRequest(client, "GET", url, headers); + fun clientGET(clientId: String?, url: String, headers: MutableMap = HashMap()) : BatchBuilder + = clientRequest(clientId, "GET", url, headers); @V8Function - fun clientPOST(client: PackageHttpClient, url: String, body: String, headers: MutableMap = HashMap()) : BatchBuilder - = clientRequestWithBody(client, "POST", url, body, headers); + fun clientPOST(clientId: String?, url: String, body: String, headers: MutableMap = HashMap()) : BatchBuilder + = clientRequestWithBody(clientId, "POST", url, body, headers); //Finalizer @@ -321,6 +334,7 @@ class PackageHttp: V8Package { @Transient private val _clientId: String?; + @V8Property fun clientId(): String? { return _clientId; @@ -333,6 +347,17 @@ 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) @@ -429,8 +454,23 @@ class PackageHttp: V8Package { }; } @V8Function - fun POST(url: String, body: String, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse - = POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) + 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 { applyDefaultHeaders(headers); return logExceptions { @@ -452,9 +492,6 @@ class PackageHttp: V8Package { } }; } - @V8Function - fun POST(url: String, body: ByteArray, headers: MutableMap = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse - = POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING) fun POSTInternal(url: String, body: ByteArray, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { @@ -630,7 +667,9 @@ class PackageHttp: V8Package { _isOpen = true; if(hasOpen && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("open", arrayOf()); + _package._plugin.busy { + _listeners?.invokeVoid("open", arrayOf()); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex); @@ -640,7 +679,9 @@ class PackageHttp: V8Package { override fun message(msg: String) { if(hasMessage && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("message", msg); + _package._plugin.busy { + _listeners?.invokeVoid("message", msg); + } } catch(ex: Throwable) {} } @@ -649,7 +690,9 @@ class PackageHttp: V8Package { if(hasClosing && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("closing", code, reason); + _package._plugin.busy { + _listeners?.invokeVoid("closing", code, reason); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex); @@ -660,7 +703,9 @@ class PackageHttp: V8Package { _isOpen = false; if(hasClosed && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("closed", code, reason); + _package._plugin.busy { + _listeners?.invokeVoid("closed", code, reason); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); @@ -676,7 +721,9 @@ class PackageHttp: V8Package { Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); if(hasFailure && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("failure", exception.message); + _package._plugin.busy { + _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/mainactivity/MainActivityFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/MainActivityFragment.kt index 6244004b..c9b89d82 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) + (activity as MainActivity).navigate(frag, parameter, withHistory, false) 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 d43a67f6..b69082ae 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 @@ -331,7 +331,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 = false) })) + 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) })) } //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 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 index 989a19e1..d052e0f1 100644 --- 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 @@ -778,6 +778,8 @@ class ArticleDetailFragment : MainFragment { 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) { 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 e79e0c13..eaa84e3e 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,6 +1,8 @@ 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 @@ -66,8 +68,7 @@ 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 { @@ -115,11 +116,14 @@ 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) { @@ -127,17 +131,18 @@ class BuyFragment : MainFragment() { val activationResult = StatePayment.instance.setPaymentLicense(licenseText); withContext(Dispatchers.Main) { - 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)); + 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) } } } @@ -158,5 +163,6 @@ 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 72dd3a58..91e6aaa3 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 @@ -172,7 +172,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) + _overlayLoadingSpinner = findViewById(R.id.channel_loader_frag) _overlayContainer = findViewById(R.id.overlay_container) _buttonSubscribe.onSubscribed.subscribe { UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer) @@ -234,6 +234,8 @@ 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 -> 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 cc528a2b..fbb85dac 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 @@ -86,6 +86,8 @@ abstract class ContentFeedView : FeedView("creators_ordering") + override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val view = inflater.inflate(R.layout.fragment_creators, container, false); @@ -44,7 +48,7 @@ class CreatorsFragment : MainFragment() { _buttonClearSearch?.visibility = View.INVISIBLE; } - val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs -> + val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription), _ordering?.value?.toIntOrNull() ?: 5) { subs -> _textMeta?.let { it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}"; } @@ -61,6 +65,7 @@ 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 536700d8..0e429430 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 @@ -150,7 +150,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"); + val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { @@ -160,6 +160,8 @@ 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() @@ -257,6 +259,8 @@ 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/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index c56585b0..1e90e36e 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,7 +10,6 @@ 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 @@ -165,14 +164,24 @@ class PlaylistFragment : MainFragment() { }; } - private fun copyPlaylist(playlist: Playlist) { + private fun savePlaylist(playlist: Playlist) { StatePlaylists.instance.playlistStore.save(playlist) - _fragment.topBar?.assume()?.setMenuItems( - arrayListOf() - ) 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") + } + fun onShown(parameter: Any?) { _taskLoadPlaylist.cancel() @@ -188,12 +197,14 @@ class PlaylistFragment : MainFragment() { setButtonExportVisible(false) setButtonEditVisible(true) - if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { - _fragment.topBar?.assume() - ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + _fragment.topBar?.assume() + ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { + if (StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { copyPlaylist(parameter) - })) - } + } else { + savePlaylist(parameter) + } + })) } else { setName(null) setVideos(null, false) @@ -259,7 +270,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", { - copyPlaylist(playlist) + savePlaylist(playlist) download() }) return @@ -292,7 +303,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", { - copyPlaylist(playlist) + savePlaylist(playlist) onEditClick() }) return 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 83e39a88..4a3fdf91 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>({StateApp.instance.scope}, { withRefresh -> + private val _taskGetPager = TaskHandler>({fragment.lifecycleScope}, { 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(StateApp.instance.scope, withRefresh, group); + val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(fragment.lifecycleScope, 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/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index fd3319f0..3404de15 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.column_width_dp) * 2 + return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.smallest_width_dp) } private fun isAutoRotateEnabled(): Boolean { @@ -467,10 +467,14 @@ class VideoDetailFragment() : MainFragment() { activity?.enterPictureInPictureMode(params); } fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, isStop: Boolean, newConfig: Configuration) { - if (isInPictureInPictureMode) { - _viewDetail?.startPictureInPicture(); - } else if (isInPictureInPicture) { - leavePictureInPictureMode(isStop); + try { + if (isInPictureInPictureMode) { + _viewDetail?.startPictureInPicture(); + } else if (isInPictureInPicture) { + leavePictureInPictureMode(isStop); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle onPictureInPictureModeChanged", e) } } fun leavePictureInPictureMode(isStop: Boolean) { @@ -623,11 +627,6 @@ 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 c5d647ff..a1ddc394 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,6 +2,8 @@ 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.Context import android.content.Intent import android.content.res.Configuration @@ -91,6 +93,7 @@ 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 @@ -172,6 +175,7 @@ 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 @@ -408,6 +412,14 @@ 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); @@ -597,6 +609,10 @@ class VideoDetailView : ConstraintLayout { } } + _player.onReloadRequired.subscribe { + fetchVideo(); + } + _player.onPlayChanged.subscribe { if (StateCasting.instance.activeDevice == null) { handlePlayChanged(it); @@ -1115,7 +1131,7 @@ class VideoDetailView : ConstraintLayout { when (Settings.instance.playback.backgroundPlay) { 0 -> handlePause(); 1 -> { - if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio) + if(!(video?.isLive ?: false)) _player.switchToAudioMode(); StatePlayer.instance.startOrUpdateMediaSession(context, video); } @@ -1399,8 +1415,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() @@ -1457,6 +1473,32 @@ 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 } @@ -1897,8 +1939,8 @@ class VideoDetailView : ConstraintLayout { } updateQualityFormatsOverlay( - videoTrackFormats.distinctBy { it.height }.sortedBy { it.height }, - audioTrackFormats.distinctBy { it.bitrate }.sortedBy { it.bitrate }); + videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height }, + audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate }); } } @@ -2149,23 +2191,40 @@ 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, - if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, + qualityPlaybackSpeedTitle, 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()); + 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)); 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 } - ad.changeSpeed(v.toDouble()) - setSelected(v); + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); + ad.changeSpeed(newPlaybackSpeed) + setSelected(playbackSpeedString); } else { - _player.setPlaybackRate(v.toFloat()); - setSelected(v); + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); + _player.setPlaybackRate(playbackSpeedString.toFloat()); + setSelected(playbackSpeedString); } }; } else null, @@ -2438,7 +2497,9 @@ class VideoDetailView : ConstraintLayout { val url = _url; if (!url.isNullOrBlank()) { - setLoading(true); + fragment.lifecycleScope.launch(Dispatchers.Main) { + setLoading(true); + } _taskLoadVideo.run(url); } } @@ -2522,7 +2583,9 @@ class VideoDetailView : ConstraintLayout { } fun saveBrightness() { - _player.gestureControl.saveBrightness() + if (Settings.instance.gestureControls.useSystemBrightness) { + _player.gestureControl.saveBrightness() + } } fun restoreBrightness() { _player.gestureControl.restoreBrightness() @@ -2702,6 +2765,8 @@ 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) { @@ -2969,6 +3034,11 @@ class VideoDetailView : ConstraintLayout { return@TaskHandler result; }) .success { setVideoDetails(it, true) } + .exception { + StatePlatform.instance.handleReloadRequired(it, { + fetchVideo(); + }); + } .exception { Logger.w(TAG, "exception", it) 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 d7b1035f..758929d5 100644 --- a/app/src/main/java/com/futo/platformplayer/models/Playlist.kt +++ b/app/src/main/java/com/futo/platformplayer/models/Playlist.kt @@ -5,6 +5,7 @@ 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 @@ -35,11 +36,15 @@ 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/PlatformLinkMovementMethod.kt b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt index d524b7cf..76652236 100644 --- a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt +++ b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt @@ -8,11 +8,14 @@ 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 kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMethod() { @@ -60,31 +63,39 @@ 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)) { - runBlocking { - for (link in pressedLinks!!) { - Logger.i(TAG) { "Link clicked '${link.url}'." } + for (link in pressedLinks!!) { + Logger.i(TAG) { "Link clicked '${link.url}'." } - 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() - } + 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) { + if (time_s != -1L) { + withContext(Dispatchers.Main) { MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) - continue } + return@launch } } - _context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + + withContext(Dispatchers.Main) { + c.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/services/DownloadService.kt b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt index b39b4592..5ab75011 100644 --- a/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/DownloadService.kt @@ -62,7 +62,7 @@ class DownloadService : Service() { Logger.i(TAG, "onStartCommand"); synchronized(this) { if(_started) - return START_STICKY; + return START_NOT_STICKY; if(!FragmentedStorage.isInitialized) { Logger.i(TAG, "Attempted to start DownloadService without initialized files"); 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 e2155e8b..9757f005 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -156,6 +156,8 @@ class StateApp { return thisContext; } + private var _mainId: String? = null; + //Files private var _tempDirectory: File? = null; private var _cacheDirectory: File? = null; @@ -295,9 +297,12 @@ class StateApp { } //Lifecycle - fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) { + fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null, mainId: String? = null) { + _mainId = mainId; _context = context; _scope = coroutineScope + Logger.w(TAG, "Scope initialized ${(coroutineScope != null)}\n ${Log.getStackTraceString(Throwable())}") + } fun initializeFiles(force: Boolean = false) { @@ -719,7 +724,9 @@ class StateApp { migrateStores(context, managedStores, index + 1); } - fun mainAppDestroyed(context: Context) { + fun mainAppDestroyed(context: Context, mainId: String? = null) { + if (mainId != null && (_mainId != mainId || _mainId == null)) + return Logger.i(TAG, "App ended"); _receiverBecomingNoisy?.let { _receiverBecomingNoisy = null; @@ -743,7 +750,8 @@ class StateApp { fun dispose(){ _context = null; - _scope = null; + // _scope = null; + Logger.w(TAG, "StateApp disposed: ${Log.getStackTraceString(Throwable())}") } private val _connectivityEvents = object : ConnectivityManager.NetworkCallback() { 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 3047731d..97ab82c1 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -131,8 +131,13 @@ class StateHistory { fun getHistoryPager(): IPager { return _historyDBStore.getObjectPager(); } - fun getHistorySearchPager(query: String): IPager { - return _historyDBStore.queryLikeObjectPager(DBHistory.Index::name, "%${query}%", 10); + 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 getHistoryIndexByUrl(url: String): DBHistory.Index? { return historyIndex[url]; 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 a6e07a7b..d2cd0250 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -2,6 +2,7 @@ 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 @@ -38,6 +39,7 @@ 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 @@ -316,7 +318,18 @@ class StatePlatform { _platformOrderPersistent.save(); } - suspend fun reloadClient(context: Context, id: String) : JSClient? { + 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? { return withContext(Dispatchers.IO) { val client = getClient(id); if (client !is JSClient) @@ -347,10 +360,27 @@ 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 }; @@ -361,6 +391,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) { synchronized(_clientsLock) { val removed = _enabledClients.toMutableList(); @@ -385,6 +418,7 @@ class StatePlatform { onSourceDisabled.emit(oldClient); } } + afterLoad?.invoke(); }; } @@ -976,7 +1010,7 @@ class StatePlatform { return EmptyPager(); if(!StateApp.instance.privateMode) - return client.fromPool(_mainClientPool).getComments(url); + return client.fromPool(_pagerClientPool).getComments(url); else return client.fromPool(_privateClientPool).getComments(url); } 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 c20375f2..cbe1c518 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -3,7 +3,6 @@ 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 @@ -21,7 +20,6 @@ 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 @@ -30,15 +28,12 @@ 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 @@ -178,31 +173,30 @@ class StatePlaylists { StateDownloads.instance.checkForOutdatedPlaylistVideos(VideoDownload.GROUP_WATCHLATER); } } - fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false, orderPosition: Int = -1): Boolean { - var wasNew = false; + fun addToWatchLater(video: SerializedPlatformVideo, isUserInteraction: Boolean = false): Boolean { synchronized(_watchlistStore) { - 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()); + if (_watchlistStore.hasItem { it.url == video.url }) { + return false } - _watchlistOrderStore.save(); + + _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() } onWatchLaterChanged.emit(); - if(isUserInteraction) { + if (isUserInteraction) { val now = OffsetDateTime.now(); _watchLaterAdds.setAndSave(video.url, now); broadcastWatchLaterAddition(video, now); } StateDownloads.instance.checkForOutdatedPlaylists(); - return wasNew; + return true; } fun getLastPlayedPlaylist() : Playlist? { @@ -423,17 +417,25 @@ class StatePlaylists { class PlaylistBackup: ReconstructStore() { override fun toReconstruction(obj: Playlist): String { val items = ArrayList(); - items.add(obj.name); + items.add(obj.name + ":::" + obj.id); 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}"); } - val name = items[0]; + 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 videos = items.drop(1).filter { it.isNotEmpty() }.map { try { val videoUrl = it; @@ -465,7 +467,7 @@ class StatePlaylists { throw ReconstructionException(name, "${name}:[${it}] ${ex.message}", ex); } }.filter { it != null }.map { it!! } - return Playlist(id, name, videos); + return Playlist(idToUse, name, videos); } } } \ No newline at end of file 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 1d1acff6..60026ea6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -329,8 +329,19 @@ class StateSubscriptions { } } - if(StateSubscriptionGroups.instance.hasSubscriptionGroup(sub.channel.url)) - getSubscriptionOtherOrCreate(sub.channel.url, sub.channel.name, sub.channel.thumbnail); + 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 + ); */ + } } 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 fd08165c..25cff055 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -78,22 +78,32 @@ class StateSync { onAuthorized = { sess, isNewlyAuthorized, isNewSession -> if (isNewSession) { deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) - StateApp.instance.scope.launch(Dispatchers.IO) { checkForSync(sess) } + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + checkForSync(sess) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to check for sync.", e) + } + } } } onUnauthorized = { sess -> StateApp.instance.scope.launch(Dispatchers.Main) { - UIDialogs.showConfirmationDialog( - context, - "Device Unauthorized: ${sess.displayName}", - action = { - Logger.i(TAG, "${sess.remotePublicKey} unauthorized received") - removeAuthorizedDevice(sess.remotePublicKey) - deviceRemoved.emit(sess.remotePublicKey) - }, - cancelAction = {} - ) + 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) + } } } @@ -118,30 +128,38 @@ class StateSync { if (scope != null && activity != null) { scope.launch(Dispatchers.Main) { - UIDialogs.showConfirmationDialog(activity, "Allow connection from $remotePublicKey?", - action = { - scope.launch(Dispatchers.IO) { - try { - callback(true) - Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") + 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" + ) - activity.finish() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize", e) + 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) + } } } - }, - 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) - } - } - } - ) + ) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show authorized dialog.", e) + } } } else { callback(false) @@ -224,6 +242,11 @@ class StateSync { } } + private val _lockSubscriptions = Any(); + private val _lockSubscriptionGroups = Any(); + private val _lockPlaylists = Any(); + private val _lockWatchlater = Any(); + private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { val remotePublicKey = session.remotePublicKey when (subOpcode) { @@ -307,7 +330,9 @@ class StateSync { data.get(dataBody); val json = String(dataBody, Charsets.UTF_8); val subPackage = Serializer.json.decodeFromString(json); - handleSyncSubscriptionPackage(session, subPackage); + synchronized(_lockSubscriptions) { + handleSyncSubscriptionPackage(session, subPackage); + } if(subPackage.subscriptions.size > 0) { val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; @@ -327,21 +352,23 @@ class StateSync { val pack = Serializer.json.decodeFromString(json); var lastSubgroupChange = OffsetDateTime.MIN; - for(group in pack.groups){ - if(group.lastChange > lastSubgroupChange) - lastSubgroupChange = group.lastChange; + synchronized(_lockSubscriptionGroups) { + for(group in pack.groups){ + if(group.lastChange > lastSubgroupChange) + lastSubgroupChange = group.lastChange; - val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + 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); + 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) { @@ -358,18 +385,20 @@ class StateSync { 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); + 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); + 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) { @@ -390,14 +419,16 @@ class StateSync { 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)) (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); + 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) { 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 2e493eef..6b9ed65f 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,10 +274,17 @@ 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 @@ -336,6 +343,13 @@ 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({ @@ -344,6 +358,14 @@ 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 b72e840c..2740ca8b 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 index e17b6309..4e9b2653 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -75,7 +75,7 @@ class ChannelRelayed( private var handshakeState: HandshakeState? = if (initiator) { HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply { localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) - remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0) + remotePublicKey.setPublicKey(publicKey.base64ToByteArray(), 0) } } else { HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply { @@ -177,7 +177,7 @@ class ChannelRelayed( this.remoteVersion = remoteVersion val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength) handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + this.remotePublicKey = remoteKeyBytes.toBase64() handshakeState?.destroy() handshakeState = null this.transport = transport @@ -316,7 +316,7 @@ class ChannelRelayed( val channelMessage = ByteArray(1024) val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0) - val publicKeyBytes = Base64.getDecoder().decode(publicKey) + val publicKeyBytes = publicKey.base64ToByteArray() if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") val (pairingMessageLength, pairingMessage) = if (pairingCode != null) { 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 index 5e5ad7de..23ea684b 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt @@ -13,6 +13,7 @@ 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 @@ -98,7 +99,7 @@ class SyncService( override fun onServiceLost(service: NsdServiceInfo) { Log.e(TAG, "service lost: $service") val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return - val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + val pkey = urlSafePkey.base64UrlToByteArray().toBase64() synchronized(_mdnsCache) { _mdnsCache.remove(pkey) } @@ -128,7 +129,7 @@ class SyncService( } val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return - val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + val pkey = urlSafePkey.base64UrlToByteArray().toBase64() val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) synchronized(_mdnsCache) { @@ -157,7 +158,7 @@ class SyncService( override fun onServiceLost() { Log.v(TAG, "onServiceLost: $service") val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return - val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + val pkey = urlSafePkey.base64UrlToByteArray().toBase64() synchronized(_mdnsCache) { _mdnsCache.remove(pkey) } @@ -327,7 +328,7 @@ class SyncService( val now = System.currentTimeMillis() synchronized(_mdnsCache) { for ((pkey, info) in _mdnsCache) { - if (!database.isAuthorized(pkey) || isConnected(pkey)) continue + if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue val last = synchronized(_lastConnectTimesMdns) { _lastConnectTimesMdns[pkey] ?: 0L @@ -359,8 +360,8 @@ class SyncService( while (_started) { val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf() val addressesToConnect = authorizedDevices.mapNotNull { - val connected = isConnected(it) - if (connected) { + val connectedDirectly = getLinkType(it) == LinkType.Direct + if (connectedDirectly) { return@mapNotNull null } @@ -467,8 +468,13 @@ class SyncService( while (_started && !socketClosed) { val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices() - ?.filter { !isConnected(it) }?.toTypedArray() - ?: arrayOf() + ?.filter { + if (Settings.instance.synchronization.connectLocalDirectThroughRelay) { + getLinkType(it) != LinkType.Direct + } else { + !isConnected(it) + } + }?.toTypedArray() ?: arrayOf() relaySession.publishConnectionInformation( unconnectedAuthorizedDevices, settings.listenerPort, @@ -496,7 +502,7 @@ class SyncService( val potentialLocalAddresses = connectionInfo.ipv4Addresses .filter { it != connectionInfo.remoteIp } - if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { + if (getLinkType(targetKey) != LinkType.Direct && connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { Thread { try { Log.v( @@ -528,7 +534,7 @@ class SyncService( // TODO: Implement hole punching if needed } - if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { + if (!isConnected(targetKey) && connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { try { Logger.v( TAG, @@ -740,6 +746,7 @@ class SyncService( ) } + 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] } @@ -796,8 +803,12 @@ class SyncService( _relaySession = null _serverSocket?.close() _serverSocket = null + + synchronized(_sessions) { + _sessions.values.toList() + }.forEach { it.close() } + synchronized(_sessions) { - _sessions.values.forEach { it.close() } _sessions.clear() } } 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 cb67f934..35437a7b 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 @@ -2,6 +2,7 @@ package com.futo.platformplayer.sync.internal import android.os.Build 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 @@ -123,7 +124,7 @@ class SyncSocketSession { val localPublicKey = ByteArray(localKeyPair.publicKeyLength) localKeyPair.getPublicKey(localPublicKey, 0) - _localPublicKey = Base64.getEncoder().encodeToString(localPublicKey) + _localPublicKey = localPublicKey.toBase64() } fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { @@ -253,14 +254,14 @@ class SyncSocketSession { val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR) initiator.localKeyPair.copyFrom(_localKeyPair) - initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) + 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(Base64.getDecoder().decode(remotePublicKey), 0) + pairingHandshake.remotePublicKey.setPublicKey(remotePublicKey.base64ToByteArray(), 0) pairingHandshake.start() val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) val pairingBuffer = ByteArray(512) @@ -299,7 +300,7 @@ class SyncSocketSession { _cipherStatePair = initiator.split() val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64() + _remotePublicKey = remoteKeyBytes.toBase64() } private fun handshakeAsResponder(): Boolean { @@ -516,7 +517,7 @@ class SyncSocketSession { return } val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } - val publicKey = Base64.getEncoder().encodeToString(publicKeyBytes) + val publicKey = publicKeyBytes.toBase64() val pairingCode = if (pairingMessageLength > 0) { val pairingProtocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { localKeyPair.copyFrom(_localKeyPair) @@ -671,7 +672,7 @@ class SyncSocketSession { val records = mutableMapOf>() repeat(recordCount) { val publisherBytes = ByteArray(32).also { data.get(it) } - val publisher = Base64.getEncoder().encodeToString(publisherBytes) + val publisher = publisherBytes.toBase64() val blobLength = data.int val encryptedBlob = ByteArray(blobLength).also { data.get(it) } val timestamp = data.long @@ -712,7 +713,7 @@ class SyncSocketSession { val numResponses = data.get().toInt() val result = mutableMapOf() repeat(numResponses) { - val publicKey = Base64.getEncoder().encodeToString(ByteArray(32).also { data.get(it) }) + val publicKey = ByteArray(32).also { data.get(it) }.toBase64() val status = data.get().toInt() if (status == 0) { val infoSize = data.int @@ -813,7 +814,7 @@ class SyncSocketSession { return } val decryptedPayload = channel.decrypt(data) - val errorCode = SyncErrorCode.entries.find { it.value == decryptedPayload.int } ?: SyncErrorCode.ConnectionClosed + val errorCode = decryptedPayload.int Logger.e(TAG, "Received relayed error (errorCode = $errorCode) on connectionId $connectionId, closing") channel.close() _channels.remove(connectionId) @@ -824,7 +825,7 @@ class SyncSocketSession { return } val connectionId = data.long - val errorCode = SyncErrorCode.entries.find { it.value == data.int } ?: SyncErrorCode.ConnectionClosed + val errorCode = data.int val channel = _channels[connectionId] ?: run { Logger.e(TAG, "Received error code $errorCode for non-existent channel with connectionId $connectionId") return @@ -994,7 +995,7 @@ class SyncSocketSession { val deferred = CompletableDeferred() _pendingConnectionInfoRequests[requestId] = deferred try { - val publicKeyBytes = Base64.getDecoder().decode(publicKey) + 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) @@ -1017,7 +1018,7 @@ class SyncSocketSession { packet.putInt(requestId) packet.put(publicKeys.size.toByte()) for (pk in publicKeys) { - val pkBytes = Base64.getDecoder().decode(pk) + val pkBytes = pk.base64ToByteArray() if (pkBytes.size != 32) throw IllegalArgumentException("Invalid public key length for $pk") packet.put(pkBytes) } @@ -1078,20 +1079,9 @@ class SyncSocketSession { ) { if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255") - val ipv4Addresses = mutableListOf() - val ipv6Addresses = mutableListOf() - for (nic in NetworkInterface.getNetworkInterfaces()) { - if (nic.isUp) { - for (addr in nic.inetAddresses) { - if (!addr.isLoopbackAddress) { - when (addr) { - is Inet4Address -> ipv4Addresses.add(addr.hostAddress) - is Inet6Address -> ipv6Addresses.add(addr.hostAddress) - } - } - } - } - } + val candidateAddresses = findCandidateAddresses() + val ipv4Addresses = candidateAddresses.filterIsInstance() + val ipv6Addresses = candidateAddresses.filterIsInstance() val deviceName = getDeviceName() val nameBytes = getLimitedUtf8Bytes(deviceName, 255) @@ -1103,12 +1093,12 @@ class SyncSocketSession { data.put(nameBytes) data.put(ipv4Addresses.size.toByte()) for (addr in ipv4Addresses) { - val addrBytes = InetAddress.getByName(addr).address + val addrBytes = addr.address data.put(addrBytes) } data.put(ipv6Addresses.size.toByte()) for (addr in ipv6Addresses) { - val addrBytes = InetAddress.getByName(addr).address + val addrBytes = addr.address data.put(addrBytes) } data.put(if (allowLocalDirect) 1 else 0) @@ -1125,7 +1115,7 @@ class SyncSocketSession { publishBytes.put(authorizedKeys.size.toByte()) for (key in authorizedKeys) { - val publicKeyBytes = Base64.getDecoder().decode(key) + val publicKeyBytes = key.base64ToByteArray() if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) @@ -1183,7 +1173,7 @@ class SyncSocketSession { packet.put(consumerPublicKeys.size.toByte()) for (consumer in consumerPublicKeys) { - val consumerBytes = Base64.getDecoder().decode(consumer) + 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 { @@ -1222,7 +1212,7 @@ class SyncSocketSession { val deferred = CompletableDeferred?>() _pendingGetRecordRequests[requestId] = deferred try { - val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + 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) @@ -1253,7 +1243,7 @@ class SyncSocketSession { packet.put(keyBytes) packet.put(publisherPublicKeys.size.toByte()) for (publisher in publisherPublicKeys) { - val bytes = Base64.getDecoder().decode(publisher) + val bytes = publisher.base64ToByteArray() if (bytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") packet.put(bytes) } @@ -1272,9 +1262,9 @@ class SyncSocketSession { val deferred = CompletableDeferred() _pendingDeleteRequests[requestId] = deferred try { - val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + val publisherBytes = publisherPublicKey.base64ToByteArray() if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") - val consumerBytes = Base64.getDecoder().decode(consumerPublicKey) + 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) @@ -1301,9 +1291,9 @@ class SyncSocketSession { val deferred = CompletableDeferred>>() _pendingListKeysRequests[requestId] = deferred try { - val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + val publisherBytes = publisherPublicKey.base64ToByteArray() if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") - val consumerBytes = Base64.getDecoder().decode(consumerPublicKey) + 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) 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 2fb9dd32..a31dd14b 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 @@ -43,7 +43,6 @@ 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); 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 33783e67..fb28c2e4 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,10 +31,11 @@ class SubscriptionAdapter : RecyclerView.Adapter { updateDataset(); } - constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List)->Unit)? = null) : super() { + constructor(inflater: LayoutInflater, confirmationMessage: String, sortByDefault: Int, 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() } 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 42cef197..469ce702 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 @@ -51,7 +51,6 @@ 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); @@ -95,7 +94,13 @@ class VideoListEditorViewHolder : ViewHolder { .into(_imageThumbnail); _textName.text = v.name; _textAuthor.text = v.author.name; - _textVideoDuration.text = v.duration.toHumanTime(false); + + 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(); 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 001779f1..e2ba8e5e 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,7 +29,6 @@ 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/PreviewVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt index bcabda4f..898b7e14 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt @@ -204,8 +204,14 @@ open class PreviewVideoView : LinearLayout { .into(_imageVideo); }; - if(!isPlanned) - _textVideoDuration.text = video.duration.toHumanTime(false); + if(!isPlanned) { + if(video.duration > 0) { + _textVideoDuration.text = video.duration.toHumanTime(false); + _textVideoDuration.visibility = View.VISIBLE; + } + else + _textVideoDuration.visibility = View.GONE; + } else _textVideoDuration.text = context.getString(R.string.planned); 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 f90102b3..10a88341 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,6 +39,9 @@ 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 { @@ -79,6 +82,9 @@ 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 @@ -92,6 +98,11 @@ 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; @@ -103,6 +114,8 @@ class GestureControlView : LinearLayout { val onZoom = Event1(); val onSoundAdjusted = Event1(); val onToggleFullscreen = Event0(); + val onSpeedHoldStart = Event0() + val onSpeedHoldEnd = Event0() var fullScreenGestureEnabled = true @@ -124,6 +137,9 @@ 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 { @@ -216,7 +232,21 @@ class GestureControlView : LinearLayout { return true; } - override fun onLongPress(p0: MotionEvent) = Unit + 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() + } + } }); _gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener { @@ -301,6 +331,17 @@ 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; @@ -309,6 +350,12 @@ 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) { 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 cf599176..6e3a8860 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,12 +8,16 @@ 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.runBlocking +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { private var _lastTouchedLinks: Array? = null @@ -77,12 +81,14 @@ 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)) { - 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 + 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 + } if (timestampRegex.matches(link.url)) { val tokens = link.url.split(':') var time_s = -1L @@ -92,13 +98,21 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { tokens[1].toLong() * 60 + tokens[2].toLong() } + if (time_s != -1L) { - MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) - continue + withContext(Dispatchers.Main) { + MediaControlReceiver.onSeekToReceived.emit(time_s * 1000) + } + return@launch } } - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) - } else { + + withContext(Dispatchers.Main) { + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + } + } + } else { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { 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 fff853a8..fe941fea 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,6 +18,7 @@ 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 @@ -58,6 +59,8 @@ class CastView : ConstraintLayout { 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(); @@ -87,6 +90,20 @@ class CastView : ConstraintLayout { _gestureControlView = findViewById(R.id.gesture_control); _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); 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 03af1ee8..a7e142f8 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,6 +41,8 @@ class ButtonField : BigButton, IField { return null; }; + override var isAdvanced: Boolean = false; + //private val _title : TextView; //private val _subtitle : TextView; @@ -89,7 +91,7 @@ class ButtonField : BigButton, IField { return this; } - override fun fromField(obj : Any, field : Field, formField: FormField?) : ButtonField { + override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : 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 590335d3..4f01d4d5 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,6 +40,8 @@ class DropdownField : TableRow, IField { override var reference: Any? = null; + override var isAdvanced: Boolean = false; + override val onChanged = Event3(); override val value: Any? get() = _selected; @@ -112,7 +114,7 @@ class DropdownField : TableRow, IField { return this; } - override fun fromField(obj: Any, field: Field, formField: FormField?) : DropdownField { + override fun fromField(obj: Any, field: Field, formField: FormField?, advanced: Boolean) : DropdownField { this._field = field; this._obj = obj; @@ -133,6 +135,9 @@ 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 9439d46a..b11ce641 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,6 +4,10 @@ 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) @@ -22,6 +26,8 @@ interface IField { val obj : Any?; val field : Field?; + val isAdvanced: Boolean; + val value: Any?; val onChanged : Event3; @@ -29,7 +35,7 @@ interface IField { val searchContent: String?; - fun fromField(obj : Any, field : Field, formField: FormField? = null) : IField; + fun fromField(obj : Any, field : Field, formField: FormField? = null, advanced: Boolean = false) : 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 1262e345..566a5024 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,6 +37,8 @@ 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); @@ -58,11 +60,17 @@ class FieldForm : LinearLayout { if(field is GroupField) { updateSettingsVisibility(field); } else if(field is View && field.descriptor != null) { - 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; + 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; + } } } } @@ -71,6 +79,10 @@ class FieldForm : LinearLayout { } } + fun setShowAdvancedSettings(show: Boolean) { + _showAdvancedSettings = show; + updateSettingsVisibility(); + } fun setSearchQuery(query: String) { _editSearch.setText(query); updateSettingsVisibility(); @@ -92,13 +104,22 @@ 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(); } } @@ -267,10 +288,12 @@ 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); - TOGGLE -> ToggleField(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); 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 133fb788..9621285a 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,6 +34,7 @@ class GroupField : LinearLayout, IField { private val _container : LinearLayout; override var reference: Any? = null; + override var isAdvanced: Boolean = false; override val value: Any? = null; @@ -100,7 +101,7 @@ class GroupField : LinearLayout, IField { return this; } - override fun fromField(obj: Any, field: Field, formField: FormField?) : GroupField { + override fun fromField(obj: Any, field: Field, formField: FormField?, advanced: Boolean) : 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 d0cfb7dc..3fb78aeb 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,6 +31,7 @@ class ReadOnlyTextField : TableRow, IField { override val onChanged = Event3(); override var reference: Any? = null; + override var isAdvanced: Boolean = false; override val value: Any? = null; @@ -45,7 +46,7 @@ class ReadOnlyTextField : TableRow, IField { override fun setValue(value: Any) {} - override fun fromField(obj : Any, field : Field, formField: FormField?) : ReadOnlyTextField { + override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : 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 8e1cfbbb..1421f2c8 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,6 +33,7 @@ class ToggleField : TableRow, IField { private var _lastValue: Boolean = false; override var reference: Any? = null; + override var isAdvanced: Boolean = false; override val onChanged = Event3(); @@ -75,7 +76,7 @@ class ToggleField : TableRow, IField { return this; } - override fun fromField(obj : Any, field : Field, formField: FormField?) : ToggleField { + override fun fromField(obj : Any, field : Field, formField: FormField?, advanced: Boolean) : ToggleField { this._field = field; this._obj = obj; @@ -87,6 +88,11 @@ 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/livechat/LiveChatDonationListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt index 05577fcb..1a4087a8 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,5 +1,6 @@ package com.futo.platformplayer.views.livechat +import CSSColor import android.graphics.Color import android.graphics.drawable.LevelListDrawable import android.text.Spannable @@ -24,6 +25,7 @@ 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)) { @@ -55,10 +57,10 @@ class LiveChatDonationListItem(viewGroup: ViewGroup) _amount.text = event.amount.trim(); if(event.colorDonation != null && event.colorDonation.isHexColor()) { - val color = Color.parseColor(event.colorDonation); - _amountContainer.background.setTint(color); + val color = CSSColor.parseColor(event.colorDonation); + _amountContainer.background.setTint(color.toAndroidColor()); - if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + if(color.lightness > 0.5) _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 02619424..34ae1c1b 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,6 +13,7 @@ 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; @@ -33,10 +34,10 @@ class LiveChatDonationPill: LinearLayout { if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { - val color = Color.parseColor(donation.colorDonation); - root.background.setTint(color); + val color = CSSColor.parseColor(donation.colorDonation); + root.background.setTint(color.toAndroidColor()); - if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + if(color.lightness > 0.5) _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 ffe7f1b3..df742225 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,6 +18,7 @@ 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)) { @@ -75,7 +76,7 @@ class LiveChatMessageListItem(viewGroup: ViewGroup) if (!event.colorName.isNullOrEmpty()) { try { - _authorName.setTextColor(Color.parseColor(event.colorName)); + _authorName.setTextColor(CSSColor.parseColor(event.colorName).toAndroidColor()); } catch (ex: Throwable) { } } else 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 fc3eff23..a3a87946 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 @@ -14,9 +14,6 @@ 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 @@ -43,6 +40,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import toAndroidColor class LiveChatOverlay : LinearLayout { @@ -69,7 +67,7 @@ class LiveChatOverlay : LinearLayout { private val _overlayRaid_Thumbnail: ImageView; private val _overlayRaid_ButtonGo: Button; - private val _overlayRaid_ButtonPrevent: Button; + private val _overlayRaid_ButtonDismiss: Button; private val _textViewers: TextView; @@ -150,7 +148,7 @@ class LiveChatOverlay : LinearLayout { _overlayRaid_Name = findViewById(R.id.raid_name); _overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail); _overlayRaid_ButtonGo = findViewById(R.id.raid_button_go); - _overlayRaid_ButtonPrevent = findViewById(R.id.raid_button_prevent); + _overlayRaid_ButtonDismiss = findViewById(R.id.raid_button_prevent); _overlayRaid.visibility = View.GONE; @@ -159,7 +157,7 @@ class LiveChatOverlay : LinearLayout { onRaidNow.emit(it); } } - _overlayRaid_ButtonPrevent.setOnClickListener { + _overlayRaid_ButtonDismiss.setOnClickListener { _currentRaid?.let { _currentRaid = null; _overlayRaid.visibility = View.GONE; @@ -291,10 +289,10 @@ class LiveChatOverlay : LinearLayout { _overlayDonation_Amount.text = donation.amount.trim(); _overlayDonation.visibility = VISIBLE; if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { - val color = Color.parseColor(donation.colorDonation); - _overlayDonation_AmountContainer.background.setTint(color); + val color = CSSColor.parseColor(donation.colorDonation); + _overlayDonation_AmountContainer.background.setTint(color.toAndroidColor()); - if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + if(color.lightness > 0.5) _overlayDonation_Amount.setTextColor(Color.BLACK) else _overlayDonation_Amount.setTextColor(Color.WHITE); @@ -372,6 +370,8 @@ class LiveChatOverlay : LinearLayout { } else _overlayRaid.visibility = View.GONE; + + _overlayRaid_ButtonGo.visibility = if (raid?.isOutgoing == true) View.VISIBLE else View.GONE } } fun setViewCount(viewCount: Int) { 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 78031ec0..d30a4795 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, 3.0f, resources.displayMetrics).toInt(); + val marginLeft = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.5f, resources.displayMetrics).toInt(); val marginRight = marginLeft; buttons.clear(); 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 58850998..72500a49 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,6 +13,7 @@ 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 @@ -42,10 +43,14 @@ 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; - if(!_container!!.children.contains(this)) { - _container!!.removeAllViews(); - _container!!.addView(this); + _container!!.removeAllViews(); + _container!!.addView(this); + if (_container!!.isVisible) { + isVisible = true + _viewBackground.alpha = 1.0f; + _viewOverlayContainer.translationY = 0.0f; } + _textTitle.text = titleText; groupItems = items; @@ -56,6 +61,12 @@ class SlideUpMenuOverlay : RelativeLayout { } setItems(items); + + if (!isVisible) { + _viewOverlayContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + _viewOverlayContainer.translationY = _viewOverlayContainer.measuredHeight.toFloat() + _viewBackground.alpha = 0f; + } } @@ -146,16 +157,9 @@ class SlideUpMenuOverlay : RelativeLayout { } isVisible = true; - _container?.post { - _container?.visibility = View.VISIBLE; - _container?.bringToFront(); - } + _container?.visibility = View.VISIBLE; 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/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index e209f937..193b564e 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,5 +1,6 @@ package com.futo.platformplayer.views.video +import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.content.res.Resources @@ -44,8 +45,11 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlayer 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 @@ -117,6 +121,9 @@ 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 @@ -147,6 +154,11 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onChapterClicked = Event1(); + private val loaderOverlay: FrameLayout + private val loaderIndeterminate: android.widget.ProgressBar + private val loaderDeterminate: android.widget.ProgressBar + private var determinateAnimator: ValueAnimator? = null + @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); @@ -187,6 +199,14 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); + loaderOverlay = findViewById(R.id.loader_overlay) + loaderIndeterminate = findViewById(R.id.loader_indeterminate) + loaderDeterminate = findViewById(R.id.loader_determinate) + + loaderOverlay.visibility = View.GONE + loaderIndeterminate.visibility = View.GONE + loaderDeterminate.visibility = View.GONE + _control_chapter.setOnClickListener { _currentChapter?.let { onChapterClicked.emit(it); @@ -254,6 +274,20 @@ 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) { @@ -848,4 +882,35 @@ class FutoVideoPlayer : FutoVideoPlayerBase { override fun onSurfaceSizeChanged(width: Int, height: Int) { gestureControl.resetZoomPan() } + + override fun setLoading(isLoading: Boolean) { + determinateAnimator?.cancel() + if (isLoading) { + loaderOverlay.visibility = View.VISIBLE + loaderIndeterminate.visibility = View.VISIBLE + loaderDeterminate.visibility = View.GONE + } else { + loaderOverlay.visibility = View.GONE + loaderIndeterminate.visibility = View.GONE + loaderDeterminate.visibility = View.GONE + } + } + + override fun setLoading(expectedDurationMs: Int) { + determinateAnimator?.cancel() + + loaderOverlay.visibility = View.VISIBLE + loaderIndeterminate.visibility = View.GONE + loaderDeterminate.visibility = View.VISIBLE + loaderDeterminate.max = expectedDurationMs + loaderDeterminate.progress = 0 + + determinateAnimator = ValueAnimator.ofInt(0, expectedDurationMs).apply { + duration = expectedDurationMs.toLong() + addUpdateListener { anim -> + loaderDeterminate.progress = anim.animatedValue as Int + } + start() + } + } } \ 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 67a56545..82fd5ca6 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 @@ -53,16 +53,21 @@ 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.constructs.Event0 import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException 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 @@ -107,6 +112,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val onPositionDiscontinuity = Event1(); val onDatasourceError = Event1(); + val onReloadRequired = Event0(); + private var _didCallSourceChange = false; private var _lastState: Int = -1; @@ -251,12 +258,22 @@ abstract class FutoVideoPlayerBase : RelativeLayout { fun switchToVideoMode() { Logger.i(TAG, "Switching to Video Mode"); isAudioMode = false; - loadSelectedSources(playing, true); + val player = exoPlayer ?: return + player.player.trackSelectionParameters = + player.player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode) + .build() } fun switchToAudioMode() { Logger.i(TAG, "Switching to Audio Mode"); isAudioMode = true; - loadSelectedSources(playing, true); + val player = exoPlayer ?: return + player.player.trackSelectionParameters = + player.player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode) + .build() } fun seekTo(ms: Long) { @@ -337,8 +354,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { var videoSourceUsed = videoSource; var audioSourceUsed = audioSource; if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){ - videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); - audioSourceUsed = null; + videoSource.getUnderlyingPlugin()?.busy { + videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource); + audioSourceUsed = null; + } } val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume); @@ -549,17 +568,32 @@ abstract class FutoVideoPlayerBase : RelativeLayout { if(videoSource.hasGenerate) { findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) { + val scope = this; + var startId = -1; try { - val generated = videoSource.generate(); + 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(); + withContext(Dispatchers.Main) { + setLoading(false) + } if (generated != null) { withContext(Dispatchers.Main) { val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource)) - videoSource.getHttpDataSourceFactory() + withContext(Dispatchers.IO) { videoSource.getHttpDataSourceFactory() } else DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource) - dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor()); + dataSource.setRequestExecutor2(withContext(Dispatchers.IO){videoSource.audio.getRequestExecutor()}); _lastVideoMediaSource = DashMediaSource.Factory(dataSource) .createMediaSource( DashManifestParser().parse( @@ -574,8 +608,23 @@ 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; @@ -660,25 +709,64 @@ abstract class FutoVideoPlayerBase : RelativeLayout { @OptIn(UnstableApi::class) 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 generated = audioSource.generate(); - if(generated != null) { + 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); } withContext(Dispatchers.Main) { - _lastVideoMediaSource = DashMediaSource.Factory(dataSource) - .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), - ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0)))); - loadSelectedSources(play, resume); + if (generatedDef.estDuration >= 0) { + setLoading(generatedDef.estDuration) + } else { + setLoading(true) + } + } + val generated = generatedDef.await(); + 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) } } } 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( @@ -879,6 +967,9 @@ 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/layout/activity_polycentric_backup.xml b/app/src/main/res/layout/activity_polycentric_backup.xml index e31e8584..d6579dd7 100644 --- a/app/src/main/res/layout/activity_polycentric_backup.xml +++ b/app/src/main/res/layout/activity_polycentric_backup.xml @@ -76,4 +76,15 @@ app:buttonIcon="@drawable/ic_copy" android:layout_marginTop="8dp" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_channel.xml b/app/src/main/res/layout/fragment_channel.xml index ea9d4f52..29292034 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/list_comment.xml b/app/src/main/res/layout/list_comment.xml index 91a1d0a9..b39af456 100644 --- a/app/src/main/res/layout/list_comment.xml +++ b/app/src/main/res/layout/list_comment.xml @@ -80,7 +80,7 @@ android:isScrollContainer="false" android:textColor="#CCCCCC" android:textSize="13sp" - android:maxLines="100" + android:maxLines="150" app:layout_constraintTop_toBottomOf="@id/text_metadata" app:layout_constraintLeft_toRightOf="@id/image_thumbnail" app:layout_constraintRight_toRightOf="parent" diff --git a/app/src/main/res/layout/list_locked_preview.xml b/app/src/main/res/layout/list_locked_preview.xml index 2413c98c..1aefd5b8 100644 --- a/app/src/main/res/layout/list_locked_preview.xml +++ b/app/src/main/res/layout/list_locked_preview.xml @@ -116,9 +116,9 @@ android:layout_marginBottom="6dp" android:background="#DD000000" android:visibility="gone" + android:gravity="center" android:orientation="vertical"> - + android:gravity="center" + android:orientation="vertical"> + + - - - +