From 05872ae26dca715e97f08426ad17fb4df372931e Mon Sep 17 00:00:00 2001 From: xufuji456 <839789740@qq.com> Date: Sat, 29 May 2021 16:53:26 +0800 Subject: [PATCH] parse and display lyrics --- .../ffmpeg/activity/AudioPlayActivity.kt | 57 +++- .../frank/ffmpeg/listener/OnLrcListener.kt | 8 + .../java/com/frank/ffmpeg/model/LrcLine.kt | 18 ++ .../java/com/frank/ffmpeg/tool/LrcLineTool.kt | 88 ++++++ .../java/com/frank/ffmpeg/view/LrcView.kt | 258 ++++++++++++++++++ .../main/res/layout/activity_audio_play.xml | 6 +- 6 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/frank/ffmpeg/listener/OnLrcListener.kt create mode 100644 app/src/main/java/com/frank/ffmpeg/model/LrcLine.kt create mode 100644 app/src/main/java/com/frank/ffmpeg/tool/LrcLineTool.kt create mode 100644 app/src/main/java/com/frank/ffmpeg/view/LrcView.kt diff --git a/app/src/main/java/com/frank/ffmpeg/activity/AudioPlayActivity.kt b/app/src/main/java/com/frank/ffmpeg/activity/AudioPlayActivity.kt index 199bc0b..fca9792 100644 --- a/app/src/main/java/com/frank/ffmpeg/activity/AudioPlayActivity.kt +++ b/app/src/main/java/com/frank/ffmpeg/activity/AudioPlayActivity.kt @@ -12,7 +12,15 @@ import android.widget.ImageView import android.widget.SeekBar import android.widget.TextView import com.frank.ffmpeg.R +import com.frank.ffmpeg.handler.FFmpegHandler +import com.frank.ffmpeg.handler.FFmpegHandler.MSG_FINISH +import com.frank.ffmpeg.listener.OnLrcListener +import com.frank.ffmpeg.model.MediaBean +import com.frank.ffmpeg.util.FFmpegUtil import com.frank.ffmpeg.util.TimeUtil +import com.frank.ffmpeg.model.LrcLine +import com.frank.ffmpeg.tool.LrcLineTool +import com.frank.ffmpeg.view.LrcView class AudioPlayActivity : AppCompatActivity() { @@ -23,11 +31,14 @@ class AudioPlayActivity : AppCompatActivity() { private const val MSG_DURATION = 234 } + private var path: String? = null + private var txtTitle: TextView? = null private var txtArtist: TextView? = null private var txtTime: TextView? = null private var txtDuration: TextView? = null private var audioBar: SeekBar? = null + private var lrcView: LrcView? = null private lateinit var audioPlayer:MediaPlayer @@ -40,12 +51,29 @@ class AudioPlayActivity : AppCompatActivity() { audioBar?.progress = audioPlayer.currentPosition txtTime?.text = TimeUtil.getVideoTime(audioPlayer.currentPosition.toLong()) sendEmptyMessageDelayed(MSG_TIME, 1000) + lrcView?.seekToTime(audioPlayer.currentPosition.toLong()) } MSG_DURATION -> { val duration = msg.obj as Int txtDuration?.text = TimeUtil.getVideoTime(duration.toLong()) audioBar?.max = duration } + MSG_FINISH -> { + val result = msg.obj as MediaBean + txtTitle?.text = result.audioBean?.title + txtArtist?.text = result.audioBean?.artist + val lyrics = result.audioBean?.lyrics + if (lyrics != null) { + val lrcList = arrayListOf() + for (i in lyrics.indices) { + Log.e(TAG, "lyrics=_=" + lyrics[i]) + val line = LrcLineTool.getLrcLine(lyrics[i]) + if (line != null) lrcList.addAll(line) + } + LrcLineTool.sortLyrics(lrcList) + lrcView?.setLrc(lrcList) + } + } } } } @@ -56,6 +84,7 @@ class AudioPlayActivity : AppCompatActivity() { initView() initAudioPlayer() + initLrc() } private fun initView() { @@ -63,6 +92,7 @@ class AudioPlayActivity : AppCompatActivity() { txtArtist = findViewById(R.id.txt_artist) txtTime = findViewById(R.id.txt_time) txtDuration = findViewById(R.id.txt_duration) + lrcView = findViewById(R.id.list_lyrics) val btnPlay: ImageView = findViewById(R.id.img_play) btnPlay.setOnClickListener { if (isPlaying()) { @@ -85,13 +115,21 @@ class AudioPlayActivity : AppCompatActivity() { } override fun onStopTrackingTouch(seekBar: SeekBar?) { - audioPlayer.seekTo(audioBar?.progress!!) + val progress = audioBar?.progress ?: 0 + audioPlayer.seekTo(progress) + lrcView?.seekToTime(progress.toLong()) + } + }) + lrcView?.setListener(object :OnLrcListener { + override fun onLrcSeek(position: Int, lrcLine: LrcLine) { + audioPlayer.seekTo(lrcLine.startTime.toInt()) + Log.e(TAG, "lrc position=$position--time=${lrcLine.startTime}") } }) } private fun initAudioPlayer() { - val path = intent.data?.path + path = intent.data?.path Log.e(TAG, "path=$path") if (TextUtils.isEmpty(path)) return audioPlayer = MediaPlayer() @@ -106,14 +144,25 @@ class AudioPlayActivity : AppCompatActivity() { } } + private fun initLrc() { + if (path.isNullOrEmpty()) return + val ffmpegHandler = FFmpegHandler(mHandler) + val commandLine = FFmpegUtil.probeFormat(path) + ffmpegHandler.executeFFprobeCmd(commandLine) + } + private fun isPlaying() :Boolean { return audioPlayer.isPlaying } override fun onStop() { super.onStop() - audioPlayer.stop() - audioPlayer.release() + try { + audioPlayer.stop() + audioPlayer.release() + } catch (e: Exception) { + Log.e(TAG, "release player err=$e") + } } } diff --git a/app/src/main/java/com/frank/ffmpeg/listener/OnLrcListener.kt b/app/src/main/java/com/frank/ffmpeg/listener/OnLrcListener.kt new file mode 100644 index 0000000..a5baa80 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/listener/OnLrcListener.kt @@ -0,0 +1,8 @@ +package com.frank.ffmpeg.listener + +import com.frank.ffmpeg.model.LrcLine + +interface OnLrcListener { + + fun onLrcSeek(position: Int, lrcLine: LrcLine) +} diff --git a/app/src/main/java/com/frank/ffmpeg/model/LrcLine.kt b/app/src/main/java/com/frank/ffmpeg/model/LrcLine.kt new file mode 100644 index 0000000..c06e170 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/model/LrcLine.kt @@ -0,0 +1,18 @@ +package com.frank.ffmpeg.model + + +class LrcLine : Comparable { + + var timeString: String? = null + + var startTime: Long = 0 + + var endTime: Long = 0 + + var content: String? = null + + override fun compareTo(another: LrcLine): Int { + return (this.startTime - another.startTime).toInt() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/frank/ffmpeg/tool/LrcLineTool.kt b/app/src/main/java/com/frank/ffmpeg/tool/LrcLineTool.kt new file mode 100644 index 0000000..c8d1ce9 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/tool/LrcLineTool.kt @@ -0,0 +1,88 @@ +package com.frank.ffmpeg.tool + +import com.frank.ffmpeg.model.LrcLine + +import java.util.ArrayList +import java.util.Collections + +object LrcLineTool { + + private fun createLine(lrcLine: String?): List? { + try { + if (lrcLine == null || lrcLine.isEmpty() || + lrcLine.indexOf("[") != 0 || lrcLine.indexOf("]") != 9) { + return null + } + + val lastIndexOfRightBracket = lrcLine.lastIndexOf("]") + val content = lrcLine.substring(lastIndexOfRightBracket + 1) + val times = lrcLine.substring(0, lastIndexOfRightBracket + 1).replace("[", "-") + .replace("]", "-") + if (times.isEmpty() || !Character.isDigit(times[1])) return null + val arrTimes = times.split("-".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val listTimes = ArrayList() + for (temp in arrTimes) { + if (temp.trim { it <= ' ' }.isEmpty()) { + continue + } + val mLrcLine = LrcLine() + mLrcLine.content = content + mLrcLine.timeString = temp + val startTime = timeConvert(temp) + mLrcLine.startTime = startTime + listTimes.add(mLrcLine) + } + return listTimes + } catch (e: Exception) { + e.printStackTrace() + return null + } + + } + + /** + * string time to milliseconds + */ + private fun timeConvert(timeStr: String): Long { + var timeString = timeStr + timeString = timeString.replace('.', ':') + val times = timeString.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + return (Integer.valueOf(times[0]) * 60 * 1000 + + Integer.valueOf(times[1]) * 1000 + + Integer.valueOf(times[2])).toLong() + } + + fun getLrcLine(line: String?): List? { + if (line == null || line.isEmpty()) { + return null + } + val rows = ArrayList() + try { + val lrcLines = createLine(line) + if (lrcLines != null && lrcLines.isNotEmpty()) { + rows.addAll(lrcLines) + } + } catch (e: Exception) { + return null + } + + return rows + } + + fun sortLyrics(lrcList: List): List { + Collections.sort(lrcList) + if (lrcList.isNotEmpty()) { + val size = lrcList.size + for (i in 0 until size) { + val lrcLine = lrcList[i] + if (i < size - 1) { + lrcLine.endTime = lrcList[i + 1].startTime + } else { + lrcLine.endTime = lrcLine.startTime + 10 * 1000 + } + } + } + return lrcList + } + +} diff --git a/app/src/main/java/com/frank/ffmpeg/view/LrcView.kt b/app/src/main/java/com/frank/ffmpeg/view/LrcView.kt new file mode 100644 index 0000000..1ed91f9 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/view/LrcView.kt @@ -0,0 +1,258 @@ +package com.frank.ffmpeg.view + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Paint.Align +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View + +import com.frank.ffmpeg.listener.OnLrcListener +import com.frank.ffmpeg.model.LrcLine +import kotlin.math.abs + +/** + * LrcView: display lyrics with playing time, seek and sync + */ + +class LrcView(context: Context, attr: AttributeSet) : View(context, attr) { + + private val mPadding = 10 + + private val mLrcFontSize = 35 + + private var mHighLightRow = 0 + + private var mLastMotionY = 0f + + private var currentMillis: Long = 0 + + private var mLrcLines: List? = null + + private var mLrcViewListener: OnLrcListener? = null + + private val mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + + private val mNormalRowColor = Color.BLACK + + private val mHighLightRowColor = Color.BLUE + + private var mDisplayMode = DISPLAY_MODE_NORMAL + + private val mode = MODE_HIGH_LIGHT_KARAOKE + + + init { + mPaint.textSize = mLrcFontSize.toFloat() + } + + override fun onDraw(canvas: Canvas) { + val height = height + val width = width + if (mLrcLines == null || mLrcLines!!.isEmpty()) { + mPaint.color = mHighLightRowColor + mPaint.textSize = mLrcFontSize.toFloat() + mPaint.textAlign = Align.CENTER + canvas.drawText(noLrcTip, (width / 2).toFloat(), (height / 2 - mLrcFontSize).toFloat(), mPaint) + return + } + + var rowY: Int + val rowX = width / 2 + var rowNum: Int = mHighLightRow - 1 + + // 1: current highlight lyrics + val highlightRowY = height / 2 - mLrcFontSize + + if (mode == MODE_HIGH_LIGHT_KARAOKE) { + // highlight one by one + drawKaraokeHighLightLrcRow(canvas, width, rowX, highlightRowY) + } else { + drawHighLrcRow(canvas, height, rowX, highlightRowY) + } + + if (mDisplayMode == DISPLAY_MODE_SEEK) { + mPaint.color = mSeekLineColor + val mSeekLinePaddingX = 0 + canvas.drawLine(mSeekLinePaddingX.toFloat(), (highlightRowY + mPadding).toFloat(), + (width - mSeekLinePaddingX).toFloat(), (highlightRowY + mPadding).toFloat(), mPaint) + mPaint.color = mSeekLineTextColor + mPaint.textSize = mSeekLineTextSize.toFloat() + mPaint.textAlign = Align.LEFT + canvas.drawText(mLrcLines!![mHighLightRow].timeString!!, 0f, highlightRowY.toFloat(), mPaint) + } + + // lyrics above the highlight one + mPaint.color = mNormalRowColor + mPaint.textSize = mLrcFontSize.toFloat() + mPaint.textAlign = Align.CENTER + rowY = highlightRowY - mPadding - mLrcFontSize + while (rowY > -mLrcFontSize && rowNum >= 0) { + val text = mLrcLines!![rowNum].content + canvas.drawText(text!!, rowX.toFloat(), rowY.toFloat(), mPaint) + rowY -= mPadding + mLrcFontSize + rowNum-- + } + + // lyrics below the highlight one + rowNum = mHighLightRow + 1 + rowY = highlightRowY + mPadding + mLrcFontSize + while (rowY < height && rowNum < mLrcLines!!.size) { + val text = mLrcLines!![rowNum].content + canvas.drawText(text!!, rowX.toFloat(), rowY.toFloat(), mPaint) + rowY += mPadding + mLrcFontSize + rowNum++ + } + } + + private fun drawKaraokeHighLightLrcRow(canvas: Canvas, width: Int, rowX: Int, highlightRowY: Int) { + val highLrcLine = mLrcLines!![mHighLightRow] + val highlightText = highLrcLine.content + + mPaint.color = mNormalRowColor + mPaint.textSize = mLrcFontSize.toFloat() + mPaint.textAlign = Align.CENTER + canvas.drawText(highlightText!!, rowX.toFloat(), highlightRowY.toFloat(), mPaint) + + val highLineWidth = mPaint.measureText(highlightText).toInt() + val leftOffset = (width - highLineWidth) / 2 + val start = highLrcLine.startTime + val end = highLrcLine.endTime + val highWidth = ((currentMillis - start) * 1.0f / (end - start) * highLineWidth).toInt() + if (highWidth > 0) { + mPaint.color = mHighLightRowColor + val textBitmap = Bitmap.createBitmap(highWidth, highlightRowY + mPadding, Bitmap.Config.ARGB_8888) + val textCanvas = Canvas(textBitmap) + textCanvas.drawText(highlightText, (highLineWidth / 2).toFloat(), highlightRowY.toFloat(), mPaint) + canvas.drawBitmap(textBitmap, leftOffset.toFloat(), 0f, mPaint) + } + } + + private fun drawHighLrcRow(canvas: Canvas, height: Int, rowX: Int, highlightRowY: Int) { + val highlightText = mLrcLines!![mHighLightRow].content + mPaint.color = mHighLightRowColor + mPaint.textSize = mLrcFontSize.toFloat() + mPaint.textAlign = Align.CENTER + canvas.drawText(highlightText!!, rowX.toFloat(), highlightRowY.toFloat(), mPaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (mLrcLines == null || mLrcLines!!.isEmpty()) { + return super.onTouchEvent(event) + } + when (event.action) { + MotionEvent.ACTION_DOWN -> { + mLastMotionY = event.y + invalidate() + } + MotionEvent.ACTION_MOVE -> doSeek(event) + MotionEvent.ACTION_CANCEL, + + MotionEvent.ACTION_UP -> { + if (mDisplayMode == DISPLAY_MODE_SEEK) { + seekLrc(mHighLightRow, true) + } + mDisplayMode = DISPLAY_MODE_NORMAL + invalidate() + } + } + return true + } + + /** + * Moving lyrics, when touch and move the screen + */ + private fun doSeek(event: MotionEvent) { + val y = event.y + val offsetY = y - mLastMotionY + if (abs(offsetY) < mMinSeekFiredOffset) { + return + } + mDisplayMode = DISPLAY_MODE_SEEK + val rowOffset = abs(offsetY.toInt() / mLrcFontSize) + + if (offsetY < 0) { + mHighLightRow += rowOffset + } else if (offsetY > 0) { + mHighLightRow -= rowOffset + } + mHighLightRow = 0.coerceAtLeast(mHighLightRow) + mHighLightRow = mHighLightRow.coerceAtMost(mLrcLines!!.size - 1) + if (rowOffset > 0) { + mLastMotionY = y + invalidate() + } + } + + fun setListener(listener: OnLrcListener) { + mLrcViewListener = listener + } + + fun setLrc(lrcLines: List) { + mLrcLines = lrcLines + invalidate() + } + + private fun seekLrc(position: Int, cb: Boolean) { + if (mLrcLines == null || position < 0 || position > mLrcLines!!.size) { + return + } + val lrcLine = mLrcLines!![position] + mHighLightRow = position + invalidate() + if (mLrcViewListener != null && cb) { + mLrcViewListener!!.onLrcSeek(position, lrcLine) + } + } + + fun seekToTime(time: Long) { + if (mLrcLines == null || mLrcLines!!.isEmpty()) { + return + } + if (mDisplayMode != DISPLAY_MODE_NORMAL) { + return + } + currentMillis = time + + for (i in mLrcLines!!.indices) { + val current = mLrcLines!![i] + val next = if (i + 1 == mLrcLines!!.size) null else mLrcLines!![i + 1] + if (time >= current.startTime && next != null && time < next.startTime || time > current.startTime && next == null) { + seekLrc(i, false) + return + } + } + } + + companion object { + + private const val mSeekLineTextSize = 15 + + private const val mSeekLineColor = Color.RED + + private const val mSeekLineTextColor = Color.BLUE + + /** + * Mode normal + */ + const val DISPLAY_MODE_NORMAL = 0 + /** + * Mode seek + */ + const val DISPLAY_MODE_SEEK = 1 + + /** + * Minimum seeking distance + */ + private const val mMinSeekFiredOffset = 10 + + private const val noLrcTip = "No lyrics..." + + private const val MODE_HIGH_LIGHT_NORMAL = 0 + + private const val MODE_HIGH_LIGHT_KARAOKE = 1 + } +} diff --git a/app/src/main/res/layout/activity_audio_play.xml b/app/src/main/res/layout/activity_audio_play.xml index ea40270..c280716 100644 --- a/app/src/main/res/layout/activity_audio_play.xml +++ b/app/src/main/res/layout/activity_audio_play.xml @@ -22,11 +22,13 @@ android:textColor="@color/colorPrimary" android:textSize="18sp"/> - + android:layout_centerInParent="true" + android:layout_marginTop="160dp" + android:layout_marginBottom="160dp"/>