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..073033da 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 @@ -114,7 +113,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 @@ -610,6 +608,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }, UIDialogs.ActionStyle.PRIMARY) ) } + + //startActivity(Intent(this, TestActivity::class.java)) } /* 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/views/TargetTapLoaderView.kt b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt new file mode 100644 index 00000000..222d6cbc --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt @@ -0,0 +1,273 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.core.graphics.toColorInt +import kotlin.math.* +import kotlin.random.Random + +class TargetTapLoaderView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : View(context, attrs) { + + private var expectedDurationMs: Long? = null + private var startTime: Long = 0L + private var loaderFinished = false + private var forceIndeterminate = false + + private val isIndeterminate: Boolean + get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L + + private val targets = mutableListOf() + private val particles = mutableListOf() + private var score = 0 + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 48f + textAlign = Paint.Align.CENTER + setShadowLayer(4f, 0f, 0f, Color.BLACK) + typeface = Typeface.DEFAULT_BOLD + } + private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = "#2D63ED".toColorInt() + } + private val spinnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = "#2D63ED".toColorInt() + strokeWidth = 12f + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.RED + style = Paint.Style.FILL + } + private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.YELLOW + style = Paint.Style.FILL + } + private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(50, 0, 0, 0) + } + private val backgroundPaint = Paint() + + private var spinnerAngle = 0f + + private val frameRunnable = object : Runnable { + override fun run() { + invalidate() + if (!loaderFinished) postDelayed(this, 16L) + } + } + + init { + setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + handleTap(event.x, event.y) + } + true + } + } + + fun startLoader(durationMs: Long? = null) { + expectedDurationMs = durationMs?.takeIf { it > 0L } + forceIndeterminate = expectedDurationMs == null + loaderFinished = false + startTime = System.currentTimeMillis() + score = 0 + targets.clear() + particles.clear() + removeCallbacks(frameRunnable) + post(frameRunnable) + post { spawnTarget() } + + if (!isIndeterminate) { + postDelayed({ + if (!loaderFinished) { + forceIndeterminate = true + startTime = System.currentTimeMillis() + spawnTarget() + } + }, expectedDurationMs!!) + } + } + + fun finishLoader() { + loaderFinished = true + invalidate() + } + + fun stopAndResetLoader() { + loaderFinished = true + targets.clear() + particles.clear() + removeCallbacks(frameRunnable) + invalidate() + } + + private fun handleTap(x: Float, y: Float) { + val now = System.currentTimeMillis() + val hitIndex = targets.indexOfFirst { t -> !t.hit && hypot(x - t.x, y - t.y) <= t.radius } + if (hitIndex >= 0) { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + val target = targets[hitIndex] + target.hit = true + target.hitTime = now + score += if (!isIndeterminate) 10 else 5 + spawnParticles(target.x, target.y, target.radius) + } + } + + private fun spawnTarget() { + if (loaderFinished) 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 + targets.add(Target(x, y, radius, System.currentTimeMillis())) + + val delay = if (isIndeterminate) 1400L else 700L + postDelayed({ spawnTarget() }, delay) + } + + private fun spawnParticles(cx: Float, cy: Float, radius: Float) { + repeat(12) { + val angle = Random.nextFloat() * 2f * PI.toFloat() + val speed = Random.nextFloat() * 5f + 2f + val vx = cos(angle) * speed + val vy = sin(angle) * speed + particles.add(Particle(cx, cy, vx, vy, 255, System.currentTimeMillis())) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawBackground(canvas) + + val now = System.currentTimeMillis() + drawTargets(canvas, now) + drawParticles(canvas, now) + + if (!loaderFinished) { + if (isIndeterminate) drawIndeterminateSpinner(canvas) + else drawDeterministicProgressBar(canvas, now) + } + + canvas.drawText("Score: $score", width / 2f, height - 80f, textPaint) + + if (loaderFinished) { + canvas.drawText("Loading Complete!", width / 2f, height / 2f, textPaint) + } + } + + private fun drawBackground(canvas: Canvas) { + val gradient = LinearGradient( + 0f, 0f, 0f, height.toFloat(), + Color.rgb(20, 20, 40), Color.BLACK, + Shader.TileMode.CLAMP + ) + backgroundPaint.shader = gradient + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint) + } + + private fun drawTargets(canvas: Canvas, now: Long) { + val expireMs = if (isIndeterminate) 2500L else 1500L + targets.removeAll { it.hit && now - it.hitTime > 300L } + targets.removeAll { !it.hit && now - it.spawnTime > expireMs } + + for (t in targets) { + val scale = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f, 1f) else 1f + val alpha = if (t.hit) ((1f - scale) * 255).toInt().coerceAtMost(255) else 255 + val safeRadius = (t.radius * scale).coerceAtLeast(1f) + val glowPaint = Paint().apply { + shader = RadialGradient(t.x, t.y, safeRadius, Color.YELLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP) + } + canvas.drawCircle(t.x, t.y, safeRadius * 1.2f, glowPaint) + canvas.drawCircle(t.x + 4f, t.y + 4f, safeRadius, shadowPaint) + outerRingPaint.alpha = alpha + middleRingPaint.alpha = alpha + centerDotPaint.alpha = alpha + canvas.drawCircle(t.x, t.y, safeRadius, outerRingPaint) + canvas.drawCircle(t.x, t.y, safeRadius * 0.66f, middleRingPaint) + canvas.drawCircle(t.x, t.y, safeRadius * 0.33f, centerDotPaint) + } + } + + private fun drawParticles(canvas: Canvas, now: Long) { + val lifespan = 400L + val iterator = particles.iterator() + while (iterator.hasNext()) { + val p = iterator.next() + val age = now - p.startTime + if (age > lifespan) { + iterator.remove() + continue + } + val alpha = ((1f - (age / lifespan.toFloat())) * 255).toInt() + val paint = Paint().apply { + color = Color.YELLOW + this.alpha = alpha + } + p.x += p.vx + p.y += p.vy + canvas.drawCircle(p.x, p.y, 6f, paint) + } + } + + private fun drawDeterministicProgressBar(canvas: Canvas, now: Long) { + val duration = expectedDurationMs ?: return + val rawProgress = ((now - startTime).toFloat() / duration).coerceIn(0f, 1f) + val easedProgress = AccelerateDecelerateInterpolator().getInterpolation(rawProgress) + + val barHeight = 20f + val barRadius = 10f + val barWidth = width * easedProgress + + val rect = RectF(0f, height - barHeight, barWidth, height.toFloat()) + canvas.drawRoundRect(rect, barRadius, barRadius, progressBarPaint) + } + + private fun drawIndeterminateSpinner(canvas: Canvas) { + spinnerAngle = (spinnerAngle + 6f) % 360f + val cx = width / 2f + val cy = height / 2f + val radius = min(width, height) / 6f + val sweepAngle = 270f + + val glowPaint = Paint(spinnerPaint).apply { + maskFilter = BlurMaskFilter(15f, BlurMaskFilter.Blur.SOLID) + } + + val shader = SweepGradient(cx, cy, intArrayOf(Color.TRANSPARENT, Color.WHITE, Color.TRANSPARENT), floatArrayOf(0f, 0.5f, 1f)) + spinnerPaint.shader = shader + + canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius, spinnerAngle, sweepAngle, false, glowPaint) + canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius, spinnerAngle, sweepAngle, false, spinnerPaint) + } + + private data class Target( + val x: Float, + val y: Float, + val radius: Float, + val spawnTime: Long, + var hit: Boolean = false, + var hitTime: Long = 0L + ) + + private data class Particle( + var x: Float, + var y: Float, + val vx: Float, + val vy: Float, + var alpha: Int, + val startTime: Long + ) +} 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 350ef6b0..995c3c55 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 @@ -44,6 +44,7 @@ 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 @@ -154,10 +155,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onChapterClicked = Event1(); - private val loaderOverlay: FrameLayout - private val loaderIndeterminate: android.widget.ProgressBar - private val loaderDeterminate: android.widget.ProgressBar - private var determinateAnimator: ValueAnimator? = null + private val _loaderGame: TargetTapLoaderView @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { @@ -199,13 +197,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); - loaderOverlay = findViewById(R.id.loader_overlay) - loaderIndeterminate = findViewById(R.id.loader_indeterminate) - loaderDeterminate = findViewById(R.id.loader_determinate) - - loaderOverlay.visibility = View.GONE - loaderIndeterminate.visibility = View.GONE - loaderDeterminate.visibility = View.GONE + _loaderGame = findViewById(R.id.loader_overlay) + _loaderGame.visibility = View.GONE _control_chapter.setOnClickListener { _currentChapter?.let { @@ -884,37 +877,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } override fun setLoading(isLoading: Boolean) { - determinateAnimator?.cancel() if (isLoading) { - loaderOverlay.visibility = View.VISIBLE - loaderIndeterminate.visibility = View.VISIBLE - loaderDeterminate.visibility = View.GONE + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader() } else { - loaderOverlay.visibility = View.GONE - loaderIndeterminate.visibility = View.GONE - loaderDeterminate.visibility = View.GONE + _loaderGame.visibility = View.GONE + _loaderGame.stopAndResetLoader() } } override fun setLoading(expectedDurationMs: Int) { - determinateAnimator?.cancel() - - loaderOverlay.visibility = View.VISIBLE - loaderIndeterminate.visibility = View.GONE - loaderDeterminate.visibility = View.VISIBLE - loaderDeterminate.max = expectedDurationMs - loaderDeterminate.progress = 0 - - determinateAnimator = ValueAnimator.ofInt(0, expectedDurationMs).apply { - duration = expectedDurationMs.toLong() - addUpdateListener { anim -> - loaderDeterminate.progress = anim.animatedValue as Int - if(loaderDeterminate.progress > loaderDeterminate.max - 10) { - setLoading(true); - } - - } - start() - }; + _loaderGame.visibility = View.VISIBLE + _loaderGame.startLoader(expectedDurationMs.toLong()) } } \ 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/video_view.xml b/app/src/main/res/layout/video_view.xml index bc9a8142..cad54d41 100644 --- a/app/src/main/res/layout/video_view.xml +++ b/app/src/main/res/layout/video_view.xml @@ -65,36 +65,10 @@ android:visibility="gone" /> - - - - - - + android:visibility="gone" /> \ No newline at end of file