diff --git a/app/build.gradle b/app/build.gradle index fcbd422c..25d458d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,6 +156,7 @@ android { dependencies { implementation 'com.google.dagger:dagger:2.48' implementation 'androidx.test:monitor:1.7.2' + implementation 'com.google.android.material:material:1.12.0' annotationProcessor 'com.google.dagger:dagger-compiler:2.48' //Core @@ -180,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/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 0638f079..7f348347 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -103,6 +103,12 @@ class UnavailableException extends ScriptException { super("UnavailableException", msg); } } +class ReloadRequiredException extends ScriptException { + constructor(msg, reloadData) { + super("ReloadRequiredException", msg); + this.reloadData = reloadData; + } +} class AgeException extends ScriptException { constructor(msg) { super("AgeException", msg); @@ -701,11 +707,12 @@ class LiveEventViewCount extends LiveEvent { } } class LiveEventRaid extends LiveEvent { - constructor(targetUrl, targetName, targetThumbnail) { + constructor(targetUrl, targetName, targetThumbnail, isOutgoing) { super(100); this.targetUrl = targetUrl; this.targetName = targetName; this.targetThumbnail = targetThumbnail; + this.isOutgoing = isOutgoing ?? true; } } @@ -778,6 +785,7 @@ let plugin = { //To override by plugin const source = { getHome() { return new ContentPager([], false, {}); }, + getShorts() { return new VideoPager([], false, {}); }, enable(config){ }, disable() {}, diff --git a/app/src/main/java/com/futo/platformplayer/CSSColor.kt b/app/src/main/java/com/futo/platformplayer/CSSColor.kt new file mode 100644 index 00000000..73b50413 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/CSSColor.kt @@ -0,0 +1,319 @@ +import kotlin.math.* + +class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) { + init { + require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) { + "RGBA channels must be in [0,1]" + } + } + + // -- RGB(A) channels stored 0–1 -- + var r: Float = r.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } + var g: Float = g.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } + var b: Float = b.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } + var a: Float = a.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f) } + + // -- Int views of RGBA 0–255 -- + var red: Int + get() = (r * 255).roundToInt() + set(v) { r = (v.coerceIn(0, 255) / 255f) } + var green: Int + get() = (g * 255).roundToInt() + set(v) { g = (v.coerceIn(0, 255) / 255f) } + var blue: Int + get() = (b * 255).roundToInt() + set(v) { b = (v.coerceIn(0, 255) / 255f) } + var alpha: Int + get() = (a * 255).roundToInt() + set(v) { a = (v.coerceIn(0, 255) / 255f) } + + // -- HSLA storage & lazy recompute flags -- + private var _h: Float = 0f + private var _s: Float = 0f + private var _l: Float = 0f + private var _hslDirty = true + + /** Hue [0...360) */ + var hue: Float + get() { computeHslIfNeeded(); return _h } + set(v) { setHsl(v, saturation, lightness) } + + /** Saturation [0...1] */ + var saturation: Float + get() { computeHslIfNeeded(); return _s } + set(v) { setHsl(hue, v, lightness) } + + /** Lightness [0...1] */ + var lightness: Float + get() { computeHslIfNeeded(); return _l } + set(v) { setHsl(hue, saturation, v) } + + private fun computeHslIfNeeded() { + if (!_hslDirty) return + val max = max(max(r, g), b) + val min = min(min(r, g), b) + val d = max - min + _l = (max + min) / 2f + _s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f)) + _h = when { + d == 0f -> 0f + max == r -> ((g - b) / d % 6f) * 60f + max == g -> (((b - r) / d) + 2f) * 60f + else -> (((r - g) / d) + 4f) * 60f + }.let { if (it < 0f) it + 360f else it } + _hslDirty = false + } + + /** + * Set all three HSL channels at once. + * Hue in degrees [0...360), s/l [0...1]. + */ + fun setHsl(h: Float, s: Float, l: Float) { + val hh = ((h % 360f) + 360f) % 360f + val cc = (1f - abs(2f * l - 1f)) * s + val x = cc * (1f - abs((hh / 60f) % 2f - 1f)) + val m = l - cc / 2f + + val (rp, gp, bp) = when { + hh < 60f -> Triple(cc, x, 0f) + hh < 120f -> Triple(x, cc, 0f) + hh < 180f -> Triple(0f, cc, x) + hh < 240f -> Triple(0f, x, cc) + hh < 300f -> Triple(x, 0f, cc) + else -> Triple(cc, 0f, x) + } + + r = rp + m; g = gp + m; b = bp + m + _h = hh; _s = s; _l = l; _hslDirty = false + } + + /** Return 0xRRGGBBAA int */ + fun toRgbaInt(): Int { + val ai = (a * 255).roundToInt() and 0xFF + val ri = (r * 255).roundToInt() and 0xFF + val gi = (g * 255).roundToInt() and 0xFF + val bi = (b * 255).roundToInt() and 0xFF + return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai + } + + /** Return 0xAARRGGBB int */ + fun toArgbInt(): Int { + val ai = (a * 255).roundToInt() and 0xFF + val ri = (r * 255).roundToInt() and 0xFF + val gi = (g * 255).roundToInt() and 0xFF + val bi = (b * 255).roundToInt() and 0xFF + return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi + } + + // — Convenience modifiers (chainable) — + + /** Lighten by fraction [0...1] */ + fun lighten(fraction: Float): CSSColor = apply { + lightness = (lightness + fraction).coerceIn(0f, 1f) + } + + /** Darken by fraction [0...1] */ + fun darken(fraction: Float): CSSColor = apply { + lightness = (lightness - fraction).coerceIn(0f, 1f) + } + + /** Increase saturation by fraction [0...1] */ + fun saturate(fraction: Float): CSSColor = apply { + saturation = (saturation + fraction).coerceIn(0f, 1f) + } + + /** Decrease saturation by fraction [0...1] */ + fun desaturate(fraction: Float): CSSColor = apply { + saturation = (saturation - fraction).coerceIn(0f, 1f) + } + + /** Rotate hue by degrees (can be negative) */ + fun rotateHue(degrees: Float): CSSColor = apply { + hue = (hue + degrees) % 360f + } + + companion object { + /** Create from Android 0xAARRGGBB */ + @JvmStatic fun fromArgb(color: Int): CSSColor { + val a = ((color ushr 24) and 0xFF) / 255f + val r = ((color ushr 16) and 0xFF) / 255f + val g = ((color ushr 8) and 0xFF) / 255f + val b = ( color and 0xFF) / 255f + return CSSColor(r, g, b, a) + } + + /** Create from Android 0xRRGGBBAA */ + @JvmStatic fun fromRgba(color: Int): CSSColor { + val r = ((color ushr 24) and 0xFF) / 255f + val g = ((color ushr 16) and 0xFF) / 255f + val b = ((color ushr 8) and 0xFF) / 255f + val a = ( color and 0xFF) / 255f + return CSSColor(r, g, b, a) + } + + @JvmStatic fun fromAndroidColor(color: Int): CSSColor { + return fromArgb(color) + } + + private val NAMED_HEX = mapOf( + "aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF", + "aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC", + "bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD", + "blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A", + "burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00", + "chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED", + "cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF", + "darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B", + "darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9", + "darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F", + "darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000", + "darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B", + "darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1", + "darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF", + "dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF", + "firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22", + "fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF", + "gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080", + "green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080", + "honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C", + "indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C", + "lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00", + "lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080", + "lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3", + "lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1", + "lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA", + "lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE", + "lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32", + "linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000", + "mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3", + "mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE", + "mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585", + "midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1", + "moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080", + "oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23", + "orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6", + "palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE", + "palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9", + "peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD", + "powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399", + "red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1", + "saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460", + "seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D", + "silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD", + "slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA", + "springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C", + "teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347", + "turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3", + "white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00", + "yellowgreen" to "9ACD32" + ) + private val NAMED: Map = NAMED_HEX + .mapValues { (_, hexRgb) -> + // parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity + val rgb = hexRgb.toInt(16) + (rgb shl 8) or 0xFF + } + ("transparent" to 0x00000000) + + private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE) + private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE) + private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE) + + @JvmStatic + fun parseColor(s: String): CSSColor { + val str = s.trim() + // named + NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() } + + // hex + HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part -> + return parseHexPart(part) + } + + // rgb/rgba + RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { + return parseRgbParts(it.split(',').map(String::trim)) + } + + // hsl/hsla + HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { + return parseHslParts(it.split(',').map(String::trim)) + } + + error("Cannot parse color: \"$s\"") + } + + private fun parseHexPart(p: String): CSSColor { + // expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA" + val hex = when (p.length) { + 3 -> p.map { "$it$it" }.joinToString("") + "FF" + 4 -> p.map { "$it$it" }.joinToString("") + 6 -> p + "FF" + 8 -> p + else -> error("Invalid hex color: #$p") + } + + val parsed = hex.toLong(16).toInt() + val alpha = (parsed and 0xFF) shl 24 + val rgbOnly = (parsed ushr 8) and 0x00FFFFFF + val argb = alpha or rgbOnly + return fromArgb(argb) + } + + private fun parseRgbParts(parts: List): CSSColor { + require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" } + + // r/g/b: "128" → 128/255, "50%" → 0.5 + fun channel(ch: String): Float = + if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f + else ch.toFloat().coerceIn(0f, 255f) / 255f + + // alpha: "0.5" → 0.5, "50%" → 0.5 + fun alpha(a: String): Float = + if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f + else a.toFloat().coerceIn(0f, 1f) + + val r = channel(parts[0]) + val g = channel(parts[1]) + val b = channel(parts[2]) + val a = if (parts.size == 4) alpha(parts[3]) else 1f + + return CSSColor(r, g, b, a) + } + + private fun parseHslParts(parts: List): CSSColor { + require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" } + + fun hueOf(h: String): Float = when { + h.endsWith("deg") -> h.removeSuffix("deg").toFloat() + h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f + h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat()) + h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f + else -> h.toFloat() + } + + // for s and l you only ever see percentages + fun pct(p: String): Float = + p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f + + // alpha: "0.5" → 0.5, "50%" → 0.5 + fun alpha(a: String): Float = + if (a.endsWith("%")) pct(a) + else a.toFloat().coerceIn(0f, 1f) + + val h = hueOf(parts[0]) + val s = pct(parts[1]) + val l = pct(parts[2]) + val a = if (parts.size == 4) alpha(parts[3]) else 1f + + return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) } + } + } +} + +fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this) +fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this) +fun CSSColor.toAndroidColor(): Int = toArgbInt() diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt b/app/src/main/java/com/futo/platformplayer/Extensions_V8.kt index e31d3dac..fc1f5cf3 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,137 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap { for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get(it).toString() }) map.put(prop, obj.getString(prop)); return map; +} + + +fun V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T { + val latch = CountDownLatch(1); + var promiseResult: T? = null; + var promiseException: Throwable? = null; + plugin.busy { + this.register(object: IV8ValuePromise.IListener { + override fun onFulfilled(p0: V8Value?) { + if(p0 is V8ValueError) + promiseException = ScriptExecutionException(plugin.config, p0.message); + else + promiseResult = p0 as T; + latch.countDown(); + } + override fun onRejected(p0: V8Value?) { + promiseException = (NotImplementedError("onRejected promise not implemented..")); + latch.countDown(); + } + override fun onCatch(p0: V8Value?) { + promiseException = (NotImplementedError("onCatch promise not implemented..")); + latch.countDown(); + } + }); + } + + plugin.registerPromise(this) { + promiseException = CancellationException("Cancelled by system"); + latch.countDown(); + } + plugin.unbusy { + latch.await(); + } + if(promiseException != null) + throw promiseException!!; + return promiseResult!!; +} +fun V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred { + val underlyingDef = CompletableDeferred(); + val def = if(this.has("estDuration")) + V8Deferred(underlyingDef, + this.getOrDefault(plugin.config, "estDuration", "toV8ValueAsync", -1) ?: -1); + else + V8Deferred(underlyingDef); + + if(def.estDuration > 0) + Logger.i("V8", "Promise with duration: [${def.estDuration}]"); + + val promise = this; + plugin.busy { + this.register(object: IV8ValuePromise.IListener { + override fun onFulfilled(p0: V8Value?) { + plugin.resolvePromise(promise); + underlyingDef.complete(p0 as T); + } + override fun onRejected(p0: V8Value?) { + plugin.resolvePromise(promise); + underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented..")); + } + override fun onCatch(p0: V8Value?) { + plugin.resolvePromise(promise); + underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented..")); + } + }); + } + plugin.registerPromise(promise) { + if(def.isActive) + def.cancel("Cancelled by system"); + } + return def; +} + +class V8Deferred(val deferred: Deferred, val estDuration: Int = -1): Deferred by deferred { + + fun convert(conversion: (result: T)->R): V8Deferred{ + val newDef = CompletableDeferred() + this.invokeOnCompletion { + if(it != null) + newDef.completeExceptionally(it); + else + newDef.complete(conversion(this@V8Deferred.getCompleted())); + } + + return V8Deferred(newDef, estDuration); + } + + + companion object { + fun merge(scope: CoroutineScope, defs: List>, conversion: (result: List)->R): V8Deferred { + + var amount = -1; + for(def in defs) + amount = Math.max(amount, def.estDuration); + + val def = scope.async { + val results = defs.map { it.await() }; + return@async conversion(results); + } + return V8Deferred(def, amount); + } + } +} + + +fun V8ValueObject.invokeV8(method: String, vararg obj: Any?): T { + var result = this.invoke(method, *obj); + if(result is V8ValuePromise) { + return result.toV8ValueBlocking(this.getSourcePlugin()!!); + } + return result as T; +} +fun V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred { + var result = this.invoke(method, *obj); + if(result is V8ValuePromise) { + return result.toV8ValueAsync(this.getSourcePlugin()!!); + } + return V8Deferred(CompletableDeferred(result as T)); +} +fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value { + var result = this.invoke(method, *obj); + if(result is V8ValuePromise) { + return result.toV8ValueBlocking(this.getSourcePlugin()!!); + } + return result; +} +fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred { + var result = this.invoke(method, *obj); + if(result is V8ValuePromise) { + val result = result.toV8ValueAsync(this.getSourcePlugin()!!); + return result; + } + return V8Deferred(CompletableDeferred(result)); } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index e8f4f70a..da414d8f 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -584,6 +584,25 @@ class Settings : FragmentedStorageFileJson() { 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) @@ -999,10 +1018,13 @@ class Settings : FragmentedStorageFileJson() { @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..409adbf5 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -129,115 +129,163 @@ class UISlideOverlays { val originalVideo = subscription.doFetchVideos; val originalPosts = subscription.doFetchPosts; - val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf()); + val menu = SlideUpMenuOverlay( + container.context, + container, + "Subscription Settings", + null, + true, + listOf() + ); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ - val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); - val capabilities = plugin.getChannelCapabilities(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); + val capabilities = plugin.getChannelCapabilities(); - withContext(Dispatchers.Main) { - items.addAll(listOf( - SlideUpMenuItem( - container.context, - R.drawable.ic_notifications, - "Notifications", - "", - tag = "notifications", - call = { - subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; - }, - invokeParent = false - ), - if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) - SlideUpMenuGroup(container.context, "Subscription Groups", - "You can select which groups this subscription is part of.", - -1, listOf()) else null, - if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) - SlideUpMenuRecycler(container.context, "as") { - val groups = ArrayList(StateSubscriptionGroups.instance.getSubscriptionGroups() - .map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } - .sortedBy { !it.selected }); - var adapter: AnyAdapterView? = null; - adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { - it.onClick.subscribe { - if(it is SubscriptionGroup.Selectable) { - val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id) - ?: return@subscribe; - groups.clear(); - if(it.selected) - actualGroup.urls.remove(subscription.channel.url); - else - actualGroup.urls.add(subscription.channel.url); + withContext(Dispatchers.Main) { + items.addAll( + listOf( + SlideUpMenuItem( + container.context, + R.drawable.ic_notifications, + "Notifications", + "", + tag = "notifications", + call = { + subscription.doNotifications = + menu?.selectOption(null, "notifications", true, true) + ?: subscription.doNotifications; + }, + invokeParent = false + ), + if (StateSubscriptionGroups.instance.getSubscriptionGroups() + .isNotEmpty() + ) + SlideUpMenuGroup( + container.context, "Subscription Groups", + "You can select which groups this subscription is part of.", + -1, listOf() + ) else null, + if (StateSubscriptionGroups.instance.getSubscriptionGroups() + .isNotEmpty() + ) + SlideUpMenuRecycler(container.context, "as") { + val groups = + ArrayList( + StateSubscriptionGroups.instance.getSubscriptionGroups() + .map { + SubscriptionGroup.Selectable( + it, + it.urls.contains(subscription.channel.url) + ) + } + .sortedBy { !it.selected }); + var adapter: AnyAdapterView? = + null; + adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { + it.onClick.subscribe { + if (it is SubscriptionGroup.Selectable) { + val actualGroup = + StateSubscriptionGroups.instance.getSubscriptionGroup( + it.id + ) + ?: return@subscribe; + groups.clear(); + if (it.selected) + actualGroup.urls.remove(subscription.channel.url); + else + actualGroup.urls.add(subscription.channel.url); - StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup); - groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups() - .map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } - .sortedBy { !it.selected }); - adapter?.notifyContentChanged(); - } - } - }; - return@SlideUpMenuRecycler adapter; - } else null, - SlideUpMenuGroup(container.context, "Fetch Settings", - "Depending on the platform you might not need to enable a type for it to be available.", - -1, listOf()), - if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem( - container.context, - R.drawable.ic_live_tv, - "Livestreams", - "Check for livestreams", - tag = "fetchLive", - call = { - subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; - }, - invokeParent = false - ) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Streams", - "Check for streams", - tag = "fetchStreams", - call = { - subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; - }, - invokeParent = false - ) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Videos", - "Check for videos", - tag = "fetchVideos", - call = { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; - }, - invokeParent = false - ) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) - SlideUpMenuItem( - container.context, - R.drawable.ic_play, - "Content", - "Check for content", - tag = "fetchVideos", - call = { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; - }, - invokeParent = false - ) else null, - if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( - container.context, - R.drawable.ic_chat, - "Posts", - "Check for posts", - tag = "fetchPosts", - call = { - subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; - }, - invokeParent = false - ) else null/*,, + StateSubscriptionGroups.instance.updateSubscriptionGroup( + actualGroup + ); + groups.addAll( + StateSubscriptionGroups.instance.getSubscriptionGroups() + .map { + SubscriptionGroup.Selectable( + it, + it.urls.contains(subscription.channel.url) + ) + } + .sortedBy { !it.selected }); + adapter?.notifyContentChanged(); + } + } + }; + return@SlideUpMenuRecycler adapter; + } else null, + SlideUpMenuGroup( + container.context, "Fetch Settings", + "Depending on the platform you might not need to enable a type for it to be available.", + -1, listOf() + ), + if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem( + container.context, + R.drawable.ic_live_tv, + "Livestreams", + "Check for livestreams", + tag = "fetchLive", + call = { + subscription.doFetchLive = + menu?.selectOption(null, "fetchLive", true, true) + ?: subscription.doFetchLive; + }, + invokeParent = false + ) else null, + if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Streams", + "Check for streams", + tag = "fetchStreams", + call = { + subscription.doFetchStreams = + menu?.selectOption(null, "fetchStreams", true, true) + ?: subscription.doFetchStreams; + }, + invokeParent = false + ) else null, + if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Videos", + "Check for videos", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = + menu?.selectOption(null, "fetchVideos", true, true) + ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) + SlideUpMenuItem( + container.context, + R.drawable.ic_play, + "Content", + "Check for content", + tag = "fetchVideos", + call = { + subscription.doFetchVideos = + menu?.selectOption(null, "fetchVideos", true, true) + ?: subscription.doFetchVideos; + }, + invokeParent = false + ) else null, + if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( + container.context, + R.drawable.ic_chat, + "Posts", + "Check for posts", + tag = "fetchPosts", + call = { + subscription.doFetchPosts = + menu?.selectOption(null, "fetchPosts", true, true) + ?: subscription.doFetchPosts; + }, + invokeParent = false + ) else null/*,, SlideUpMenuGroup(container.context, "Actions", "Various things you can do with this subscription", @@ -245,61 +293,82 @@ class UISlideOverlays { SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", { showCreateSubscriptionGroup(container, subscription.channel); }, false)*/ - ).filterNotNull()); + ).filterNotNull() + ); - menu.setItems(items); + menu.setItems(items); - if(subscription.doNotifications) - menu.selectOption(null, "notifications", true, true); - if(subscription.doFetchLive) - menu.selectOption(null, "fetchLive", true, true); - if(subscription.doFetchStreams) - menu.selectOption(null, "fetchStreams", true, true); - if(subscription.doFetchVideos) - menu.selectOption(null, "fetchVideos", true, true); - if(subscription.doFetchPosts) - menu.selectOption(null, "fetchPosts", true, true); + if (subscription.doNotifications) + menu.selectOption(null, "notifications", true, true); + if (subscription.doFetchLive) + menu.selectOption(null, "fetchLive", true, true); + if (subscription.doFetchStreams) + menu.selectOption(null, "fetchStreams", true, true); + if (subscription.doFetchVideos) + menu.selectOption(null, "fetchVideos", true, true); + if (subscription.doFetchPosts) + menu.selectOption(null, "fetchPosts", true, true); - menu.onOK.subscribe { - subscription.save(); - menu.hide(true); + menu.onOK.subscribe { + subscription.save(); + menu.hide(true); - if(subscription.doNotifications && !originalNotif) { - val mainContext = StateApp.instance.contextOrNull; - if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { - UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work"); + if (subscription.doNotifications && !originalNotif) { + val mainContext = StateApp.instance.contextOrNull; + if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { + UIDialogs.toast( + container.context, + "Enable 'Background Update' in settings for notifications to work" + ); - if(mainContext is MainActivity) { - UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required", - "You need to set a Background Updating interval for notifications", null, 0, - UIDialogs.Action("Cancel", {}), - UIDialogs.Action("Configure", { - val intent = Intent(mainContext, SettingsActivity::class.java); - intent.putExtra("query", mainContext.getString(R.string.background_update)); - mainContext.startActivity(intent); - }, UIDialogs.ActionStyle.PRIMARY)); - } - return@subscribe; - } - else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { - UIDialogs.toast(container.context, "Android notifications are disabled"); - if(mainContext is MainActivity) { - mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); + if (mainContext is MainActivity) { + UIDialogs.showDialog( + mainContext, + R.drawable.ic_settings, + "Background Updating Required", + "You need to set a Background Updating interval for notifications", + null, + 0, + UIDialogs.Action("Cancel", {}), + UIDialogs.Action("Configure", { + val intent = Intent( + mainContext, + SettingsActivity::class.java + ); + intent.putExtra( + "query", + mainContext.getString(R.string.background_update) + ); + mainContext.startActivity(intent); + }, UIDialogs.ActionStyle.PRIMARY) + ); + } + return@subscribe; + } else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { + UIDialogs.toast( + container.context, + "Android notifications are disabled" + ); + if (mainContext is MainActivity) { + mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); + } } } - } - }; - menu.onCancel.subscribe { - subscription.doNotifications = originalNotif; - subscription.doFetchLive = originalLive; - subscription.doFetchStreams = originalStream; - subscription.doFetchVideos = originalVideo; - subscription.doFetchPosts = originalPosts; - }; + }; + menu.onCancel.subscribe { + subscription.doNotifications = originalNotif; + subscription.doFetchLive = originalLive; + subscription.doFetchStreams = originalStream; + subscription.doFetchVideos = originalVideo; + subscription.doFetchPosts = originalPosts; + }; - menu.setOk("Save"); + menu.setOk("Save"); - menu.show(); + menu.show(); + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show subscription overlay.", e) } } @@ -1151,6 +1220,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/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 829b7857..0d5bf8d9 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -32,7 +32,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.whenStateAtLeast import androidx.lifecycle.withStateAtLeast import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.BuildConfig @@ -63,6 +62,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment +import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment @@ -114,7 +114,6 @@ import java.io.PrintWriter 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 @@ -171,6 +170,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragHistory: HistoryFragment; + lateinit var _fragShorts: ShortsFragment; lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; @@ -340,6 +340,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragWebDetail = WebDetailFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance(); _fragHistory = HistoryFragment.newInstance(); + _fragShorts = ShortsFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance(); _fragDownloads = DownloadsFragment(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); @@ -610,6 +611,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }, UIDialogs.ActionStyle.PRIMARY) ) } + + //startActivity(Intent(this, TestActivity::class.java)) } /* @@ -1253,6 +1256,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { WebDetailFragment::class -> _fragWebDetail as T; WatchLaterFragment::class -> _fragWatchlist as T; HistoryFragment::class -> _fragHistory as T; + ShortsFragment::class -> _fragShorts as T; SourceDetailFragment::class -> _fragSourceDetail as T; DownloadsFragment::class -> _fragDownloads as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt index 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/activities/TestActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt index 608bda0a..5f9e0a10 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/TestActivity.kt @@ -2,12 +2,24 @@ package com.futo.platformplayer.activities import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R +import com.futo.platformplayer.views.TargetTapLoaderView +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class TestActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_test); + + val view = findViewById(R.id.test_view) + view.startLoader(10000) + + lifecycleScope.launch { + delay(5000) + view.startLoader() + } } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 010fd3c1..56d8fbd2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.models.ImageVariable @@ -36,6 +37,11 @@ interface IPlatformClient { */ fun getHome(): IPager + /** + * Gets the shorts feed + */ + fun getShorts(): IPager + //Search /** * Gets search suggestion for the provided query string 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 476bad8a..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 @@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter @@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager +import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 @@ -59,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 @@ -83,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; @@ -98,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; @@ -118,6 +126,7 @@ open class JSClient : IPlatformClient { val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true + val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true fun getSubscriptionRateLimit(): Int? { val pluginRateLimit = config.subscriptionRateLimit; @@ -197,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 @@ -213,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; @@ -260,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") @@ -295,6 +331,13 @@ open class JSClient : IPlatformClient { plugin.executeTyped("source.getHome()")); } + @JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform") + override fun getShorts(): IPager = isBusyWith("getShorts") { + ensureEnabled() + return@isBusyWith JSVideoPager(config, this, + plugin.executeTyped("source.getShorts()")) + } + @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocsParameter("query", "Query to complete suggestions for") override fun searchSuggestions(query: String): Array = isBusyWith("searchSuggestions") { @@ -313,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); @@ -342,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") @@ -375,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") @@ -400,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); @@ -513,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") @@ -552,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; } @@ -594,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") { @@ -622,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 @@ -734,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 a637e89d..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 @@ -47,6 +48,7 @@ class SourcePluginConfig( var subscriptionRateLimit: Int? = null, var enableInSearch: Boolean = true, var enableInHome: Boolean = true, + var enableInShorts: Boolean = true, var supportedClaimTypes: List = listOf(), var primaryClaimFieldType: Int? = null, var developerSubmitUrl: String? = null, @@ -168,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 { @@ -204,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/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index add53131..971c7baa 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -103,9 +103,11 @@ class SourcePluginDescriptor { @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1) var enableHome: Boolean? = null; - @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2) var enableSearch: Boolean? = null; + + @FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3) + var enableShorts: Boolean? = null; } @FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3) @@ -143,6 +145,8 @@ class SourcePluginDescriptor { tabEnabled.enableHome = config.enableInHome if(tabEnabled.enableSearch == null) tabEnabled.enableSearch = config.enableInSearch + if(tabEnabled.enableShorts == null) + tabEnabled.enableShorts = config.enableInShorts } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index 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/JSArticleDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt index 0e2c0e32..40c63d48 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSArticleDetails.kt @@ -21,6 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullableList +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { @@ -85,12 +86,12 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced } private fun getContentRecommendationsJS(client: JSClient): JSContentPager { - val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + val contentPager = _content.invokeV8("getContentRecommendations", arrayOf()); return JSContentPager(_pluginConfig, client, contentPager); } private fun getCommentsJS(client: JSClient): JSCommentPager { - val commentPager = _content.invoke("getComments", arrayOf()); + val commentPager = _content.invokeV8("getComments", arrayOf()); return JSCommentPager(_pluginConfig, client, commentPager); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt index ab847b6b..7767ef78 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSComment.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullable +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import java.time.LocalDateTime import java.time.OffsetDateTime @@ -60,7 +61,7 @@ class JSComment : IPlatformComment { if(!_hasGetReplies) return null; - val obj = _comment!!.invoke("getReplies", arrayOf()); + val obj = _comment!!.invokeV8("getReplies", arrayOf()); val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient"); return JSCommentPager(_config!!, plugin, obj); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/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..731d0e51 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPager.kt @@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.warnIfMainThread abstract class JSPager : IPager { @@ -29,7 +30,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(); } @@ -38,17 +41,20 @@ abstract class JSPager : IPager { } override fun hasMorePages(): Boolean { - return _hasMorePages; + return _hasMorePages && !pager.isClosed; } 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.invokeV8("nextPage", arrayOf()); + }; + _hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; + _resultChanged = true; + } /* try { } @@ -70,15 +76,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..bd0e4400 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,51 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.warnIfMainThread class JSPlaybackTracker: IPlaybackTracker { - private 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 +54,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.invokeV8Void("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 +72,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.invokeV8Void("onProgress", Math.floor(seconds), isPlaying); + nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); + _lastRequest = System.currentTimeMillis(); + } } } } @@ -67,7 +86,9 @@ class JSPlaybackTracker: IPlaybackTracker { if(_hasOnConcluded) { synchronized(_obj) { Logger.i("JSPlaybackTracker", "onConcluded"); - _obj.invokeVoid("onConcluded", -1); + _client.busy { + _obj.invokeV8Void("onConcluded", -1); + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt index 6c80d7dc..4d48a354 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSPostDetails.kt @@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { @@ -68,12 +69,12 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { return null; } private fun getContentRecommendationsJS(client: JSClient): JSContentPager { - val contentPager = _content.invoke("getContentRecommendations", arrayOf()); + val contentPager = _content.invokeV8("getContentRecommendations", arrayOf()); return JSContentPager(_pluginConfig, client, contentPager); } private fun getCommentsJS(client: JSClient): JSCommentPager { - val commentPager = _content.invoke("getComments", arrayOf()); + val commentPager = _content.invokeV8("getComments", arrayOf()); return JSCommentPager(_pluginConfig, client, commentPager); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt index 70dfecfd..ed428790 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt @@ -14,6 +14,8 @@ import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 +import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import kotlinx.serialization.Serializable @@ -46,52 +48,55 @@ 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.invokeV8("executeRequest", url, headers, method, body); + } as V8Value; + } else V8Plugin.catchScriptErrors( _config, "[${_config.name}] JSRequestExecutor", "builder.modifyRequest()" ) { - _executor.invoke("executeRequest", url, headers, method, body); + _executor.invokeV8("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 +104,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.invokeV8("cleanup", null); + }; + } + else V8Plugin.catchScriptErrors( + _config, + "[${_config.name}] JSRequestExecutor", + "builder.modifyRequest()" + ) { + _executor.invokeV8("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..af03d070 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestModifier.kt @@ -11,12 +11,14 @@ import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 +import com.futo.platformplayer.invokeV8Void class JSRequestModifier: IRequestModifier { private val _plugin: JSClient; private val _config: IV8PluginConfig; private var _modifier: V8ValueObject; - override var allowByteSkip: Boolean; + override var allowByteSkip: Boolean = false; constructor(plugin: JSClient, modifier: V8ValueObject) { this._plugin = plugin; @@ -24,10 +26,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 +40,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.invokeV8("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..74843d22 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,8 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.getSourcePlugin +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -35,8 +37,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.invokeV8("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..abea9550 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 @@ -24,9 +24,11 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrowNullable +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.states.StateDeveloper class JSVideoDetails : JSVideo, IPlatformVideoDetails { + private val _plugin: JSClient; private val _hasGetComments: Boolean; private val _hasGetContentRecommendations: Boolean; private val _hasGetPlaybackTracker: Boolean; @@ -48,6 +50,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 +85,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.invokeV8("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 +111,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.invokeV8("getContentRecommendations", arrayOf()); + return@busy JSContentPager(_pluginConfig, client, contentPager); + } } override fun getComments(client: IPlatformClient): IPager? { @@ -123,10 +130,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.invokeV8("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/JSAudioUrlWidevineSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt index 516e07ff..7df120d5 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlWidevineSource.kt @@ -6,6 +6,8 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 +import com.futo.platformplayer.invokeV8Void class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource { override val licenseUri: String @@ -25,7 +27,7 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource { return null val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") { - _obj.invoke("getLicenseRequestExecutor", arrayOf()) + _obj.invokeV8("getLicenseRequestExecutor", arrayOf()) } if (result !is V8ValueObject) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index 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/JSDashManifestWidevineSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt index be72d3a0..7700bd82 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestWidevineSource.kt @@ -9,6 +9,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrNull import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 +import com.futo.platformplayer.invokeV8Void class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource, IDashManifestWidevineSource, JSSource { @@ -45,7 +47,7 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource, return null val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") { - _obj.invoke("getLicenseRequestExecutor", arrayOf()) + _obj.invokeV8("getLicenseRequestExecutor", arrayOf()) } if (result !is V8ValueObject) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index 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..4fe4307f 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,7 +14,9 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.getOrDefault +import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.orNull import com.futo.platformplayer.views.video.datasources.JSHttpDataSource @@ -53,36 +55,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()); + _obj.invokeV8("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()); + _obj.invokeV8("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 +110,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 +132,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/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt index bcd6607d..aff22c33 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoUrlWidevineSource.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.getOrThrow +import com.futo.platformplayer.invokeV8 class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource { override val licenseUri: String @@ -25,7 +26,7 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource { return null val result = V8Plugin.catchScriptErrors(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") { - _obj.invoke("getLicenseRequestExecutor", arrayOf()) + _obj.invokeV8("getLicenseRequestExecutor", arrayOf()) } if (result !is V8ValueObject) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt index e86c9ba6..66554572 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/MultiDistributionContentPager.kt @@ -7,12 +7,12 @@ import java.util.stream.IntStream * A Content MultiPager that returns results based on a specified distribution * TODO: Merge all basic distribution pagers */ -class MultiDistributionContentPager : MultiPager { +class MultiDistributionContentPager : MultiPager { - private val dist : HashMap, Float>; - private val distConsumed : HashMap, Float>; + private val dist : HashMap, Float>; + private val distConsumed : HashMap, Float>; - constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { + constructor(pagers : Map, Float>) : super(pagers.keys.toMutableList()) { val distTotal = pagers.values.sum(); dist = HashMap(); @@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager { } @Synchronized - override fun selectItemIndex(options: Array>): Int { + override fun selectItemIndex(options: Array>): Int { if(options.size == 0) return -1; var bestIndex = 0; @@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager { distConsumed[options[bestIndex].pager.getPager()] = bestConsumed; return bestIndex; } - - } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/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/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index dcfaf63d..be62f726 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -348,7 +348,7 @@ class FCastCastingDevice : CastingDevice { headerBytesRead += read } - val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt(); + val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt(); if (size > buffer.size) { Logger.w(TAG, "Packets larger than $size bytes are not supported.") break 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 1e8e1830..f7802e86 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -39,6 +39,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -64,6 +65,7 @@ import java.net.URLDecoder import java.net.URLEncoder import java.util.Collections import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger class StateCasting { private val _scopeIO = CoroutineScope(Dispatchers.IO); @@ -89,6 +91,7 @@ class StateCasting { var _resumeCastingDevice: CastingDeviceInfo? = null; private var _nsdManager: NsdManager? = null val isCasting: Boolean get() = activeDevice != null; + private val _castId = AtomicInteger(0) private val _discoveryListeners = mapOf( "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), @@ -432,129 +435,112 @@ class StateCasting { action(); } - fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean { - val ad = activeDevice ?: return false; - if (ad.connectionState != CastConnectionState.CONNECTED) { - return false; - } + fun cancel() { + _castId.incrementAndGet() + } - val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; + suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean { + return withContext(Dispatchers.IO) { + val ad = activeDevice ?: return@withContext false; + if (ad.connectionState != CastConnectionState.CONNECTED) { + return@withContext false; + } - var sourceCount = 0; - if (videoSource != null) sourceCount++; - if (audioSource != null) sourceCount++; - if (subtitleSource != null) sourceCount++; + val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0; + val castId = _castId.incrementAndGet() - if (sourceCount < 1) { - throw Exception("At least one source should be specified."); - } + var sourceCount = 0; + if (videoSource != null) sourceCount++; + if (audioSource != null) sourceCount++; + if (subtitleSource != null) sourceCount++; - if (sourceCount > 1) { - if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { - if (ad is AirPlayCastingDevice) { - Logger.i(TAG, "Casting as local HLS"); - castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + if (sourceCount < 1) { + throw Exception("At least one source should be specified."); + } + + if (sourceCount > 1) { + if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { + if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as local HLS"); + castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + } else { + Logger.i(TAG, "Casting as local DASH"); + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); + } } else { - Logger.i(TAG, "Casting as local DASH"); - castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); - } - } else { - StateApp.instance.scope.launch(Dispatchers.IO) { - try { - val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource - if (isRawDash) { - Logger.i(TAG, "Casting as raw DASH"); + val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource + if (isRawDash) { + Logger.i(TAG, "Casting as raw DASH"); - try { - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e); - } + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading); + } else { + if (ad is FCastCastingDevice) { + Logger.i(TAG, "Casting as DASH direct"); + castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); + } else if (ad is AirPlayCastingDevice) { + Logger.i(TAG, "Casting as HLS indirect"); + castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } else { - if (ad is FCastCastingDevice) { - Logger.i(TAG, "Casting as DASH direct"); - castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } else if (ad is AirPlayCastingDevice) { - Logger.i(TAG, "Casting as HLS indirect"); - castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } else { - Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); - } + Logger.i(TAG, "Casting as DASH indirect"); + castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e); - } - } - } - } else { - val proxyStreams = Settings.instance.casting.alwaysProxyRequests; - val url = getLocalUrl(ad); - val id = UUID.randomUUID(); - - if (videoSource is IVideoUrlSource) { - val videoPath = "/video-${id}" - val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); - Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); - } else if (audioSource is IAudioUrlSource) { - val audioPath = "/audio-${id}" - val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); - Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); - } else if(videoSource is IHLSManifestSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { - Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); - } else { - Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); - } - } else if(audioSource is IHLSManifestAudioSource) { - if (proxyStreams || ad is ChromecastCastingDevice) { - Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); - } else { - Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); - } - } else if (videoSource is LocalVideoSource) { - Logger.i(TAG, "Casting as local video"); - castLocalVideo(video, videoSource, resumePosition, speed); - } else if (audioSource is LocalAudioSource) { - Logger.i(TAG, "Casting as local audio"); - castLocalAudio(video, audioSource, resumePosition, speed); - } else if (videoSource is JSDashManifestRawSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource video"); - - StateApp.instance.scope.launch(Dispatchers.IO) { - try { - castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e); - } - } - } else if (audioSource is JSDashManifestRawAudioSource) { - Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); - - StateApp.instance.scope.launch(Dispatchers.IO) { - try { - castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e); } } } else { - var str = listOf( - if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, - if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, - if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null - ).filterNotNull().joinToString(", "); - throw UnsupportedCastException(str); - } - } + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); - return true; + if (videoSource is IVideoUrlSource) { + val videoPath = "/video-${id}" + val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl(); + Logger.i(TAG, "Casting as singular video"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); + } else if (audioSource is IAudioUrlSource) { + val audioPath = "/audio-${id}" + val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl(); + Logger.i(TAG, "Casting as singular audio"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); + } else if(videoSource is IHLSManifestSource) { + if (proxyStreams || ad is ChromecastCastingDevice) { + Logger.i(TAG, "Casting as proxied HLS"); + castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); + } else { + Logger.i(TAG, "Casting as non-proxied HLS"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); + } + } else if(audioSource is IHLSManifestAudioSource) { + if (proxyStreams || ad is ChromecastCastingDevice) { + Logger.i(TAG, "Casting as proxied audio HLS"); + castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); + } else { + Logger.i(TAG, "Casting as non-proxied audio HLS"); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); + } + } else if (videoSource is LocalVideoSource) { + Logger.i(TAG, "Casting as local video"); + castLocalVideo(video, videoSource, resumePosition, speed); + } else if (audioSource is LocalAudioSource) { + Logger.i(TAG, "Casting as local audio"); + castLocalAudio(video, audioSource, resumePosition, speed); + } else if (videoSource is JSDashManifestRawSource) { + Logger.i(TAG, "Casting as JSDashManifestRawSource video"); + castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); + } else if (audioSource is JSDashManifestRawAudioSource) { + Logger.i(TAG, "Casting as JSDashManifestRawSource audio"); + castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); + } else { + var str = listOf( + if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, + if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, + if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null + ).filterNotNull().joinToString(", "); + throw UnsupportedCastException(str); + } + } + + return@withContext true; + } } fun resumeVideo(): Boolean { @@ -766,7 +752,7 @@ class StateCasting { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) val url = getLocalUrl(ad); val id = UUID.randomUUID(); @@ -1129,9 +1115,14 @@ class StateCasting { return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } + private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean { + val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true + return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier + } + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); - val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; + val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource) val url = getLocalUrl(ad); val id = UUID.randomUUID(); @@ -1236,7 +1227,7 @@ class StateCasting { } @OptIn(UnstableApi::class) - private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { + private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List { val ad = activeDevice ?: return listOf(); cleanExecutors() @@ -1283,20 +1274,48 @@ class StateCasting { } } - var dashContent = withContext(Dispatchers.IO) { + var dashContent: String = withContext(Dispatchers.IO) { + stopVideo() + //TODO: Include subtitlesURl in the future - return@withContext if (audioSource != null && videoSource != null) { - JSDashManifestMergingRawSource(videoSource, audioSource).generate() + val deferred = if (audioSource != null && videoSource != null) { + JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO) } else if (audioSource != null) { - audioSource.generate() + audioSource.generateAsync(_scopeIO) } else if (videoSource != null) { - videoSource.generate() + videoSource.generateAsync(_scopeIO) } else { Logger.e(TAG, "Expected at least audio or video to be set") null } + + if (deferred != null) { + try { + withContext(Dispatchers.Main) { + if (deferred.estDuration >= 0) { + onLoadingEstimate?.invoke(deferred.estDuration) + } else { + onLoading?.invoke(true) + } + } + deferred.await() + } finally { + if (castId == _castId.get()) { + withContext(Dispatchers.Main) { + onLoading?.invoke(false) + } + } + } + } else { + return@withContext null + } } ?: throw Exception("Dash is null") + if (castId != _castId.get()) { + Log.i(TAG, "Get DASH cancelled.") + return emptyList() + } + for (representation in representationRegex.findAll(dashContent)) { val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found") dashContent = mediaInitializationRegex.replace(dashContent) { diff --git a/app/src/main/java/com/futo/platformplayer/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..d7aa89b7 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 @@ -17,6 +17,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.V8BindObject +import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger import java.net.SocketTimeoutException import java.util.concurrent.ForkJoinPool @@ -44,6 +45,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 +124,8 @@ class PackageHttp: V8Package { _plugin.registerHttpClient(httpClient); val client = PackageHttpClient(this, httpClient); + _clients.put(client.clientId() ?: "", client); + return client; } @V8Function @@ -246,18 +260,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 +282,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 +335,7 @@ class PackageHttp: V8Package { @Transient private val _clientId: String?; + @V8Property fun clientId(): String? { return _clientId; @@ -333,6 +348,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 +455,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 +493,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 +668,9 @@ class PackageHttp: V8Package { _isOpen = true; if(hasOpen && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("open", arrayOf()); + _package._plugin.busy { + _listeners?.invokeV8Void("open", arrayOf()); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex); @@ -640,7 +680,9 @@ class PackageHttp: V8Package { override fun message(msg: String) { if(hasMessage && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("message", msg); + _package._plugin.busy { + _listeners?.invokeV8Void("message", msg); + } } catch(ex: Throwable) {} } @@ -649,7 +691,9 @@ class PackageHttp: V8Package { if(hasClosing && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("closing", code, reason); + _package._plugin.busy { + _listeners?.invokeV8Void("closing", code, reason); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex); @@ -660,7 +704,9 @@ class PackageHttp: V8Package { _isOpen = false; if(hasClosed && _listeners?.isClosed != true) { try { - _listeners?.invokeVoid("closed", code, reason); + _package._plugin.busy { + _listeners?.invokeV8Void("closed", code, reason); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); @@ -676,7 +722,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?.invokeV8Void("failure", exception.message); + } } catch(ex: Throwable){ Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 0939fbde..b99e211e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.engine.exceptions.PluginException @@ -61,7 +62,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), private var _query: String? = null private var _searchView: SearchView? = null - val onContentClicked = Event2(); + val onContentClicked = Event3, ArrayList>?>(); val onContentUrlClicked = Event2(); val onUrlClicked = Event1(); val onChannelClicked = Event1(); @@ -211,7 +212,10 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply { this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); - this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); + this.onContentClicked.subscribe { content, num -> + val results = ArrayList(_results) + this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results)) + } this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); 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 c8f0d62e..f6e57c26 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -15,6 +15,7 @@ import android.view.ViewGroup import android.widget.* import androidx.core.animation.doOnEnd import androidx.lifecycle.lifecycleScope +import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() { fun newInstance() = MenuBottomBarFragment().apply { } + @UnstableApi //Add configurable buttons here var buttonDefinitions = listOf( ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, { @@ -390,13 +392,14 @@ class MenuBottomBarFragment : MainActivityFragment() { ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate(withHistory = false) }), + ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate(withHistory = false) }), ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate(withHistory = false) }), ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { - val c = it.context ?: return@ButtonDefinition; + val c = it.context ?: return@ButtonDefinition; Logger.i(TAG, "settings preventPictureInPicture()"); it.requireFragment().preventPictureInPicture(); val intent = Intent(c, SettingsActivity::class.java); 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/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 63b60c1f..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) @@ -211,6 +211,14 @@ class ChannelFragment : MainFragment() { } } } + adapter.onShortClicked.subscribe { v, _, pagerPair -> + when (v) { + is IPlatformVideo -> { + StatePlayer.instance.clearQueue() + fragment.navigate(Triple(v, pagerPair!!.first, pagerPair.second)) + } + } + } adapter.onAddToClicked.subscribe { content -> _overlayContainer.let { if (content is IPlatformVideo) _slideUpOverlay = @@ -226,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()?.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/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt new file mode 100644 index 00000000..f70d9104 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -0,0 +1,1268 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.text.Spanned +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException +import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.downloads.VideoLocal +import com.futo.platformplayer.dp +import com.futo.platformplayer.engine.exceptions.ScriptAgeException +import com.futo.platformplayer.engine.exceptions.ScriptException +import com.futo.platformplayer.engine.exceptions.ScriptImplementationException +import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.exceptions.UnsupportedCastException +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.getNowDiffSeconds +import com.futo.platformplayer.helpers.VideoHelper +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.selectBestImage +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateMeta +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanBitrate +import com.futo.platformplayer.toHumanBytesSize +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.MonetizationView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.DescriptionOverlay +import com.futo.platformplayer.views.overlays.RepliesOverlay +import com.futo.platformplayer.views.overlays.SupportOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle +import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.CommentsList +import com.futo.platformplayer.views.video.FutoShortPlayer +import com.futo.platformplayer.views.video.FutoVideoPlayerBase +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ContentType +import com.futo.polycentric.core.Models +import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import com.futo.polycentric.core.toURLInfoSystemLinkUrl +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import userpackage.Protocol + +@UnstableApi +class ShortView : FrameLayout { + private lateinit var mainFragment: MainFragment + private val player: FutoShortPlayer + + private val channelInfo: LinearLayout + private val creatorThumbnail: CreatorThumbnail + private val channelName: TextView + private val videoTitle: TextView + private val platformIndicator: PlatformIndicator + + private val backButton: MaterialButton + private val backButtonContainer: ConstraintLayout + + private val likeContainer: FrameLayout + private val dislikeContainer: FrameLayout + private val likeButton: MaterialButton + private val likeCount: TextView + private val dislikeButton: MaterialButton + private val dislikeCount: TextView + + private val commentsButton: MaterialButton + private val shareButton: MaterialButton + private val refreshButton: MaterialButton + private val refreshButtonContainer: View + private val qualityButton: MaterialButton + + private val playPauseOverlay: FrameLayout + private val playPauseIcon: ImageView + + private val overlayLoading: FrameLayout + private val overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout + + private var overlayQualitySelector: SlideUpMenuOverlay? = null + + private var video: IPlatformVideo? = null + set(value) { + field = value + onVideoUpdated.emit(value) + } + private var videoDetails: IPlatformVideoDetails? = null + + private var playWhenReady = false + + private var _lastVideoSource: IVideoSource? = null + private var _lastAudioSource: IAudioSource? = null + private var _lastSubtitleSource: ISubtitleSource? = null + + private var loadVideoTask: TaskHandler? = null + private var loadLikesTask: TaskHandler>? = + null + + val onResetTriggered = Event0() + private val onPlayingToggled = Event1() + private val onLikesLoaded = Event3() + private val onLikeDislikeUpdated = Event1() + private val onVideoUpdated = Event1() + + private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet() + + var likes: Long = 0 + set(value) { + field = value + likeCount.text = value.toString() + } + + var dislikes: Long = 0 + set(value) { + field = value + dislikeCount.text = value.toString() + } + + constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) { + this.overlayQualityContainer = overlayQualityContainer + + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT + ) + + this.mainFragment = fragment + bottomSheet.mainFragment = fragment + } + + // Required constructor for XML inflation + constructor(context: Context) : this(context, null, null) + + // Required constructor for XML inflation with attributes + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null) + + // Required constructor for XML inflation with attributes and style + constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super( + context, attrs, defStyleAttr ?: 0 + ) { + // Inflate the layout once here + inflate(context, R.layout.view_short, this) + + // Initialize all val properties using findViewById + player = findViewById(R.id.short_player) + channelInfo = findViewById(R.id.channel_info) + creatorThumbnail = findViewById(R.id.creator_thumbnail) + channelName = findViewById(R.id.channel_name) + videoTitle = findViewById(R.id.video_title) + platformIndicator = findViewById(R.id.short_platform_indicator) + backButton = findViewById(R.id.back_button) + backButtonContainer = findViewById(R.id.back_button_container) + likeContainer = findViewById(R.id.like_container) + dislikeContainer = findViewById(R.id.dislike_container) + likeButton = findViewById(R.id.like_button) + likeCount = findViewById(R.id.like_count) + dislikeButton = findViewById(R.id.dislike_button) + dislikeCount = findViewById(R.id.dislike_count) + commentsButton = findViewById(R.id.comments_button) + shareButton = findViewById(R.id.share_button) + refreshButton = findViewById(R.id.refresh_button) + refreshButtonContainer = findViewById(R.id.refresh_button_container) + qualityButton = findViewById(R.id.quality_button) + playPauseOverlay = findViewById(R.id.play_pause_overlay) + playPauseIcon = findViewById(R.id.play_pause_icon) + overlayLoading = findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = findViewById(R.id.short_view_loader) + + player.setOnClickListener { + if (player.activelyPlaying) { + player.pause() + onPlayingToggled.emit(false) + } else { + player.play() + onPlayingToggled.emit(true) + } + } + + onPlayingToggled.subscribe { playing -> + if (playing) { + playPauseIcon.setImageResource(R.drawable.ic_play) + playPauseIcon.contentDescription = context.getString(R.string.play) + } else { + playPauseIcon.setImageResource(R.drawable.ic_pause) + playPauseIcon.contentDescription = context.getString(R.string.pause) + } + showPlayPauseIcon() + } + + onVideoUpdated.subscribe { + videoTitle.text = it?.name + platformIndicator.setPlatformFromClientID(it?.id?.pluginId) + creatorThumbnail.setThumbnail(it?.author?.thumbnail, true) + channelName.text = it?.author?.name + } + + backButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.closeSegment() + } + + channelInfo.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + mainFragment.navigate(video?.author) + } + + videoTitle.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + commentsButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + if (!bottomSheet.isAdded) { + bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG) + } + } + + shareButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val url = video?.shareUrl ?: video?.url + mainFragment.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + }, null)) + } + + refreshButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + onResetTriggered.emit() + } + + refreshButton.setOnLongClickListener { + UIDialogs.toast(context, "Reload all platform shorts pagers") + false + } + + qualityButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + showVideoSettings() + } + + likeButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val checked = !likeButton.isChecked + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + likes++ + } else { + likes-- + } + + likeButton.isChecked = checked + + if (dislikeButton.isChecked && checked) { + dislikeButton.isChecked = false + dislikes-- + } + + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + + dislikeButton.setOnClickListener { + playSoundEffect(SoundEffectConstants.CLICK) + val checked = !dislikeButton.isChecked + StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) { + if (checked) { + dislikes++ + } else { + dislikes-- + } + + dislikeButton.isChecked = checked + + if (likeButton.isChecked && checked) { + likeButton.isChecked = false + likes-- + } + + onLikeDislikeUpdated.emit( + OnLikeDislikeUpdatedArgs( + it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked + ) + ) + } + } + + onLikesLoaded.subscribe(tag) { rating, liked, disliked -> + likes = rating.likes + dislikes = rating.dislikes + likeButton.isChecked = liked + dislikeButton.isChecked = disliked + + dislikeContainer.visibility = VISIBLE + likeContainer.visibility = VISIBLE + } + + player.onPlaybackStateChanged.subscribe { + val videoSource = _lastVideoSource + + if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + val audioTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO } + + val videoTrackFormats = mutableListOf() + val audioTrackFormats = mutableListOf() + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i)) + } + if (audioTracks != null) { + for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i)) + } + + updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height } + .sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate } + .sortedBy { it.bitrate }) + } else { + updateQualitySourcesOverlay(videoDetails, null) + } + } + } + + private fun showPlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.alpha = 0f + overlay.scaleX = 0f + overlay.scaleY = 0f + overlay.visibility = VISIBLE + + overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400) + .setInterpolator(OvershootInterpolator(1.2f)).start() + + overlay.postDelayed({ + hidePlayPauseIcon() + }, 1500) + } + + private fun hidePlayPauseIcon() { + val overlay = playPauseOverlay + + overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300) + .setInterpolator(AccelerateInterpolator()).withEndAction { + overlay.visibility = GONE + }.start() + } + + // TODO merge this with the updateQualitySourcesOverlay for the normal video player + @androidx.annotation.OptIn(UnstableApi::class) + private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { + Logger.i(TAG, "updateQualitySourcesOverlay") + + val video: IPlatformVideoDetails? + val localVideoSources: List? + val localAudioSource: List? + val localSubtitleSources: List? + + val videoSources: List? + val audioSources: List? + + if (videoDetails is VideoLocal) { + video = videoLocal?.videoSerialized + localVideoSources = videoDetails.videoSource.toList() + localAudioSource = videoDetails.audioSource.toList() + localSubtitleSources = videoDetails.subtitlesSources.toList() + videoSources = null + audioSources = null + } else { + video = videoDetails + videoSources = video?.video?.videoSources?.toList() + audioSources = + if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList() + else null + if (videoLocal != null) { + localVideoSources = videoLocal.videoSource.toList() + localAudioSource = videoLocal.audioSource.toList() + localSubtitleSources = videoLocal.subtitlesSources.toList() + } else { + localVideoSources = null + localAudioSource = null + localSubtitleSources = null + } + } + + val doDedup = Settings.instance.playback.simplifySources + + val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct() + ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } + ?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct() + ?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf() + val bestAudioContainer = + audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container } + val bestAudioSources = + if (doDedup) audioSources?.filter { it.container == bestAudioContainer } + ?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource }) + ?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf() + + val canSetSpeed = true + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector = + SlideUpMenuOverlay( + this.context, overlayQualityContainer, context.getString( + R.string.quality + ), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString()) + onClick.subscribe { v -> + + player.setPlaybackRate(v.toFloat()) + setSelected(v) + + } + } else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_video), "video", (listOf( + SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { player.selectVideoTrack(-1) }) + ) + (liveStreamVideoFormats.map { + SlideUpMenuItem( + this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType + ?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { player.selectVideoTrack(it.height) }) + })) + ) + else null, if (liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup( + this.context, context.getString(R.string.stream_audio), "audio", *liveStreamAudioFormats.map { + SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) }) + }.toList().toTypedArray() + ) + else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.video), "video", *bestVideoSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup( + this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map { + val estSize = VideoHelper.estimateSourceSize(it) + val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "" + SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) }) + }.toList().toTypedArray() + ) + else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup( + this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map { + SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) }) + }.toList().toTypedArray() + ) + else null + ) + } + + private fun handleSelectVideoTrack(videoSource: IVideoSource) { + Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)") + if (_lastVideoSource == videoSource) return + + _lastVideoSource = videoSource + + playVideo(player.position) + } + + private fun handleSelectAudioTrack(audioSource: IAudioSource) { + Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)") + if (_lastAudioSource == audioSource) return + + _lastAudioSource = audioSource + + playVideo(player.position) + } + + private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) { + Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)") + var toSet: ISubtitleSource? = subtitleSource + if (_lastSubtitleSource == subtitleSource) toSet = null + + player.swapSubtitles(mainFragment.lifecycleScope, toSet) + + _lastSubtitleSource = toSet + } + + private fun showVideoSettings() { + Logger.i(TAG, "showVideoSettings") + + overlayQualitySelector?.selectOption("video", _lastVideoSource) + overlayQualitySelector?.selectOption("audio", _lastAudioSource) + overlayQualitySelector?.selectOption("subtitles", _lastSubtitleSource) + + if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) { + val videoTracks = + player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO } + + var selectedQuality: Format? = null + + if (videoTracks != null) { + for (i in 0 until videoTracks.mediaTrackGroup.length) { + if (videoTracks.mediaTrackGroup.getFormat(i).height == player.targetTrackVideoHeight) { + selectedQuality = videoTracks.mediaTrackGroup.getFormat(i) + } + } + } + + var videoMenuGroup: SlideUpMenuGroup? = null + for (view in overlayQualitySelector!!.groupItems) { + if (view is SlideUpMenuGroup && view.groupTag == "video") { + videoMenuGroup = view + } + } + + if (selectedQuality != null) { + videoMenuGroup?.getItem("auto")?.setSubText("") + overlayQualitySelector?.selectOption("video", selectedQuality) + } else { + videoMenuGroup?.getItem("auto") + ?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}") + overlayQualitySelector?.selectOption("video", "auto") + } + } + + val currentPlaybackRate = player.getPlaybackRate() + overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" } + ?.let { + (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) + } + + overlayQualitySelector?.show() + } + + @Suppress("unused") + fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) { + this.mainFragment = fragment + this.bottomSheet.mainFragment = fragment + this.overlayQualityContainer = overlayQualityContainer + } + + fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) { + if (this.video?.url == video.url) { + return + } + this.video = video + + refreshButtonContainer.visibility = if (isChannelShortsMode) { + GONE + } else { + VISIBLE + } + backButtonContainer.visibility = if (isChannelShortsMode) { + VISIBLE + } else { + GONE + } + + loadVideo(video.url) + } + + @Suppress("unused") + fun changeVideo(videoDetails: IPlatformVideoDetails) { + if (video?.url == videoDetails.url) { + return + } + + this.video = videoDetails + this.videoDetails = videoDetails + } + + fun play() { + loadLikes(this.video!!) + player.clear() + player.attach() + player.clear() + playVideo() + } + + fun pause() { + player.pause() + } + + fun stop() { + playWhenReady = false + + player.clear() + player.detach() + } + + fun cancel() { + loadVideoTask?.cancel() + loadLikesTask?.cancel() + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = VISIBLE + } else { + overlayLoading.visibility = GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + private fun loadLikes(video: IPlatformVideo) { + likeContainer.visibility = GONE + dislikeContainer.visibility = GONE + + loadLikesTask?.cancel() + loadLikesTask = + TaskHandler>( + StateApp.instance.scopeGetter, { + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = + video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null } + + val queryReferencesResponse = ApiMethods.getQueryReferences( + ApiMethods.SERVER, ref, null, null, arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data) + ) + .build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data) + ).build() + ), extraByteReferences = listOfNotNull(extraBytesRef) + ) + + Pair(ref, queryReferencesResponse) + }).success { (ref, queryReferencesResponse) -> + val likes = queryReferencesResponse.countsList[0] + val dislikes = queryReferencesResponse.countsList[1] + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray()) + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray()) + onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked) + onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like) + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike) + } else { + args.processHandle.opinion(ref, Opinion.neutral) + } + + mainFragment.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(CommentsModalBottomSheet.TAG, "Started backfill") + args.processHandle.fullyBackfillServersAnnounceExceptions() + Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill") + } catch (e: Throwable) { + Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap( + ref, args.hasLiked, args.hasDisliked + ) + } + } + + loadLikesTask?.run(video) + } + + private fun loadVideo(url: String) { + loadVideoTask?.cancel() + videoDetails = null + _lastVideoSource = null + _lastAudioSource = null + _lastSubtitleSource = null + + setLoading(true) + + loadVideoTask = TaskHandler( + StateApp.instance.scopeGetter, { + val result = StatePlatform.instance.getContentDetails(it).await() + if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}") + return@TaskHandler result + }).success { result -> + videoDetails = result + video = result + + bottomSheet.video = result + + setLoading(false) + + if (playWhenReady) playVideo() + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { e -> + Logger.w(TAG, "exception", e) + UIDialogs.showDialog( + context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", { + val id = e.config.let { if (it is SourcePluginConfig) it.id else null } + val didLogin = + if (id == null) false else StatePlugins.instance.loginPlugin(context, id) { + loadVideo(url) + } + if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login") + }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { } + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + }.exception { + Logger.w(TAG, "exception", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment) + }.exception { + Logger.w(ChannelFragment.TAG, "Failed to load video.", it) + UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment) + } + + loadVideoTask?.run(url) + } + + private fun playVideo(resumePositionMs: Long = 0) { + val videoDetails = this@ShortView.videoDetails + + if (videoDetails === null) { + playWhenReady = true + return + } + + updateQualitySourcesOverlay(videoDetails, null) + + try { + val videoSource = _lastVideoSource + ?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount()) + val audioSource = _lastAudioSource + ?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context)) + val subtitleSource = _lastSubtitleSource + ?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null) + Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") + + if (videoSource == null && audioSource == null) { + UIDialogs.showDialog( + context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY) + ) + StatePlatform.instance.clearContentDetailCache(videoDetails.url) + return + } + + val thumbnail = videoDetails.thumbnails.getHQThumbnail() + if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() + .load(thumbnail).into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + player.setArtwork(resource.toDrawable(resources)) + } + + override fun onLoadCleared(placeholder: Drawable?) { + player.setArtwork(null) + } + }) + else player.setArtwork(null) + player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0) + if (subtitleSource != null) player.swapSubtitles(mainFragment.lifecycleScope, subtitleSource) + player.seekTo(resumePositionMs) + + _lastVideoSource = videoSource + _lastAudioSource = audioSource + _lastSubtitleSource = subtitleSource + } catch (ex: UnsupportedCastException) { + Logger.e(TAG, "Failed to load cast media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex) + } catch (ex: Throwable) { + Logger.e(TAG, "Failed to load media", ex) + UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex) + } + } + + companion object { + const val TAG = "VideoDetailView" + } + + class CommentsModalBottomSheet : BottomSheetDialogFragment() { + var mainFragment: MainFragment? = null + + private lateinit var containerContent: FrameLayout + private lateinit var containerContentMain: LinearLayout + private lateinit var containerContentReplies: RepliesOverlay + private lateinit var containerContentDescription: DescriptionOverlay + private lateinit var containerContentSupport: SupportOverlay + + private lateinit var title: TextView + private lateinit var subTitle: TextView + private lateinit var channelName: TextView + private lateinit var channelMeta: TextView + private lateinit var creatorThumbnail: CreatorThumbnail + private lateinit var channelButton: LinearLayout + private lateinit var monetization: MonetizationView + private lateinit var platform: PlatformIndicator + private lateinit var textLikes: TextView + private lateinit var textDislikes: TextView + private lateinit var layoutRating: LinearLayout + private lateinit var imageDislikeIcon: ImageView + private lateinit var imageLikeIcon: ImageView + + private lateinit var description: TextView + private lateinit var descriptionContainer: LinearLayout + private lateinit var descriptionViewMore: TextView + + private lateinit var commentsList: CommentsList + private lateinit var addCommentView: AddCommentView + + private var polycentricProfile: PolycentricProfile? = null + + private lateinit var buttonPolycentric: Button + private lateinit var buttonPlatform: Button + + private var tabIndex: Int? = null + + private var contentOverlayView: View? = null + + lateinit var video: IPlatformVideoDetails + + private lateinit var behavior: BottomSheetBehavior + + private val _taskLoadPolycentricProfile = + TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) } + .exception { + Logger.w(TAG, "Failed to load claims.", it) + } + + override fun onCreateDialog( + savedInstanceState: Bundle?, + ): Dialog { + val bottomSheetDialog = + BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme) + bottomSheetDialog.setContentView(R.layout.modal_comments) + + behavior = bottomSheetDialog.behavior + + // TODO figure out how to not need all of these non null assertions + containerContent = bottomSheetDialog.findViewById(R.id.content_container)!! + containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!! + containerContentReplies = + bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!! + containerContentDescription = + bottomSheetDialog.findViewById(R.id.videodetail_container_description)!! + containerContentSupport = + bottomSheetDialog.findViewById(R.id.videodetail_container_support)!! + + title = bottomSheetDialog.findViewById(R.id.videodetail_title)!! + subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!! + channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!! + channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!! + creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!! + channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!! + monetization = bottomSheetDialog.findViewById(R.id.monetization)!! + platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!! + layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!! + textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!! + textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!! + imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!! + imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!! + + description = bottomSheetDialog.findViewById(R.id.videodetail_description)!! + descriptionContainer = + bottomSheetDialog.findViewById(R.id.videodetail_description_container)!! + descriptionViewMore = + bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!! + + addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!! + commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!! + buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!! + buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!! + + commentsList.onAuthorClick.subscribe { c -> + if (c !is PolycentricPlatformComment) { + return@subscribe + } + val id = c.author.id.value + + Logger.i(TAG, "onAuthorClick: $id") + if (id != null && id.startsWith("polycentric://")) { + val navUrl = "https://harbor.social/" + id.substring("polycentric://".length) + mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri())) + } + } + commentsList.onRepliesClick.subscribe { c -> + val replyCount = c.replyCount ?: 0 + var metadata = "" + if (replyCount > 0) { + metadata += "$replyCount " + requireContext().getString(R.string.replies) + } + + if (c is PolycentricPlatformComment) { + var parentComment: PolycentricPlatformComment = c + containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, { + val newComment = parentComment.cloneWithUpdatedReplyCount( + (parentComment.replyCount ?: 0) + 1 + ) + commentsList.replaceComment(parentComment, newComment) + parentComment = newComment + }) + } else { + containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) }) + } + animateOpenOverlayView(containerContentReplies) + } + + if (StatePolycentric.instance.enabled) { + buttonPolycentric.setOnClickListener { + setTabIndex(0) + StateMeta.instance.setLastCommentSection(0) + } + } else { + buttonPolycentric.visibility = GONE + } + + buttonPlatform.setOnClickListener { + setTabIndex(1) + StateMeta.instance.setLastCommentSection(1) + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + addCommentView.setContext(video.url, ref) + + if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) { + setTabIndex(2, true) + } else { + when (Settings.instance.comments.defaultCommentSection) { + 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) + 1 -> setTabIndex(1, true) + 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) + } + } + + containerContentDescription.onClose.subscribe { animateCloseOverlayView() } + containerContentReplies.onClose.subscribe { animateCloseOverlayView() } + + descriptionViewMore.setOnClickListener { + animateOpenOverlayView(containerContentDescription) + } + + updateDescriptionUI(video.description.fixHtmlLinks()) + + val dp5 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics) + val dp2 = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics) + + //UI + title.text = video.name + channelName.text = video.author.name + if (video.author.subscribers != null) { + channelMeta.text = if ((video.author.subscribers + ?: 0) > 0 + ) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else "" + (channelName.layoutParams as MarginLayoutParams).setMargins( + 0, (dp5 * -1).toInt(), 0, 0 + ) + } else { + channelMeta.text = "" + (channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0) + } + + video.author.let { + if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl) + else monetization.setPlatformMembership(null, null) + } + + val subTitleSegments: ArrayList = ArrayList() + if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}") + if (video.datetime != null) { + val diff = video.datetime?.getNowDiffSeconds() ?: 0 + val ago = video.datetime?.toHumanNowDiffString(true) + if (diff >= 0) subTitleSegments.add("$ago ago") + else subTitleSegments.add("available in $ago") + } + + platform.setPlatformFromClientID(video.id.pluginId) + subTitle.text = subTitleSegments.joinToString(" • ") + creatorThumbnail.setThumbnail(video.author.thumbnail, false) + + setPolycentricProfile(null, animate = false) + _taskLoadPolycentricProfile.run(video.author.id) + + when (video.rating) { + is RatingLikeDislikes -> { + val r = video.rating as RatingLikeDislikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = VISIBLE + textDislikes.visibility = VISIBLE + textDislikes.text = r.dislikes.toHumanNumber() + } + + is RatingLikes -> { + val r = video.rating as RatingLikes + layoutRating.visibility = VISIBLE + + textLikes.visibility = VISIBLE + imageLikeIcon.visibility = VISIBLE + textLikes.text = r.likes.toHumanNumber() + + imageDislikeIcon.visibility = GONE + textDislikes.visibility = GONE + } + + else -> { + layoutRating.visibility = GONE + } + } + + monetization.onSupportTap.subscribe { + containerContentSupport.setPolycentricProfile(polycentricProfile) + animateOpenOverlayView(containerContentSupport) + } + + monetization.onStoreTap.subscribe { + polycentricProfile?.systemState?.store?.let { + try { + val uri = it.toUri() + val intent = Intent(Intent.ACTION_VIEW) + intent.data = uri + requireContext().startActivity(intent) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to open URI: '${it}'.", e) + } + } + } + monetization.onUrlTap.subscribe { + mainFragment!!.navigate(it) + } + + addCommentView.onCommentAdded.subscribe { + commentsList.addComment(it) + } + + channelButton.setOnClickListener { + mainFragment!!.navigate(video.author) + } + + return bottomSheetDialog + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + animateCloseOverlayView() + } + + private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { + polycentricProfile = profile + + val dp35 = 35.dp(requireContext().resources) + val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35) + ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } + + if (avatar != null) { + creatorThumbnail.setThumbnail(avatar, animate) + } else { + creatorThumbnail.setThumbnail(video.author.thumbnail, animate) + creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()) + } + + val username = profile?.systemState?.username + if (username != null) { + channelName.text = username + } + + monetization.setPolycentricProfile(profile) + } + + private fun setTabIndex(index: Int?, forceReload: Boolean = false) { + Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})") + val changed = tabIndex != index || forceReload + if (!changed) { + return + } + + tabIndex = index + buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null)) + buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null)) + + when (index) { + null -> { + addCommentView.visibility = GONE + commentsList.clear() + } + + 0 -> { + addCommentView.visibility = VISIBLE + fetchPolycentricComments() + } + + 1 -> { + addCommentView.visibility = GONE + fetchComments() + } + } + } + + private fun fetchComments() { + Logger.i(TAG, "fetchComments") + video.let { + commentsList.load(true) { StatePlatform.instance.getComments(it) } + } + } + + private fun fetchPolycentricComments() { + Logger.i(TAG, "fetchPolycentricComments") + val video = video + val idValue = video.id.value + if (video.url.isEmpty()) { + Logger.w(TAG, "Failed to fetch polycentric comments because url was null") + commentsList.clear() + return + } + + val ref = Models.referenceFromBuffer(video.url.toByteArray()) + val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null } + commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); } + } + + private fun updateDescriptionUI(text: Spanned) { + containerContentDescription.load(text) + description.text = text + + if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE + else descriptionContainer.visibility = GONE + } + + private fun animateOpenOverlayView(view: View) { + if (contentOverlayView != null) { + Logger.e(TAG, "Content overlay already open") + return + } + + behavior.isDraggable = false + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + val animHeight = containerContentMain.height + + view.translationY = animHeight.toFloat() + view.visibility = VISIBLE + + view.animate().setDuration(300).translationY(0f).withEndAction { + contentOverlayView = view + }.start() + } + + private fun animateCloseOverlayView() { + val curView = contentOverlayView + if (curView == null) { + Logger.e(TAG, "No content overlay open") + return + } + + behavior.isDraggable = true + + val animHeight = contentOverlayView!!.height + + curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction { + curView.visibility = GONE + contentOverlayView = null + }.start() + } + + companion object { + const val TAG = "ModalBottomSheet" + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt new file mode 100644 index 00000000..f082c91d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortsFragment.kt @@ -0,0 +1,359 @@ +package com.futo.platformplayer.fragment.mainactivity.main + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.SoundEffectConstants +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.views.buttons.BigButton + +@UnstableApi +class ShortsFragment : MainFragment() { + override val isMainView: Boolean = true + override val isTab: Boolean = true + override val hasBottomBar: Boolean get() = true + + private var loadPagerTask: TaskHandler>? = null + private var nextPageTask: TaskHandler>? = null + + private var mainShortsPager: IPager? = null + private val mainShorts: MutableList = mutableListOf() + + // the pager to call next on + private var currentShortsPager: IPager? = null + + // the shorts array bound to the ViewPager2 adapter + private val currentShorts: MutableList = mutableListOf() + + private var channelShortsPager: IPager? = null + private val channelShorts: MutableList = mutableListOf() + val isChannelShortsMode: Boolean + get() = channelShortsPager != null + + private var viewPager: ViewPager2? = null + private lateinit var zeroState: LinearLayout + private lateinit var sourcesButton: BigButton + private lateinit var overlayLoading: FrameLayout + private lateinit var overlayLoadingSpinner: ImageView + private lateinit var overlayQualityContainer: FrameLayout + private var customViewAdapter: CustomViewAdapter? = null + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + override fun onShownWithView(parameter: Any?, isBack: Boolean) { + (activity as MainActivity?)?.getFragment()?.closeVideoDetails() + super.onShownWithView(parameter, isBack) + + if (parameter is Triple<*, *, *>) { + setLoading(false) + channelShorts.clear() + @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter + channelShorts.addAll(parameter.third as ArrayList) + @Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter + channelShortsPager = parameter.second as IPager + + currentShorts.clear() + currentShorts.addAll(channelShorts) + currentShortsPager = channelShortsPager + + viewPager?.adapter?.notifyDataSetChanged() + + viewPager?.post { + viewPager?.currentItem = channelShorts.indexOfFirst { + return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id + } + } + } else if (isChannelShortsMode) { + channelShortsPager = null + channelShorts.clear() + currentShorts.clear() + + if (loadPagerTask == null) { + currentShorts.addAll(mainShorts) + currentShortsPager = mainShortsPager + } else { + setLoading(true) + } + + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + } + + updateZeroState() + } + + override fun onCreateMainView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_shorts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewPager = view.findViewById(R.id.view_pager) + zeroState = view.findViewById(R.id.zero_state) + sourcesButton = view.findViewById(R.id.sources_button) + overlayLoading = view.findViewById(R.id.short_view_loading_overlay) + overlayLoadingSpinner = view.findViewById(R.id.short_view_loader) + overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview) + + sourcesButton.onClick.subscribe { + sourcesButton.playSoundEffect(SoundEffectConstants.CLICK) + navigate() + } + + setLoading(true) + + Logger.i(TAG, "Creating adapter") + val customViewAdapter = + CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) { + if (!currentShortsPager!!.hasMorePages()) { + return@CustomViewAdapter + } + nextPage() + } + customViewAdapter.onResetTriggered.subscribe { + setLoading(true) + loadPager() + + loadPagerTask!!.success { + setLoading(false) + } + } + val viewPager = viewPager!! + viewPager.adapter = customViewAdapter + + this.customViewAdapter = customViewAdapter + + if (loadPagerTask == null && currentShorts.isEmpty()) { + loadPager() + + loadPagerTask!!.success { + setLoading(false) + updateZeroState() + } + } else { + setLoading(false) + updateZeroState() + } + + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + fun play(adapter: CustomViewAdapter, position: Int) { + val recycler = (viewPager.getChildAt(0) as RecyclerView) + val viewHolder = + recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder? + + if (viewHolder == null) { + adapter.needToPlay = position + } else { + val focusedView = viewHolder.shortView + focusedView.play() + adapter.previousShownView = focusedView + } + } + + override fun onPageSelected(position: Int) { + val adapter = (viewPager.adapter as CustomViewAdapter) + if (adapter.previousShownView == null) { + // play if this page selection didn't trigger by a swipe from another page + play(adapter, position) + } else { + adapter.previousShownView?.stop() + adapter.previousShownView = null + adapter.newPosition = position + } + } + + // wait for the state to idle to prevent UI lag + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE) { + val adapter = (viewPager.adapter as CustomViewAdapter) + val position = adapter.newPosition ?: return + adapter.newPosition = null + + play(adapter, position) + } + } + }) + } + + private fun updateZeroState() { + if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) { + zeroState.visibility = View.VISIBLE + } else { + zeroState.visibility = View.GONE + } + } + + private fun nextPage() { + nextPageTask?.cancel() + + val nextPageTask = + TaskHandler>(StateApp.instance.scopeGetter, { + currentShortsPager!!.nextPage() + + return@TaskHandler currentShortsPager!!.getResults() + }).success { newVideos -> + val prevCount = customViewAdapter!!.itemCount + currentShorts.addAll(newVideos) + if (isChannelShortsMode) { + channelShorts.addAll(newVideos) + } else { + mainShorts.addAll(newVideos) + } + customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size) + nextPageTask = null + } + + nextPageTask.run(this) + + this.nextPageTask = nextPageTask + } + + // we just completely reset the data structure so we want to tell the adapter that + @SuppressLint("NotifyDataSetChanged") + private fun loadPager() { + loadPagerTask?.cancel() + + val loadPagerTask = + TaskHandler>(StateApp.instance.scopeGetter, { + val pager = StatePlatform.instance.getShorts() + + return@TaskHandler pager + }).success { pager -> + mainShorts.clear() + mainShorts.addAll(pager.getResults()) + mainShortsPager = pager + + if (!isChannelShortsMode) { + currentShorts.clear() + currentShorts.addAll(mainShorts) + currentShortsPager = pager + + // if the view pager exists go back to the beginning + viewPager?.adapter?.notifyDataSetChanged() + viewPager?.currentItem = 0 + } + + loadPagerTask = null + }.exception { err -> + val message = "Unable to load shorts $err" + Logger.i(TAG, message) + if (context != null) { + UIDialogs.showDialog( + requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action( + "Close", { }, UIDialogs.ActionStyle.PRIMARY + ) + ) + } + return@exception + } + + this.loadPagerTask = loadPagerTask + + loadPagerTask.run(this) + } + + private fun setLoading(isLoading: Boolean) { + if (isLoading) { + (overlayLoadingSpinner.drawable as Animatable?)?.start() + overlayLoading.visibility = View.VISIBLE + } else { + overlayLoading.visibility = View.GONE + (overlayLoadingSpinner.drawable as Animatable?)?.stop() + } + } + + override fun onPause() { + super.onPause() + customViewAdapter?.previousShownView?.pause() + } + + override fun onDestroy() { + super.onDestroy() + loadPagerTask?.cancel() + loadPagerTask = null + nextPageTask?.cancel() + nextPageTask = null + customViewAdapter?.previousShownView?.stop() + } + + companion object { + private const val TAG = "ShortsFragment" + + fun newInstance() = ShortsFragment() + } + + class CustomViewAdapter( + private val videos: MutableList, + private val inflater: LayoutInflater, + private val fragment: MainFragment, + private val overlayQualityContainer: FrameLayout, + private val isChannelShortsMode: () -> Boolean, + private val onNearEnd: () -> Unit, + ) : RecyclerView.Adapter() { + val onResetTriggered = Event0() + var previousShownView: ShortView? = null + var newPosition: Int? = null + var needToPlay: Int? = null + + @OptIn(UnstableApi::class) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + val shortView = ShortView(inflater, fragment, overlayQualityContainer) + shortView.onResetTriggered.subscribe { + onResetTriggered.emit() + } + return CustomViewHolder(shortView) + } + + @OptIn(UnstableApi::class) + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + holder.shortView.changeVideo(videos[position], isChannelShortsMode()) + + if (position == itemCount - 1) { + onNearEnd() + } + } + + override fun onViewRecycled(holder: CustomViewHolder) { + super.onViewRecycled(holder) + holder.shortView.cancel() + } + + override fun onViewAttachedToWindow(holder: CustomViewHolder) { + super.onViewAttachedToWindow(holder) + + if (holder.absoluteAdapterPosition == needToPlay) { + holder.shortView.play() + needToPlay = null + previousShownView = holder.shortView + } + } + + override fun getItemCount(): Int = videos.size + } + + @OptIn(UnstableApi::class) + class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 53886862..304f52b2 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 { @@ -337,13 +337,6 @@ class VideoDetailFragment() : MainFragment() { closeVideoDetails(); }; it.onMaximize.subscribe { maximizeVideoDetail(it) }; - it.onPlayChanged.subscribe { - if(isInPictureInPicture) { - val params = _viewDetail?.getPictureInPictureParams(); - if (params != null) - activity?.setPictureInPictureParams(params); - } - }; it.onEnterPictureInPicture.subscribe { Logger.i(TAG, "onEnterPictureInPicture") isInPictureInPicture = true; @@ -446,9 +439,14 @@ class VideoDetailFragment() : MainFragment() { val viewDetail = _viewDetail; Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}"); - if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { - _leavingPiP = false; + if (viewDetail === null) { + return + } + if (viewDetail.shouldEnterPictureInPicture) { + _leavingPiP = false + } + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") @@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() { } if (isFullscreen) { - viewDetail?.restoreBrightness() + viewDetail.restoreBrightness() } } @@ -627,11 +625,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 8e514ce0..ce783fa5 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,9 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.app.PictureInPictureParams import android.app.RemoteAction +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.res.Configuration @@ -13,6 +16,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.net.Uri +import android.os.Build import android.support.v4.media.session.PlaybackStateCompat import android.text.Spanned import android.util.AttributeSet @@ -47,6 +51,7 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -77,7 +82,9 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.models.JSVideo import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails +import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.StateCasting @@ -91,6 +98,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 +180,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 @@ -240,7 +249,13 @@ class VideoDetailView : ConstraintLayout { private val _buttonPins: RoundButtonGroup; //private val _buttonMore: RoundButton; - var preventPictureInPicture: Boolean = false; + var preventPictureInPicture: Boolean = false + set(value) { + if (field != value) { + field = value + onShouldEnterPictureInPictureChanged.emit() + } + } private val _addCommentView: AddCommentView; private var _tabIndex: Int? = null; @@ -309,11 +324,24 @@ class VideoDetailView : ConstraintLayout { val onClose = Event0(); val onFullscreenChanged = Event1(); val onEnterPictureInPicture = Event0(); - val onPlayChanged = Event1(); val onVideoChanged = Event2() - var allowBackground : Boolean = false - private set; + var allowBackground: Boolean = false + private set(value) { + if (field != value) { + field = value + onShouldEnterPictureInPictureChanged.emit() + } + } + + val shouldEnterPictureInPicture: Boolean + get() = !preventPictureInPicture && + !StateCasting.instance.isCasting && + Settings.instance.playback.isBackgroundPictureInPicture() && + !allowBackground && + isPlaying + + val onShouldEnterPictureInPictureChanged = Event0(); val onTouchCancel = Event0(); private var _lastPositionSaveTime: Long = -1; @@ -408,6 +436,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,12 +633,21 @@ class VideoDetailView : ConstraintLayout { } } + _player.onReloadRequired.subscribe { + fetchVideo(); + } + _player.onPlayChanged.subscribe { if (StateCasting.instance.activeDevice == null) { handlePlayChanged(it); } }; + onShouldEnterPictureInPictureChanged.subscribe { + val params = getPictureInPictureParams() + fragment.activity?.setPictureInPictureParams(params) + } + if (!isInEditMode) { StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> if (_onPauseCalled) { @@ -700,7 +745,7 @@ class VideoDetailView : ConstraintLayout { }; MediaControlReceiver.onBackgroundReceived.subscribe(this) { Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") - _player.switchToAudioMode(); + _player.switchToAudioMode(video); allowBackground = true; StateApp.instance.contextOrNull?.let { try { @@ -790,6 +835,8 @@ class VideoDetailView : ConstraintLayout { _lastVideoSource = null; _lastAudioSource = null; _lastSubtitleSource = null; + _cast.cancel() + StateCasting.instance.cancel() video = null; _container_content_liveChat?.close(); _player.clear(); @@ -917,6 +964,7 @@ class VideoDetailView : ConstraintLayout { return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD else false; } ?: false; + val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) { (video ?: _searchVideo)?.let { _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) { @@ -945,7 +993,7 @@ class VideoDetailView : ConstraintLayout { } else null, if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) { if (!allowBackground) { - _player.switchToAudioMode(); + _player.switchToAudioMode(video); allowBackground = true; it.text.text = resources.getString(R.string.background_revert); } else { @@ -1092,6 +1140,7 @@ class VideoDetailView : ConstraintLayout { //Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert? if(!allowBackground) { _player.switchToVideoMode(); + allowBackground = false; _buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background); } } @@ -1115,12 +1164,18 @@ class VideoDetailView : ConstraintLayout { when (Settings.instance.playback.backgroundPlay) { 0 -> handlePause(); 1 -> { - if(!(video?.isLive ?: false)) - _player.switchToAudioMode(); + if(!(video?.isLive ?: false)) { + _player.switchToAudioMode(video); + allowBackground = true; + } StatePlayer.instance.startOrUpdateMediaSession(context, video); } } } + + if (_player.isFullScreen) { + restoreBrightness() + } } fun onStop() { Logger.i(TAG, "onStop"); @@ -1399,8 +1454,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 +1512,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 } @@ -1820,19 +1901,30 @@ class VideoDetailView : ConstraintLayout { if (!isCasting) { setCastEnabled(false); - val thumbnail = video.thumbnails.getHQThumbnail(); - if (videoSource == null && !thumbnail.isNullOrBlank()) - Glide.with(context).asBitmap().load(thumbnail) - .into(object: CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - _player.setArtwork(BitmapDrawable(resources, resource)); - } - override fun onLoadCleared(placeholder: Drawable?) { - _player.setArtwork(null); - } - }); - else - _player.setArtwork(null); + val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let { + if (it is JSClient) + return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD + else false; + } ?: false; + + if (isLimitedVersion && _player.isAudioMode) { + _player.switchToVideoMode() + allowBackground = false; + } else { + val thumbnail = video.thumbnails.getHQThumbnail(); + if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank()) + Glide.with(context).asBitmap().load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + _player.setArtwork(BitmapDrawable(resources, resource)); + } + override fun onLoadCleared(placeholder: Drawable?) { + _player.setArtwork(null); + } + }); + else + _player.setArtwork(null); + } _player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0); if(subtitleSource != null) _player.swapSubtitles(fragment.lifecycleScope, subtitleSource); @@ -1856,11 +1948,46 @@ class VideoDetailView : ConstraintLayout { } private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") + castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed) + } - if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) { - _cast.setVideoDetails(video, resumePositionMs / 1000); - setCastEnabled(true); - } else throw IllegalStateException("Disconnected cast during loading"); + private fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin() + else if (audioSource is JSSource) audioSource.getUnderlyingPlugin() + else null + + val startId = plugin?.getUnderlyingPlugin()?.runtimeId + try { + val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = { + _cast.setLoading(it) + }, onLoadingEstimate = { + _cast.setLoading(it) + }) + + if (castingSucceeded) { + withContext(Dispatchers.Main) { + _cast.setVideoDetails(video, resumePositionMs / 1000); + setCastEnabled(true); + } + } + } catch (e: ScriptReloadRequiredException) { + Log.i(TAG, "Reload required exception", e) + if (plugin == null) + throw e + + if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId) + throw e + + StatePlatform.instance.handleReloadRequired(e, { + fetchVideo() + }); + } + } catch (e: Throwable) { + Logger.e(TAG, "loadCurrentVideoCast", e) + } + } } //Events @@ -1897,8 +2024,12 @@ 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 }); + } + + _layoutPlayerContainer.post { + onShouldEnterPictureInPictureChanged.emit() } } @@ -2156,19 +2287,19 @@ class VideoDetailView : ConstraintLayout { if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds(); val format = if(playbackSpeeds.size < 20) "%.2f" else "%.1f"; - val playbackLabels = playbackSpeeds.map { String.format(format, it) }.toMutableList(); + val playbackLabels = playbackSpeeds.map { String.format(Locale.US, format, it) }.toMutableList(); playbackLabels.add("+"); playbackLabels.add(0, "-"); - setButtons(playbackLabels, String.format(format, currentPlaybackRate)); + 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("%.2f", (currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed).toString(); + playbackSpeedString = String.format(Locale.US, "%.2f", Math.min((currentPlaybackSpeed?.toDouble() ?: 1.0) + stepSpeed, 5.0)).toString(); else if(v == "-") - playbackSpeedString = String.format("%.2f", (currentPlaybackSpeed?.toDouble() ?: 1.0) - stepSpeed).toString(); + 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 @@ -2176,11 +2307,11 @@ class VideoDetailView : ConstraintLayout { return@subscribe } - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", newPlaybackSpeed)})"); + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); ad.changeSpeed(newPlaybackSpeed) setSelected(playbackSpeedString); } else { - qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", newPlaybackSpeed)})"); + qualityPlaybackSpeedTitle?.setTitle(context.getString(R.string.playback_rate) + " (${String.format(Locale.US, "%.2f", newPlaybackSpeed)})"); _player.setPlaybackRate(playbackSpeedString.toFloat()); setSelected(playbackSpeedString); } @@ -2360,7 +2491,6 @@ class VideoDetailView : ConstraintLayout { } isPlaying = playing; - onPlayChanged.emit(playing); updateTracker(lastPositionMilliseconds, playing, true); } @@ -2373,7 +2503,7 @@ class VideoDetailView : ConstraintLayout { val d = StateCasting.instance.activeDevice; if (d != null && d.connectionState == CastConnectionState.CONNECTED) - StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) _player.hideControls(false); //TODO: Disable player? @@ -2388,7 +2518,7 @@ class VideoDetailView : ConstraintLayout { val d = StateCasting.instance.activeDevice; if (d != null && d.connectionState == CastConnectionState.CONNECTED) - StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed) else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) _player.hideControls(false); //TODO: Disable player? @@ -2404,7 +2534,7 @@ class VideoDetailView : ConstraintLayout { val d = StateCasting.instance.activeDevice; if (d != null && d.connectionState == CastConnectionState.CONNECTED) - StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); + castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); else _player.swapSubtitles(fragment.lifecycleScope, toSet); @@ -2455,7 +2585,9 @@ class VideoDetailView : ConstraintLayout { val url = _url; if (!url.isNullOrBlank()) { - setLoading(true); + fragment.lifecycleScope.launch(Dispatchers.Main) { + setLoading(true); + } _taskLoadVideo.run(url); } } @@ -2492,6 +2624,9 @@ class VideoDetailView : ConstraintLayout { setProgressBarOverlayed(false); } onFullscreenChanged.emit(fullscreen); + _layoutPlayerContainer.post { + onShouldEnterPictureInPictureChanged.emit() + } } private fun setCastEnabled(isCasting: Boolean) { @@ -2509,8 +2644,7 @@ class VideoDetailView : ConstraintLayout { _cast.visibility = View.VISIBLE; } else { StateCasting.instance.stopVideo(); - _cast.stopTimeJob(); - _cast.visibility = View.GONE; + _cast.cancel() if (video?.isLive == false) { _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); @@ -2520,6 +2654,8 @@ class VideoDetailView : ConstraintLayout { if (changed) { stopAllGestures(); } + + onShouldEnterPictureInPictureChanged.emit() } fun isLandscapeVideo(): Boolean? { @@ -2539,7 +2675,9 @@ class VideoDetailView : ConstraintLayout { } fun saveBrightness() { - _player.gestureControl.saveBrightness() + if (Settings.instance.gestureControls.useSystemBrightness) { + _player.gestureControl.saveBrightness() + } } fun restoreBrightness() { _player.gestureControl.restoreBrightness() @@ -2719,6 +2857,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) { @@ -2746,6 +2886,7 @@ class VideoDetailView : ConstraintLayout { _overlayContainer.removeAllViews(); _overlay_quality_selector?.hide(); + _container_content.visibility = GONE _player.fillHeight(false) _layoutPlayerContainer.setPadding(0, 0, 0, 0); @@ -2754,6 +2895,7 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "handleLeavePictureInPicture") if(!_player.isFullScreen) { + _container_content.visibility = VISIBLE _player.fitHeight(); _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); } else { @@ -2769,29 +2911,40 @@ class VideoDetailView : ConstraintLayout { videoSourceHeight = 9; } val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight; + val r = _player.getVideoRect() if(aspectRatio > 2.38) { videoSourceWidth = 16; videoSourceHeight = 9; + + // shrink the left and right equally to get the rect to be 16 by 9 aspect ratio + // we don't want a picture in picture mode that's more squashed than 16 by 9 + val targetWidth = r.height() * 16 / 9 + val shrinkAmount = (r.width() - targetWidth) / 2 + r.left += shrinkAmount + r.right -= shrinkAmount } else if(aspectRatio < 0.43) { videoSourceHeight = 16; videoSourceWidth = 9; } - val r = Rect(); - _player.getGlobalVisibleRect(r); - r.right = r.right - _player.paddingEnd; val playpauseAction = if(_player.playing) RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5)); else RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6)); val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7)); - return PictureInPictureParams.Builder() + + val params = PictureInPictureParams.Builder() .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setSourceRectHint(r) .setActions(listOf(toBackgroundAction, playpauseAction)) - .build(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + params.setAutoEnterEnabled(shouldEnterPictureInPicture) + } + + return params.build() } //Other @@ -2986,6 +3139,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 76652236..b29ebd87 100644 --- a/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt +++ b/app/src/main/java/com/futo/platformplayer/others/PlatformLinkMovementMethod.kt @@ -13,6 +13,8 @@ import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.timestampRegex +import com.futo.platformplayer.views.behavior.NonScrollingTextView +import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -91,7 +93,11 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe } withContext(Dispatchers.Main) { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + try { + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to start activity.", e) + } } } } 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/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index c843ea9f..47aa3959 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,9 +391,13 @@ class StatePlatform { * If a client is disabled, NO requests are made to said client */ suspend fun selectClients(vararg ids: String) { + selectClients(null, *ids); + } + suspend fun selectClients(afterLoad: (() -> Unit)?, vararg ids: String) { withContext(Dispatchers.IO) { + var removed: MutableList; synchronized(_clientsLock) { - val removed = _enabledClients.toMutableList(); + removed = _enabledClients.toMutableList(); _enabledClients.clear(); for (id in ids) { val client = getClient(id); @@ -379,12 +413,13 @@ class StatePlatform { } _enabledClientsPersistent.set(*ids); _enabledClientsPersistent.save(); - - for (oldClient in removed) { - oldClient.disable(); - onSourceDisabled.emit(oldClient); - } } + + for (oldClient in removed) { + oldClient.disable(); + onSourceDisabled.emit(oldClient); + } + afterLoad?.invoke(); }; } @@ -428,6 +463,47 @@ class StatePlatform { pager.initialize(); return pager; } + fun getShorts(): IPager { + Logger.i(TAG, "Platform - getShorts"); + var clientIdsOngoing = mutableListOf(); + val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true }; + + StateApp.instance.scopeOrNull?.let { + it.launch(Dispatchers.Default) { + try { + // plugins that take longer than 5 seconds to load are considered "slow" + delay(5000); + val slowClients = synchronized(clientIdsOngoing) { + return@synchronized clients.filter { clientIdsOngoing.contains(it.id) }; + }; + for(client in slowClients) + UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to show toast for slow source.", e) + } + } + } + + val pages = clients.parallelStream() + .map { + Logger.i(TAG, "getShorts - ${it.name}") + synchronized(clientIdsOngoing) { + clientIdsOngoing.add(it.id); + } + val shortsResult = it.fromPool(_pagerClientPool).getShorts(); + synchronized(clientIdsOngoing) { + clientIdsOngoing.remove(it.id); + } + return@map shortsResult; + } + .asSequence() + .toList() + .associateWith { 1f }; + + val pager = MultiDistributionContentPager(pages); + pager.initialize(); + return pager; + } suspend fun getHomeRefresh(scope: CoroutineScope): IPager { Logger.i(TAG, "Platform - getHome (Refresh)"); val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true }; @@ -935,7 +1011,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/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index 15c91025..eae8adf5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -38,6 +38,7 @@ class StatePlayer { //Players private var _exoplayer : PlayerManager? = null; private var _thumbnailExoPlayer : PlayerManager? = null; + private var _shortExoPlayer: PlayerManager? = null //Video Status var rotationLock: Boolean = false @@ -633,6 +634,13 @@ class StatePlayer { } return _thumbnailExoPlayer!!; } + fun getShortPlayerOrCreate(context: Context) : PlayerManager { + if(_shortExoPlayer == null) { + val player = createExoPlayer(context); + _shortExoPlayer = PlayerManager(player); + } + return _shortExoPlayer!!; + } @OptIn(UnstableApi::class) private fun createExoPlayer(context : Context): ExoPlayer { @@ -656,10 +664,13 @@ class StatePlayer { fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; + val shortPlayer = _shortExoPlayer _exoplayer = null; _thumbnailExoPlayer = null; + _shortExoPlayer = null player?.release(); thumbPlayer?.release(); + shortPlayer?.release() } diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index 6ebf7be6..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? { 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 90c334e3..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,7 +78,13 @@ 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) + } + } } } 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/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt index 22926841..90da8898 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -34,15 +34,18 @@ class PlayerManager { @Synchronized fun attach(view: PlayerView, stateName: String) { - if(view != _currentView) { - _currentView?.player = null; - switchState(stateName); - view.player = player; - _currentView = view; + if (view != _currentView) { + _currentView?.player = null + _currentView = null + switchState(stateName) + view.player = player + _currentView = view } } + fun detach() { - _currentView?.player = null; + _currentView?.player = null + _currentView = null } fun getState(name: String): PlayerState { diff --git a/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt new file mode 100644 index 00000000..bf9d3150 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt @@ -0,0 +1,381 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.util.TypedValue +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.OvershootInterpolator +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.toColorInt +import kotlin.math.* +import kotlin.random.Random +import com.futo.platformplayer.UIDialogs + +class TargetTapLoaderView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : View(context, attrs) { + private val primaryColor = "#2D63ED".toColorInt() + private val inactiveGlobalAlpha = 110 + private val idleSpeedMultiplier = .015f + private val overshootInterpolator = OvershootInterpolator(1.5f) + private val floatAccel = .03f + private val idleMaxSpeed = .35f + private val idleInitialTargets = 10 + private val idleHintText = "Waiting for media to become available" + + private var expectedDurationMs: Long? = null + private var loadStartTime = 0L + private var playStartTime = 0L + private var loaderFinished = false + private var forceIndeterminate= false + private var lastFrameTime = System.currentTimeMillis() + + private var score = 0 + private var isPlaying = false + + private val targets = mutableListOf() + private val particles = mutableListOf() + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(0.7f, 1f, 1f, 1f) + textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics) + textAlign = Paint.Align.LEFT + setShadowLayer(4f, 0f, 0f, Color.BLACK) + typeface = Typeface.DEFAULT_BOLD + } + private val idleHintPaint = Paint(textPaint).apply { + textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics) + typeface = Typeface.DEFAULT + setShadowLayer(2f, 0f, 0f, Color.BLACK) + } + private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryColor } + private val spinnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = primaryColor; strokeWidth = 12f + style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND + } + private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(50, 0, 0, 0) } + private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val particlePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val backgroundPaint = Paint() + private var spinnerShader: SweepGradient? = null + private var spinnerAngle = 0f + private val MIN_SPAWN_RATE = 1f + private val MAX_SPAWN_RATE = 20.0f + private val HIT_RATE_INCREMENT = 0.15f + private val MISS_RATE_DECREMENT = 0.09f + private var spawnRate = MIN_SPAWN_RATE + + private val frameRunnable = object : Runnable { + override fun run() { invalidate(); if (!loaderFinished) postDelayed(this, 16L) } + } + + init { setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_DOWN) handleTap(e.x, e.y); true } } + + fun startLoader(durationMs: Long? = null) { + val alreadyRunning = !loaderFinished + if (alreadyRunning && durationMs == null) { + expectedDurationMs = null + forceIndeterminate = true + return + } + + expectedDurationMs = durationMs?.takeIf { it > 0 } + forceIndeterminate = expectedDurationMs == null + loaderFinished = false + isPlaying = false + score = 0 + particles.clear() + spawnRate = MIN_SPAWN_RATE + + post { if (targets.isEmpty()) prepopulateIdleTargets() } + + loadStartTime = System.currentTimeMillis() + playStartTime = 0 + removeCallbacks(frameRunnable) + post(frameRunnable) + + if (!isIndeterminate) { + postDelayed({ + if (!loaderFinished) { + forceIndeterminate = true + expectedDurationMs = null + } + }, expectedDurationMs!!) + } + } + + fun finishLoader() { + loaderFinished = true + particles.clear() + isPlaying = false + invalidate() + } + + fun stopAndResetLoader() { + if (score > 0) { + val elapsed = (System.currentTimeMillis() - (if (playStartTime > 0) playStartTime else loadStartTime)) / 1000.0 + UIDialogs.toast("Nice! score $score | ${"%.1f".format(score / elapsed)} / s") + score = 0 + } + loaderFinished = true + isPlaying = false + targets.clear() + particles.clear() + removeCallbacks(frameRunnable) + invalidate() + } + + private val isIndeterminate get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L + + private fun handleTap(x: Float, y: Float) { + val idx = targets.indexOfFirst { !it.hit && hypot(x - it.x, y - it.y) <= it.radius } + if (idx >= 0) { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + val t = targets[idx] + t.hit = true; t.hitTime = System.currentTimeMillis() + accelerateSpawnRate() + score += if (!isIndeterminate) 10 else 5 + spawnParticles(t.x, t.y, t.radius) + + if (!isPlaying) { + isPlaying = true + playStartTime = System.currentTimeMillis() + score = 0 + spawnRate = MIN_SPAWN_RATE + targets.retainAll { it === t } + spawnTarget() + } + } else if (isPlaying) decelerateSpawnRate() + } + + private inline fun accelerateSpawnRate() { + spawnRate = (spawnRate + HIT_RATE_INCREMENT).coerceAtMost(MAX_SPAWN_RATE) + } + + private inline fun decelerateSpawnRate() { + spawnRate = (spawnRate - MISS_RATE_DECREMENT).coerceAtLeast(MIN_SPAWN_RATE) + } + + private fun spawnTarget() { + if (loaderFinished || width == 0 || height == 0) { + postDelayed({ spawnTarget() }, 200L); return + } + + if (!isPlaying) { + postDelayed({ spawnTarget() }, 500L); return + } + + val radius = Random.nextInt(40, 80).toFloat() + val x = Random.nextFloat() * (width - 2 * radius) + radius + val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius + + val baseSpeed = Random.nextFloat() + .1f + val speed = baseSpeed + val angle = Random.nextFloat() * TAU + val vx = cos(angle) * speed + val vy = sin(angle) * speed + val alpha = Random.nextInt(150, 255) + + targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy) + + val delay = (1000f / spawnRate).roundToLong() + postDelayed({ spawnTarget() }, delay) + } + + private fun prepopulateIdleTargets() { + if (width == 0 || height == 0) { + post { prepopulateIdleTargets() } + return + } + repeat(idleInitialTargets) { + val radius = Random.nextInt(40, 80).toFloat() + val x = Random.nextFloat() * (width - 2 * radius) + radius + val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius + val angle = Random.nextFloat() * TAU + val speed = (Random.nextFloat() * .3f + .05f) * idleSpeedMultiplier + val vx = cos(angle) * speed + val vy = sin(angle) * speed + val alpha = Random.nextInt(60, 110) + targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy) + } + } + + private fun spawnParticles(cx: Float, cy: Float, radius: Float) { + repeat(12) { + val angle = Random.nextFloat() * TAU + val speed = Random.nextFloat() * 5f + 2f + val vx = cos(angle) * speed + val vy = sin(angle) * speed + val col = ColorUtils.setAlphaComponent(primaryColor, Random.nextInt(120, 255)) + particles += Particle(cx, cy, vx, vy, System.currentTimeMillis(), col) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val now = System.currentTimeMillis() + val deltaMs = now - lastFrameTime + lastFrameTime = now + + drawBackground(canvas) + drawTargets(canvas, now) + drawParticles(canvas, now) + + if (!loaderFinished) { + if (isIndeterminate) drawIndeterminateSpinner(canvas, deltaMs) + else drawDeterministicProgressBar(canvas, now) + } + + if (isPlaying) { + val margin = 24f + val scoreTxt = "Score: $score" + val speedTxt = "Speed: ${"%.2f".format(spawnRate)}/s" + val maxWidth = width - margin + val needRight = max(textPaint.measureText(scoreTxt), textPaint.measureText(speedTxt)) > maxWidth + + val alignX = if (needRight) (width - margin) else margin + textPaint.textAlign = if (needRight) Paint.Align.RIGHT else Paint.Align.LEFT + + canvas.drawText(scoreTxt, alignX, textPaint.textSize + margin, textPaint) + canvas.drawText(speedTxt, alignX, 2*textPaint.textSize + margin + 4f, textPaint) + textPaint.textAlign = Paint.Align.LEFT + } + else if (loaderFinished) + canvas.drawText("Loading Complete!", width/2f, height/2f, textPaint.apply { textAlign = Paint.Align.CENTER }) + else { + idleHintPaint.textAlign = Paint.Align.CENTER + canvas.drawText(idleHintText, width / 2f, height - 48f, idleHintPaint) + } + } + + private fun drawBackground(canvas: Canvas) { + val colors = intArrayOf( + Color.rgb(20, 20, 40), + Color.rgb(15, 15, 30), + Color.rgb(10, 10, 20), + Color.rgb( 5, 5, 10), + Color.BLACK + ) + val pos = floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f) + + if (backgroundPaint.shader == null) { + backgroundPaint.shader = LinearGradient( + 0f, 0f, 0f, height.toFloat(), + colors, pos, + Shader.TileMode.CLAMP + ) + } + + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint) + } + + private fun drawTargets(canvas: Canvas, now: Long) { + val expireMsActive = if (isIndeterminate) 2500L else 1500L + val it = targets.iterator() + while (it.hasNext()) { + val t = it.next() + if (t.hit && now - t.hitTime > 300L) { it.remove(); continue } + if (isPlaying && !t.hit && now - t.spawnTime > expireMsActive) { + it.remove(); decelerateSpawnRate(); continue + } + t.x += t.vx; t.y += t.vy + t.vx += (Random.nextFloat() - .5f) * floatAccel + t.vy += (Random.nextFloat() - .5f) * floatAccel + val speedCap = if (isPlaying) Float.MAX_VALUE else idleMaxSpeed + val mag = hypot(t.vx, t.vy) + if (mag > speedCap) { + val s = speedCap / mag + t.vx *= s; t.vy *= s + } + if (t.x - t.radius < 0 || t.x + t.radius > width) t.vx *= -1 + if (t.y - t.radius < 0 || t.y + t.radius > height) t.vy *= -1 + val scale = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f,1f) + else { + val e = now - t.spawnAnimStart + if (e < 300L) overshootInterpolator.getInterpolation(e/300f) + else 1f + .02f * sin(((now - t.spawnAnimStart)/1000f)*TAU + t.pulseOffset) + } + val animAlpha = if (t.hit) ((1f - scale)*255).toInt() else 255 + val globalAlpha = if (isPlaying) 255 else inactiveGlobalAlpha + val alpha = (animAlpha * t.baseAlpha /255f * globalAlpha/255f).toInt().coerceAtMost(255) + val r = max(1f, t.radius*scale) + val outerCol = ColorUtils.setAlphaComponent(primaryColor, alpha) + val midCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.7f).toInt()) + val innerCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.4f).toInt()) + outerRingPaint.color = outerCol; middleRingPaint.color = midCol; centerDotPaint.color = innerCol + + glowPaint.shader = RadialGradient(t.x, t.y, r, outerCol, Color.TRANSPARENT, Shader.TileMode.CLAMP) + + canvas.drawCircle(t.x, t.y, r*1.2f, glowPaint) + canvas.drawCircle(t.x+4f, t.y+4f, r, shadowPaint) + canvas.drawCircle(t.x, t.y, r, outerRingPaint) + canvas.drawCircle(t.x, t.y, r*.66f, middleRingPaint) + canvas.drawCircle(t.x, t.y, r*.33f, centerDotPaint) + } + } + + private fun drawParticles(canvas: Canvas, now: Long) { + val lifespan = 400L + val it = particles.iterator() + while (it.hasNext()) { + val p = it.next() + val age = now - p.startTime + if (age > lifespan) { it.remove(); continue } + val a = ((1f - age/lifespan.toFloat())*255).toInt() + particlePaint.color = ColorUtils.setAlphaComponent(p.baseColor, a) + p.x += p.vx; p.y += p.vy + canvas.drawCircle(p.x, p.y, 6f, particlePaint) + } + } + + private fun drawDeterministicProgressBar(canvas: Canvas, now: Long) { + val dur = expectedDurationMs ?: return + val prog = ((now - loadStartTime) / dur.toFloat()).coerceIn(0f, 1f) + val eased = AccelerateDecelerateInterpolator().getInterpolation(prog) + val h = 20f; val r=10f + canvas.drawRoundRect(RectF(0f, height-h, width*eased, height.toFloat()), r, r, progressBarPaint) + } + + private fun drawIndeterminateSpinner(canvas: Canvas, dt: Long) { + val cx=width/2f; val cy=height/2f; val r=min(width,height)/6f + spinnerAngle = (spinnerAngle + .25f*dt)%360f + if(spinnerShader == null) spinnerShader = SweepGradient(cx,cy,intArrayOf(Color.TRANSPARENT,Color.WHITE,Color.TRANSPARENT),floatArrayOf(0f,.5f,1f)) + spinnerPaint.shader = spinnerShader + val glow = Paint(spinnerPaint).apply{ maskFilter = BlurMaskFilter(15f,BlurMaskFilter.Blur.SOLID) } + val sweep = 270f + canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,glow) + canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,spinnerPaint) + } + + private data class Target( + var x: Float, + var y: Float, + val radius: Float, + val spawnTime: Long, + var hit: Boolean = false, + var hitTime: Long = 0L, + val baseAlpha: Int = 255, + var vx: Float=0f, + var vy:Float=0f, + val spawnAnimStart: Long = System.currentTimeMillis(), + val pulseOffset: Float = Random.nextFloat() * TAU + ) + private data class Particle( + var x:Float, + var y:Float, + val vx:Float, + val vy:Float, + val startTime:Long, + val baseColor:Int + ) + + private companion object { private const val TAU = (2 * Math.PI).toFloat() } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index 3bd06903..c13a2df0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -9,8 +9,10 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.constructs.Event3 import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment @@ -38,6 +40,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec val onContentUrlClicked = Event2() val onUrlClicked = Event1() val onContentClicked = Event2() + val onShortClicked = Event3, ArrayList>?>() val onChannelClicked = Event1() val onAddToClicked = Event1() val onAddToQueueClicked = Event1() @@ -81,7 +84,9 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec when (_tabs[position]) { ChannelTab.VIDEOS -> { fragment = ChannelContentsFragment.newInstance().apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) + onContentClicked.subscribe { video, num, _ -> + this@ChannelViewPagerAdapter.onContentClicked.emit(video, num) + } onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) @@ -94,7 +99,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec ChannelTab.SHORTS -> { fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply { - onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit) + onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit) onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit) onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit) onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/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/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 6e3a8860..2d1aa511 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 @@ -108,12 +108,20 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { } withContext(Dispatchers.Main) { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + try { + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to start activity.", e) + } } } } else { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + try { + c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to start activity.", e) + } } } } 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..161f3dc3 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 @@ -29,6 +30,7 @@ import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.formatDuration import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -53,11 +55,14 @@ class CastView : ConstraintLayout { private val _timeBar: DefaultTimeBar; private val _background: FrameLayout; private val _gestureControlView: GestureControlView; + private val _loaderGame: TargetTapLoaderView private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main); private var _updateTimeJob: Job? = null; private var _inPictureInPicture: Boolean = false; private var _chapters: List? = null; private var _currentChapter: IChapter? = null; + private var _speedHoldPrevRate = 1.0 + private var _speedHoldWasPlaying = false val onChapterChanged = Event2(); val onMinimizeClick = Event0(); @@ -85,8 +90,25 @@ class CastView : ConstraintLayout { _timeBar = findViewById(R.id.time_progress); _background = findViewById(R.id.layout_background); _gestureControlView = findViewById(R.id.gesture_control); + _loaderGame = findViewById(R.id.loader_overlay) + _loaderGame.visibility = View.GONE + _gestureControlView.fullScreenGestureEnabled = false _gestureControlView.setupTouchArea(); + _gestureControlView.onSpeedHoldStart.subscribe { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + _speedHoldWasPlaying = d.isPlaying + _speedHoldPrevRate = d.speed + if (d.canSetSpeed) + d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed()) + d.resumeVideo() + } + _gestureControlView.onSpeedHoldEnd.subscribe { + val d = StateCasting.instance.activeDevice ?: return@subscribe; + if (!_speedHoldWasPlaying) d.pauseVideo() + d.changeSpeed(_speedHoldPrevRate) + } + _gestureControlView.onSeek.subscribe { val d = StateCasting.instance.activeDevice ?: return@subscribe; StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000); @@ -180,6 +202,12 @@ class CastView : ConstraintLayout { _updateTimeJob = null; } + fun cancel() { + stopTimeJob() + setLoading(false) + visibility = View.GONE + } + fun stopAllGestures() { _gestureControlView.stopAllGestures(); } @@ -262,6 +290,7 @@ class CastView : ConstraintLayout { _textDuration.text = (video.duration * 1000).formatDuration(); _timeBar.setPosition(position); _timeBar.setDuration(video.duration); + setLoading(false) } @OptIn(UnstableApi::class) @@ -278,6 +307,7 @@ class CastView : ConstraintLayout { _updateTimeJob?.cancel(); _updateTimeJob = null; _scope.cancel(); + setLoading(false) } private fun getPlaybackStateCompat(): Int { @@ -288,4 +318,19 @@ class CastView : ConstraintLayout { else -> PlaybackStateCompat.STATE_PAUSED; } } + + fun setLoading(isLoading: Boolean) { + if (isLoading) { + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader() + } else { + _loaderGame.visibility = View.GONE + _loaderGame.stopAndResetLoader() + } + } + + fun setLoading(expectedDurationMs: Int) { + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader(expectedDurationMs.toLong()) + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ToggleField.kt index 7675a791..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 @@ -90,7 +90,6 @@ class ToggleField : TableRow, IField { val advancedFieldAttr = field.getAnnotation(AdvancedField::class.java) if(advancedFieldAttr != null || advanced) { - // Logger.w("ToggleField", "Found advanced field: " + field.name); isAdvanced = true; } 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..a2ed19ef 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 { @@ -66,10 +64,11 @@ class LiveChatOverlay : LinearLayout { private val _overlayRaid: ConstraintLayout; private val _overlayRaid_Name: TextView; + private val _overlayRaid_Message: TextView; private val _overlayRaid_Thumbnail: ImageView; private val _overlayRaid_ButtonGo: Button; - private val _overlayRaid_ButtonPrevent: Button; + private val _overlayRaid_ButtonDismiss: Button; private val _textViewers: TextView; @@ -148,9 +147,10 @@ class LiveChatOverlay : LinearLayout { _overlayRaid = findViewById(R.id.overlay_raid); _overlayRaid_Name = findViewById(R.id.raid_name); + _overlayRaid_Message = findViewById(R.id.textRaidMessage); _overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail); _overlayRaid_ButtonGo = findViewById(R.id.raid_button_go); - _overlayRaid_ButtonPrevent = findViewById(R.id.raid_button_prevent); + _overlayRaid_ButtonDismiss = findViewById(R.id.raid_button_prevent); _overlayRaid.visibility = View.GONE; @@ -159,7 +159,7 @@ class LiveChatOverlay : LinearLayout { onRaidNow.emit(it); } } - _overlayRaid_ButtonPrevent.setOnClickListener { + _overlayRaid_ButtonDismiss.setOnClickListener { _currentRaid?.let { _currentRaid = null; _overlayRaid.visibility = View.GONE; @@ -291,10 +291,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 +372,15 @@ class LiveChatOverlay : LinearLayout { } else _overlayRaid.visibility = View.GONE; + + if(raid?.isOutgoing ?: false) { + _overlayRaid_ButtonGo.visibility = View.VISIBLE + _overlayRaid_Message.text = "Viewers are raiding"; + } + else { + _overlayRaid_ButtonGo.visibility = View.GONE; + _overlayRaid_Message.text = "Raid incoming from"; + } } } fun setViewCount(viewCount: Int) { diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt index 27befb1e..0dba3c4f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt @@ -19,7 +19,9 @@ class WebviewOverlay : LinearLayout { inflate(context, R.layout.overlay_webview, this) _topbar = findViewById(R.id.topbar); _webview = findViewById(R.id.webview); - _webview.settings.javaScriptEnabled = true; + if (!isInEditMode){ + _webview.settings.javaScriptEnabled = true; + } _topbar.onClose.subscribe(this, onClose::emit); } 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/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt index a1ccd142..7a3d5440 100644 --- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -68,15 +68,7 @@ class CommentsList : ConstraintLayout { UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() }); }; - private val _scrollListener = object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy); - onScrolled(); - - val totalScrollDistance = recyclerView.computeVerticalScrollOffset() - _layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE - } - }; + private val _scrollListener: RecyclerView.OnScrollListener private var _loader: (suspend () -> IPager)? = null; private val _adapterComments: InsertedViewAdapterWithLoader; @@ -131,6 +123,14 @@ class CommentsList : ConstraintLayout { _llmReplies = LinearLayoutManager(context); _recyclerComments.layoutManager = _llmReplies; _recyclerComments.adapter = _adapterComments; + _scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy); + onScrolled(); + + _layoutScrollToTop.visibility = if (_llmReplies.findFirstCompletelyVisibleItemPosition() > 5) View.VISIBLE else View.GONE + } + }; _recyclerComments.addOnScrollListener(_scrollListener); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt new file mode 100644 index 00000000..cb5f1240 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoShortPlayer.kt @@ -0,0 +1,153 @@ +package com.futo.platformplayer.views.video + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.LinearInterpolator +import androidx.annotation.OptIn +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import androidx.media3.ui.TimeBar +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.video.PlayerManager + +@UnstableApi +class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) : + FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) { + + companion object { + private const val TAG = "FutoShortVideoPlayer" + private const val PLAYER_STATE_NAME: String = "ShortPlayer" + } + + private var playerAttached = false + private val videoView: PlayerView + private val progressBar: DefaultTimeBar + private lateinit var player: PlayerManager + private var progressAnimator: ValueAnimator = createProgressBarAnimator() + + val onPlaybackStateChanged = Event1(); + + private var playerEventListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny( + Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED + ) + ) { + progressAnimator.cancel() + if (player.duration >= 0) { + progressAnimator.duration = player.duration + setProgressBarDuration(player.duration) + progressAnimator.currentPlayTime = player.currentPosition + } + + if (player.isPlaying) { + progressAnimator.start() + } + } + + if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + onPlaybackStateChanged.emit(player.playbackState) + } + } + } + + init { + LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true) + videoView = findViewById(R.id.short_player_view) + progressBar = findViewById(R.id.short_player_progress_bar) + + if (!isInEditMode) { + player = StatePlayer.instance.getShortPlayerOrCreate(context) + player.player.repeatMode = Player.REPEAT_MODE_ONE + } + + progressBar.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + progressAnimator.cancel() + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) {} + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) { + progressAnimator.currentPlayTime = player.player.currentPosition + progressAnimator.duration = player.player.duration + progressAnimator.start() + return + } + + // the progress bar should never be available to the user without the player being attached to this view + assert(playerAttached) + seekTo(position) + } + }) + } + + @OptIn(UnstableApi::class) + private fun createProgressBarAnimator(): ValueAnimator { + return ValueAnimator.ofFloat(0f, 1f).apply { + interpolator = LinearInterpolator() + + addUpdateListener { animation -> + progressBar.setPosition(animation.currentPlayTime) + } + } + } + + fun setProgressBarDuration(duration: Long) { + progressBar.setDuration(duration) + } + + /** + * Attaches this short player instance to the exo player instance for shorts + */ + fun attach() { + // connect the exo player for shorts to the view for this instance + player.attach(videoView, PLAYER_STATE_NAME) + + // direct the base player what exo player instance to use + changePlayer(player) + + playerAttached = true + + player.player.addListener(playerEventListener) + } + + fun detach() { + playerAttached = false + player.player.removeListener(playerEventListener) + player.detach() + } + + @OptIn(UnstableApi::class) + fun setArtwork(drawable: Drawable?) { + if (drawable != null) { + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL + videoView.defaultArtwork = drawable + } else { + videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF + videoView.defaultArtwork = null + } + } + + fun getPlaybackRate(): Float { + return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f + } + + fun setPlaybackRate(playbackRate: Float) { + val exoPlayer = exoPlayer?.player + Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}") + + val param = PlaybackParameters(playbackRate) + exoPlayer?.playbackParameters = param + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index e209f937..f6432752 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,9 +1,13 @@ package com.futo.platformplayer.views.video +import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.content.res.Resources +import android.graphics.Bitmap import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.media.AudioManager import android.net.Uri @@ -28,6 +32,9 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerView import androidx.media3.ui.TimeBar +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -35,6 +42,7 @@ import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -43,9 +51,13 @@ import com.futo.platformplayer.formatDuration import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView +import com.futo.platformplayer.views.others.ProgressBar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.Executors @@ -117,6 +129,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 +162,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onChapterClicked = Event1(); + private val _loaderGame: TargetTapLoaderView + @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.video_view, this, true); @@ -187,6 +204,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); + _loaderGame = findViewById(R.id.loader_overlay) + _loaderGame.visibility = View.GONE + _control_chapter.setOnClickListener { _currentChapter?.let { onChapterClicked.emit(it); @@ -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) { @@ -464,6 +498,13 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_autoplay_fullscreen.setColorFilter(ContextCompat.getColor(context, if (StatePlayer.instance.autoplay) com.futo.futopay.R.color.primary else R.color.white)) } + fun getVideoRect(): Rect { + val r = Rect() + // this is the only way i could reliably get a reference to a view that matches perfectly with the video playback + _videoView.subtitleView?.getGlobalVisibleRect(r) + return r + } + private fun setSystemBrightness(brightness: Float) { Log.i(TAG, "setSystemBrightness $brightness") if (android.provider.Settings.System.canWrite(context)) { @@ -848,4 +889,44 @@ class FutoVideoPlayer : FutoVideoPlayerBase { override fun onSurfaceSizeChanged(width: Int, height: Int) { gestureControl.resetZoomPan() } + + override fun setLoading(isLoading: Boolean) { + if (isLoading) { + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader() + } else { + _loaderGame.visibility = View.GONE + _loaderGame.stopAndResetLoader() + } + } + + override fun setLoading(expectedDurationMs: Int) { + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader(expectedDurationMs.toLong()) + } + + override fun switchToVideoMode() { + super.switchToVideoMode() + setArtwork(null) + } + + override fun switchToAudioMode(video: IPlatformVideoDetails?) { + super.switchToAudioMode(video) + val thumbnail = video?.thumbnails?.getHQThumbnail() + if (!thumbnail.isNullOrBlank()) { + Glide.with(context).asBitmap().load(thumbnail) + .into(object : CustomTarget() { + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + setArtwork(BitmapDrawable(resources, resource)); + } + + override fun onLoadCleared(placeholder: Drawable?) { + setArtwork(null); + } + }) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 60c5dbf2..6bb019db 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -1,12 +1,18 @@ package com.futo.platformplayer.views.video import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet +import android.view.LayoutInflater import android.widget.RelativeLayout import androidx.annotation.OptIn +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -28,7 +34,13 @@ import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -52,27 +64,36 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException +import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException +import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment +import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.views.video.datasources.PluginMediaDrmCallback import com.futo.platformplayer.views.video.datasources.JSHttpDataSource import getHttpDataSourceFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.File +import java.util.concurrent.atomic.AtomicInteger import kotlin.math.abs abstract class FutoVideoPlayerBase : RelativeLayout { private val TAG = "FutoVideoPlayerBase" - private val TEMP_DIRECTORY = StateApp.instance.getTempDirectory(); - private var _mediaSource: MediaSource? = null; var lastVideoSource: IVideoSource? = null @@ -108,9 +129,12 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val onPositionDiscontinuity = Event1(); val onDatasourceError = Event1(); + val onReloadRequired = Event0(); + private var _didCallSourceChange = false; private var _lastState: Int = -1; - + private val _swapIdAudio = AtomicInteger(0) + private val _swapIdVideo = AtomicInteger(0) var targetTrackVideoHeight = -1 private set @@ -249,7 +273,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { StateApp.instance.onConnectionAvailable.remove(_referenceObject); } - fun switchToVideoMode() { + open fun switchToVideoMode() { Logger.i(TAG, "Switching to Video Mode"); isAudioMode = false; val player = exoPlayer ?: return @@ -259,7 +283,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioMode) .build() } - fun switchToAudioMode() { + open fun switchToAudioMode(video: IPlatformVideoDetails?) { Logger.i(TAG, "Switching to Audio Mode"); isAudioMode = true; val player = exoPlayer ?: return @@ -348,8 +372,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); @@ -427,13 +453,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout { private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean { + setLoading(false) + val swapId = _swapIdVideo.incrementAndGet() _lastGeneratedDash = null; val didSet = when(videoSource) { is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; } is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; } is IDashManifestWidevineSource -> { swapVideoSourceDashWidevine(videoSource); true } is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;} - is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume); + is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume, swapId); is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; } is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; } is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; } @@ -444,11 +472,13 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return didSet; } private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean { + setLoading(false) + val swapId = _swapIdAudio.incrementAndGet() val didSet = when(audioSource) { is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; } is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; } is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; } - is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume); + is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId); is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; } is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; } null -> { _lastAudioMediaSource = null; true; } @@ -555,22 +585,41 @@ abstract class FutoVideoPlayerBase : RelativeLayout { }.createMediaSource(MediaItem.fromUri(videoSource.url)) } @OptIn(UnstableApi::class) - private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean { + private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean, swapId: Int): Boolean { Logger.i(TAG, "Loading VideoSource [Dash]"); 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(); + if (_swapIdVideo.get() != swapId) { + return@launch + } + + 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( @@ -585,8 +634,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; @@ -669,27 +733,69 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } @OptIn(UnstableApi::class) - private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean { + private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean, swapId: Int): 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(); + if (_swapIdAudio.get() != swapId) { + return@launch + } + withContext(Dispatchers.Main) { + setLoading(false) + } + if(generated != null) { + val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource)) + audioSource.getHttpDataSourceFactory() + else + DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT); + withContext(Dispatchers.Main) { + _lastVideoMediaSource = DashMediaSource.Factory(dataSource) + .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url), + ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0)))); + loadSelectedSources(play, resume); + } + } + } + catch(reloadRequired: ScriptReloadRequiredException) { + Logger.i(TAG, "Reload required detected"); + val plugin = audioSource.getUnderlyingPlugin(); + if(plugin == null) + return@launch; + if(startId != -1 && plugin.getUnderlyingPlugin()?.runtimeId != startId) + return@launch; + StatePlatform.instance.reEnableClient(plugin.id, { + onReloadRequired.emit(); + }); + } + catch(ex: Throwable) { + + } finally { + withContext(Dispatchers.Main) { + setLoading(false) } } } 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( @@ -811,6 +917,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { fun clear() { exoPlayer?.player?.stop(); exoPlayer?.player?.clearMediaItems(); + setLoading(false) + _swapIdVideo.incrementAndGet() + _swapIdAudio.incrementAndGet() _lastVideoMediaSource = null; _lastAudioMediaSource = null; _lastSubtitleMediaSource = null; @@ -890,6 +999,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/drawable/button_shadow.xml b/app/src/main/res/drawable/button_shadow.xml new file mode 100644 index 00000000..8b2e08a8 --- /dev/null +++ b/app/src/main/res/drawable/button_shadow.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/desktop_comments.xml b/app/src/main/res/drawable/desktop_comments.xml new file mode 100644 index 00000000..acdb15b4 --- /dev/null +++ b/app/src/main/res/drawable/desktop_comments.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_gear.xml b/app/src/main/res/drawable/desktop_gear.xml new file mode 100644 index 00000000..2001c903 --- /dev/null +++ b/app/src/main/res/drawable/desktop_gear.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_refresh.xml b/app/src/main/res/drawable/desktop_refresh.xml new file mode 100644 index 00000000..9625ff95 --- /dev/null +++ b/app/src/main/res/drawable/desktop_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_share.xml b/app/src/main/res/drawable/desktop_share.xml new file mode 100644 index 00000000..a98111ad --- /dev/null +++ b/app/src/main/res/drawable/desktop_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_down.xml b/app/src/main/res/drawable/desktop_thumb_down.xml new file mode 100644 index 00000000..ca85aa53 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_down.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_down_filled.xml b/app/src/main/res/drawable/desktop_thumb_down_filled.xml new file mode 100644 index 00000000..7939d8b8 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_up.xml b/app/src/main/res/drawable/desktop_thumb_up.xml new file mode 100644 index 00000000..8a8eb280 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/desktop_thumb_up_filled.xml b/app/src/main/res/drawable/desktop_thumb_up_filled.xml new file mode 100644 index 00000000..5e4a7790 --- /dev/null +++ b/app/src/main/res/drawable/desktop_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml new file mode 100644 index 00000000..f67f9d5c --- /dev/null +++ b/app/src/main/res/drawable/ic_comment.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_high_quality.xml b/app/src/main/res/drawable/ic_high_quality.xml new file mode 100644 index 00000000..4afa3e96 --- /dev/null +++ b/app/src/main/res/drawable/ic_high_quality.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml index 68758978..f9ae21c4 100644 --- a/app/src/main/res/drawable/ic_smart_display.xml +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M405.85,617L619.69,478.77L405.85,341.31L405.85,617ZM175.38,760Q152.33,760 136.16,743.84Q120,727.67 120,704.62L120,255.38Q120,232.33 136.16,216.16Q152.33,200 175.38,200L784.62,200Q807.67,200 823.84,216.16Q840,232.33 840,255.38L840,704.62Q840,727.67 823.84,743.84Q807.67,760 784.62,760L175.38,760ZM175.38,729.23L784.62,729.23Q793.85,729.23 801.54,721.54Q809.23,713.85 809.23,704.62L809.23,255.38Q809.23,246.15 801.54,238.46Q793.85,230.77 784.62,230.77L175.38,230.77Q166.15,230.77 158.46,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 158.46,721.54Q166.15,729.23 175.38,729.23ZM150.77,729.23Q150.77,729.23 150.77,721.54Q150.77,713.85 150.77,704.62L150.77,255.38Q150.77,246.15 150.77,238.46Q150.77,230.77 150.77,230.77L150.77,230.77Q150.77,230.77 150.77,238.46Q150.77,246.15 150.77,255.38L150.77,704.62Q150.77,713.85 150.77,721.54Q150.77,729.23 150.77,729.23Z"/> diff --git a/app/src/main/res/drawable/ic_smart_display_filled.xml b/app/src/main/res/drawable/ic_smart_display_filled.xml new file mode 100644 index 00000000..14245c9c --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml index 8de5f492..3a80a3e3 100644 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M262.65,192.31L666,192.31L666,628.92L415.69,880L403.6,871.19Q398.38,866.31 395.38,859.23Q392.38,852.15 392.38,843.77L392.38,839.92L433.54,628.92L136.85,628.92Q115.46,628.92 98.46,611.92Q81.46,594.92 81.46,573.54L81.46,523.18Q81.46,517.62 81.12,511.27Q80.77,504.92 83,499.46L195.15,237.15Q202.49,218.21 222.44,205.26Q242.39,192.31 262.65,192.31ZM635.23,223.08L256.69,223.08Q248.23,223.08 239.38,227.69Q230.54,232.31 225.92,243.08L112.23,512.08L112.23,573.54Q112.23,583.54 119.15,590.85Q126.08,598.15 136.85,598.15L470.62,598.15L424.54,829.46L635.23,615.46L635.23,223.08ZM635.23,615.46L635.23,615.46L635.23,598.15L635.23,598.15Q635.23,598.15 635.23,590.85Q635.23,583.54 635.23,573.54L635.23,512.08L635.23,243.08Q635.23,232.31 635.23,227.69Q635.23,223.08 635.23,223.08L635.23,223.08L635.23,615.46ZM666,628.92L666,598.15L809,598.15L809,223.08L666,223.08L666,192.31L839.77,192.31L839.77,628.92L666,628.92Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_down_filled.xml b/app/src/main/res/drawable/ic_thumb_down_filled.xml new file mode 100644 index 00000000..5517d2c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml index fdcf53d4..489f31d9 100644 --- a/app/src/main/res/drawable/ic_thumb_up.xml +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -1,9 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M696.77,800L293.54,800L293.54,363.38L543.08,112.31L555.84,121.12Q561.15,126 564.15,133.08Q567.15,140.15 567.15,147.77L567.15,152.38L525.23,363.38L822.69,363.38Q844.08,363.38 861.08,380.38Q878.08,397.38 878.08,418.77L878.08,469.13Q878.08,474.69 878.04,481.04Q878,487.38 875.77,492.85L764.38,755.15Q756,774.22 736.19,787.11Q716.38,800 696.77,800ZM324.31,769.23L702.85,769.23Q710.54,769.23 719.77,764.62Q729,760 733.62,749.23L847.31,480.23L847.31,418.77Q847.31,408.77 840,401.46Q832.69,394.15 822.69,394.15L488.92,394.15L534.23,162.85L324.31,376.85L324.31,769.23ZM324.31,376.85L324.31,376.85L324.31,394.15L324.31,394.15Q324.31,394.15 324.31,401.46Q324.31,408.77 324.31,418.77L324.31,480.23L324.31,749.23Q324.31,760 324.31,764.62Q324.31,769.23 324.31,769.23L324.31,769.23L324.31,376.85ZM293.54,363.38L293.54,394.15L150.54,394.15L150.54,769.23L293.54,769.23L293.54,800L119.77,800L119.77,363.38L293.54,363.38Z"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_thumb_up_filled.xml b/app/src/main/res/drawable/ic_thumb_up_filled.xml new file mode 100644 index 00000000..03a4da48 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_bar.xml b/app/src/main/res/drawable/progress_bar.xml new file mode 100644 index 00000000..d459af14 --- /dev/null +++ b/app/src/main/res/drawable/progress_bar.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_down_selector.xml b/app/src/main/res/drawable/thumb_down_selector.xml new file mode 100644 index 00000000..4ae564a7 --- /dev/null +++ b/app/src/main/res/drawable/thumb_down_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_up_selector.xml b/app/src/main/res/drawable/thumb_up_selector.xml new file mode 100644 index 00000000..97d02623 --- /dev/null +++ b/app/src/main/res/drawable/thumb_up_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1708eeb4..df3abc69 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_polycentric_backup.xml b/app/src/main/res/layout/activity_polycentric_backup.xml index 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/activity_test.xml b/app/src/main/res/layout/activity_test.xml index bc4ebda9..8c9bf301 100644 --- a/app/src/main/res/layout/activity_test.xml +++ b/app/src/main/res/layout/activity_test.xml @@ -5,9 +5,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:background="@color/black"> - + android:layout_height="240dp" /> \ 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/fragment_sources.xml b/app/src/main/res/layout/fragment_sources.xml index 80940400..ccd87712 100644 --- a/app/src/main/res/layout/fragment_sources.xml +++ b/app/src/main/res/layout/fragment_sources.xml @@ -8,7 +8,7 @@ android:orientation="vertical" android:paddingTop="10dp" android:animateLayoutChanges="true"> - - + diff --git a/app/src/main/res/layout/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"> + + - - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/video_meta"> @@ -226,6 +223,7 @@ android:layout_height="wrap_content" android:orientation="horizontal" android:paddingEnd="6dp" + app:layout_constraintTop_toBottomOf="@id/creator_thumbnail" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintRight_toRightOf="parent"> diff --git a/app/src/main/res/layout/list_video_preview_nested.xml b/app/src/main/res/layout/list_video_preview_nested.xml index d321ed81..a3538adf 100644 --- a/app/src/main/res/layout/list_video_preview_nested.xml +++ b/app/src/main/res/layout/list_video_preview_nested.xml @@ -107,8 +107,6 @@ android:textStyle="normal" /> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +