Skip to content

Commit

Permalink
added fade animation while scaling
Browse files Browse the repository at this point in the history
  • Loading branch information
nift4 committed Oct 22, 2024
1 parent 00e74db commit b55be3b
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.akanework.gramophone.logic.ui.spans

import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Shader
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.text.style.UpdateAppearance
import org.akanework.gramophone.logic.utils.CalculationUtils.lerp
import org.akanework.gramophone.logic.utils.CalculationUtils.lerpInv
import kotlin.math.max
import kotlin.math.min

// Hacks, hacks, hacks...
class MyGradientSpan(val grdWidth: Int, color: Int, highlightColor: Int) : CharacterStyle(), UpdateAppearance {
private val matrix = Matrix()
private var shader = LinearGradient(
0f, 50f, grdWidth.toFloat(), 50f,
highlightColor, color,
Shader.TileMode.CLAMP
)
var progress = 1f
var lineOffsets = mutableListOf<Int>()
var lineCount = 0
override fun updateDrawState(tp: TextPaint) {
tp.color = Color.WHITE
val ourProgress = max(0f, min(1f, lerpInv(lineOffsets[5*lineCount+3].toFloat(), lineOffsets[
5*lineCount+4].toFloat(), lerp(0f, lineOffsets[lineOffsets.size-1].toFloat(), progress))))
shader.setLocalMatrix(matrix.apply {
reset()
postTranslate(lineOffsets[lineCount*5].toFloat() + ((lineOffsets[5*lineCount+2]
+ (grdWidth * 3)) * ourProgress) - (grdWidth * 2), 0f)
postScale(1f, lineOffsets[lineCount*5+1] / 100f)
})
tp.shader = shader
lineCount++
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -423,16 +423,17 @@ sealed class SemanticLyrics : Parcelable {
// point minus 1ms as end point of this word
currentLine[i + 1].first - 1uL
} else if (lastWordSyncPoint != null &&
lastWordSyncPoint > currentLine[i].first) {
lastWordSyncPoint > current.first) {
// If we have a dedicated sync point just for the last word,
// use it. Similar to dummy words but for the last word only
lastWordSyncPoint
} else {
// Estimate how long this word will take based on character
// to time ratio. To avoid this estimation, add a last word
// sync point to the line after the text :)
(wout.map { it.charRange.count() / it.timeRange.count() }
.average() * (current.second?.length ?: 0)).toULong()
current.first + (wout.map { it.timeRange.count() /
it.charRange.count().toFloat() }.average() *
(current.second?.length ?: 0)).toULong()
}
if (endInclusive > current.first)
wout.add(Word(current.first..endInclusive, oIdx..<idx))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.akanework.gramophone.ui.components

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Rect
import android.text.Layout
import android.text.SpannableStringBuilder
import android.text.Spanned
Expand All @@ -11,37 +13,43 @@ import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import android.view.animation.PathInterpolator
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.TypefaceCompat
import androidx.core.text.getSpans
import androidx.core.widget.NestedScrollView
import androidx.preference.PreferenceManager
import org.akanework.gramophone.R
import org.akanework.gramophone.logic.dpToPx
import org.akanework.gramophone.logic.getBooleanStrict
import org.akanework.gramophone.logic.ui.spans.MyForegroundColorSpan
import org.akanework.gramophone.logic.ui.spans.MyGradientSpan
import org.akanework.gramophone.logic.ui.spans.StaticLayoutBuilderCompat
import org.akanework.gramophone.logic.utils.CalculationUtils.lerp
import org.akanework.gramophone.logic.utils.CalculationUtils.lerpInv
import org.akanework.gramophone.logic.utils.SemanticLyrics
import org.akanework.gramophone.logic.utils.SpeakerEntity
import org.akanework.gramophone.ui.MainActivity
import kotlin.math.max
import kotlin.math.min

// TODO colors for wakaloke ext
// TODO react to clicks
// TODO color animations
class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs) {

private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val grdWidth = 20.dpToPx(context) // TODO unhardcode?
private val smallSizeFactor = 0.97f
private val scaleInAnimTime = 650f / 2
private val scaleInAnimTime = 650f / 2 // TODO maybe reduce this
private val scaleColorInterpolator = PathInterpolator(0.4f, 0.2f, 0f, 1f)
private val defaultTextPaint = TextPaint().apply { color = Color.RED }
private val translationTextPaint = TextPaint().apply { color = Color.GREEN }
private val translationBackgroundTextPaint = TextPaint().apply { color = Color.BLUE }
private var wordActiveSpan = MyForegroundColorSpan(Color.CYAN)
private val bounds = Rect()
private var gradientSpanPool = mutableListOf<MyGradientSpan>()
private var colorSpanPool = mutableListOf<MyForegroundColorSpan>()
private var spForRender: List<SbItem>? = null
private var spForMeasure: Pair<Pair<Int, Int>, List<SbItem>>? = null
private val spSpanCache = hashMapOf<SpannableStringBuilder, Int>()
private var defaultTextColor = 0
private var highlightTextColor = 0
private var lyrics: SemanticLyrics? = null
Expand All @@ -61,16 +69,24 @@ class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs
}

fun updateTextColor(newColor: Int, newHighlightColor: Int) {
var changed = false
if (defaultTextColor != newColor) {
defaultTextColor = newColor
defaultTextPaint.color = defaultTextColor
translationTextPaint.color = defaultTextColor
translationBackgroundTextPaint.color = defaultTextColor
invalidate()
changed = true
}
if (highlightTextColor != newHighlightColor) {
highlightTextColor = newHighlightColor
wordActiveSpan.color = highlightTextColor
changed = true
}
if (changed) {
spForRender?.forEach { it.text.getSpans<MyGradientSpan>()
.forEach { s -> it.text.removeSpan(s) }}
gradientSpanPool.clear()
(1..3).forEach { gradientSpanPool.add(makeGradientSpan()) }
invalidate()
}
}
Expand Down Expand Up @@ -116,7 +132,9 @@ class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs
val lines = if (lyrics is SemanticLyrics.SyncedLyrics)
(lyrics as SemanticLyrics.SyncedLyrics).text else null
spForRender!!.forEachIndexed { i, it ->
var spanEnd: Int? = null
var spanEnd = -1
var spanStartGradient = -1
var gradientProgress = Float.NEGATIVE_INFINITY
val firstTs = lines?.get(i)?.lyric?.start ?: ULong.MIN_VALUE
val lastTs = lines?.get(i)?.lyric?.words?.lastOrNull()?.timeRange?.last ?: lines
?.find { it.lyric.start > lines[i].lyric.start }?.lyric?.start ?: Long.MAX_VALUE.toULong()
Expand All @@ -129,7 +147,7 @@ class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs
val scaleOutProgress = if (lines == null) 1f else lerpInv(lastTs.toFloat() -
timeOffsetForUse, lastTs.toFloat() + timeOffsetForUse, posForRender.toFloat())
val hlScaleFactor = if (lines == null) smallSizeFactor else {
// lerp argument order is swapped because we divide by this factor
// lerp() argument order is swapped because we divide by this factor
if (scaleOutProgress >= 0f && scaleOutProgress <= 1f)
lerp(smallSizeFactor, 1f, scaleColorInterpolator.getInterpolation(scaleOutProgress))
else if (scaleInProgress >= 0f && scaleInProgress <= 1f)
Expand All @@ -147,8 +165,17 @@ class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs
canvas.scale(1f / hlScaleFactor, 1f / hlScaleFactor)
if (lines != null && lines[i].lyric.words != null) {
val word = lines[i].lyric.words?.findLast { it.timeRange.start <= posForRender }
if (word != null)
spanEnd = word.charRange.last + 1
if (word != null) {
spanEnd = word.charRange.last + 1 // get exclusive end
val gradientEndTime = min(lastTs.toFloat() - timeOffsetForUse,
word.timeRange.last.toFloat())
val gradientStartTime = min(max(word.timeRange.start.toFloat(),
firstTs.toFloat() - timeOffsetForUse), gradientEndTime - 1f)
gradientProgress = lerpInv(gradientStartTime, gradientEndTime,
posForRender.toFloat())
if (gradientProgress >= 0f && gradientProgress <= 1f)
spanStartGradient = word.charRange.first
}
} else {
spanEnd = it.text.length
}
Expand All @@ -159,24 +186,95 @@ class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs
else
canvas.translate(width * ((1 - hlScaleFactor) / 2), 0f)
}
val cachedEnd = spSpanCache[it.text]
if (cachedEnd != spanEnd) {
if (cachedEnd != null)
it.text.removeSpan(wordActiveSpan)
if (spanEnd != null) {
if (gradientProgress >= -.1f && gradientProgress <= 1f)
animating = true
val spanEndWithoutGradient = if (spanStartGradient == -1) spanEnd else spanStartGradient
val inColorAnim = (scaleInProgress >= 0f && scaleInProgress <= 1f && gradientProgress ==
Float.NEGATIVE_INFINITY) || (scaleOutProgress >= 0f && scaleOutProgress <= 1f)
var colorSpan = it.text.getSpans<MyForegroundColorSpan>().firstOrNull()
val cachedEnd = colorSpan?.let { j -> it.text.getSpanStart(j) } ?: -1
val col = if (inColorAnim) ColorUtils.blendARGB(if (scaleOutProgress >= 0f &&
scaleOutProgress <= 1f) highlightTextColor else defaultTextColor,
if (scaleInProgress >= 0f && scaleInProgress <= 1f &&
gradientProgress == Float.NEGATIVE_INFINITY) highlightTextColor
else defaultTextColor, if (scaleOutProgress >= 0f &&
scaleOutProgress <= 1f) scaleOutProgress else scaleInProgress) else 0
if (cachedEnd != spanEndWithoutGradient || inColorAnim != (colorSpan != wordActiveSpan)) {
if (cachedEnd != -1) {
it.text.removeSpan(colorSpan!!)
if (!inColorAnim && colorSpan != wordActiveSpan) {
if (colorSpanPool.size < 10)
colorSpanPool.add(colorSpan)
colorSpan = wordActiveSpan
} else if (inColorAnim) {
if (colorSpan == wordActiveSpan)
colorSpan = null
else if (spanEndWithoutGradient <= 0) {
if (colorSpanPool.size < 10)
colorSpanPool.add(colorSpan)
colorSpan = null
}
}
}
if (spanEndWithoutGradient > 0) {
if (inColorAnim && colorSpan == null)
colorSpan = colorSpanPool.getOrElse(0) { MyForegroundColorSpan(col) }
else if (!inColorAnim)
colorSpan = wordActiveSpan
it.text.setSpan(
wordActiveSpan, 0, spanEnd,
colorSpan, 0, spanEndWithoutGradient,
Spanned.SPAN_INCLUSIVE_INCLUSIVE
)
spSpanCache[it.text] = spanEnd
} else
spSpanCache.remove(it.text)
}
}
if (inColorAnim && spanEndWithoutGradient > 0) {
if (colorSpan!! == wordActiveSpan)
throw IllegalStateException("colorSpan == wordActiveSpan")
colorSpan.color = col
}
var gradientSpan = it.text.getSpans<MyGradientSpan>().firstOrNull()
val gradientSpanStart = gradientSpan?.let { j -> it.text.getSpanStart(j) } ?: -1
if (gradientSpanStart != spanStartGradient) {
if (gradientSpanStart != -1) {
it.text.removeSpan(gradientSpan!!)
if (spanStartGradient == -1) {
if (gradientSpanPool.size < 10)
gradientSpanPool.add(gradientSpan)
gradientSpan = null
}
}
if (spanStartGradient != -1) {
if (gradientSpan == null)
gradientSpan = gradientSpanPool.getOrElse(0) { makeGradientSpan() }
it.text.setSpan(gradientSpan, spanStartGradient, spanEnd,
Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}
}
if (gradientSpan != null) {
gradientSpan.lineCount = 0
gradientSpan.lineOffsets.clear()
val firstLine = it.layout.getLineForOffset(spanStartGradient)
val lastLine = it.layout.getLineForOffset(spanEnd)
for (line in firstLine..lastLine) {
if (line == firstLine) {
it.layout.paint.getTextBounds(it.text.toString(),
it.layout.getLineStart(line), spanStartGradient, bounds)
gradientSpan.lineOffsets.add(bounds.width())
} else gradientSpan.lineOffsets.add(0)
gradientSpan.lineOffsets.add(it.layout.getLineBottom(line) - it.layout.getLineTop(line))
it.layout.paint.getTextBounds(it.text.toString(), max(spanStartGradient,
it.layout.getLineStart(line)), min(spanEnd, it.layout.getLineEnd(line)), bounds)
gradientSpan.lineOffsets.add(bounds.width())
gradientSpan.lineOffsets.add(max(spanStartGradient, it.layout.getLineStart(line)) - spanStartGradient)
gradientSpan.lineOffsets.add(min(it.layout.getLineEnd(line), spanEnd) - spanStartGradient)
}
gradientSpan.lineOffsets.add(spanEnd - spanStartGradient)
gradientSpan.progress = gradientProgress
}
it.layout.draw(canvas)
val th = it.layout.height.toFloat() + it.paddingBottom
if (highlight || it.layout.alignment != Layout.Alignment.ALIGN_NORMAL)
canvas.restore()
canvas.translate(0f, th / hlScaleFactor)
canvas.translate(0f, (it.layout.height.toFloat() + it.paddingBottom) / hlScaleFactor)
}
canvas.restore()
if (animating)
Expand Down Expand Up @@ -224,4 +322,6 @@ class NewLyricsView(context: Context, attrs: AttributeSet) : View(context, attrs
}

data class SbItem(val layout: StaticLayout, val text: SpannableStringBuilder, val paddingTop: Int, val paddingBottom: Int)

private fun makeGradientSpan() = MyGradientSpan(grdWidth, defaultTextColor, highlightTextColor)
}

0 comments on commit b55be3b

Please sign in to comment.