From 8b3946af738dd2a0b5e3ccd3330e80d53bc4c96f Mon Sep 17 00:00:00 2001 From: Halkiion Date: Thu, 22 May 2025 21:20:21 +0100 Subject: [PATCH 01/11] Fixed several bugs of the AudioPlayer plugin --- .../AudioPlayer.kt | 600 +++++++++++------- 1 file changed, 361 insertions(+), 239 deletions(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index c7593a4..3b85bab 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -2,6 +2,7 @@ package com.github.diamondminer88.plugins import android.annotation.SuppressLint import android.content.Context +import android.graphics.Color import android.media.* import android.os.Bundle import android.view.Gravity @@ -16,7 +17,6 @@ import com.aliucord.Utils import com.aliucord.annotations.AliucordPlugin import com.aliucord.entities.Plugin import com.aliucord.patcher.after -import com.aliucord.utils.DimenUtils import com.aliucord.utils.DimenUtils.dp import com.aliucord.wrappers.messages.AttachmentWrapper.Companion.filename import com.aliucord.wrappers.messages.AttachmentWrapper.Companion.url @@ -34,241 +34,363 @@ import java.util.* @SuppressLint("SetTextI18n") @AliucordPlugin class AudioPlayer : Plugin() { - private val playerBarId = View.generateViewId() - private val attachmentCardId = Utils.getResId("chat_list_item_attachment_card", "id") - private val attachmentCardIconId = Utils.getResId("chat_list_item_attachment_icon", "id") - private val audioFileIconId = Utils.getResId("ic_file_audio", "drawable") - private val validFileExtensions = arrayOf("webm", "mp3", "aac", "m4a", "wav", "flac", "wma", "opus", "ogg") - - private fun msToTime(ms: Long): String { - val hrs = ms / 3_600_000 - val mins = ms / 60000 - val secs = ms / 1000 % 60 - - return if (hrs == 0L) - String.format("%d:%02d", mins, secs) - else - String.format("%d:%d:%02d", hrs, mins, secs) - } - - override fun start(context: Context) { - val p2 = DimenUtils.defaultPadding / 2 - var onPauseListener: (() -> Unit)? = null - var currentPlayerUnsubscribe: (() -> Unit)? = null - var currentPlayer: MediaPlayer? = null - - // rotated triangle icon - val playIcon = ContextCompat.getDrawable( - context, - com.google.android.exoplayer2.ui.R.b.exo_controls_pause - ) - // two vertical bars icon - val pauseIcon = ContextCompat.getDrawable( - context, - com.google.android.exoplayer2.ui.R.b.exo_controls_play - ) - val rewindIcon = ContextCompat.getDrawable( - context, - com.yalantis.ucrop.R.c.ucrop_rotate - ) - - patcher.after( - "configureFileData", - MessageAttachment::class.java, - MessageRenderContext::class.java - ) { - val messageAttachment = it.args[0] as MessageAttachment - if (!validFileExtensions.contains(messageAttachment.filename.split(".").last())) return@after - - val root = WidgetChatListAdapterItemAttachment.`access$getBinding$p`(this) - .root as ConstraintLayout - val card = root.findViewById(attachmentCardId) - val ctx = root.context - - if (card.findViewById(playerBarId) != null) return@after - - var duration: Long = try { - MediaMetadataRetriever().use { retriever -> - retriever.setDataSource(messageAttachment.url, hashMapOf()) - val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - retriever.release() - duration?.toLong() ?: 0L - } - } catch (e: Throwable) { - -1L - } - - card.addView(LinearLayout(ctx, null, 0, R.i.UiKit_ViewGroup).apply { - id = playerBarId - - // Invalid file, ignore - if (duration == -1L) { - visibility = View.GONE - return@after - } - - val icon = card.findViewById(attachmentCardIconId) - icon.setImageResource(audioFileIconId) - - setPadding(p2, p2, p2, p2) - setOnClickListener {} // don't download attachment - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - .apply { - topMargin = 60.dp - gravity = Gravity.BOTTOM - } - - val buttonView = ImageButton(ctx).apply { - background = pauseIcon - setPadding(p2, p2, p2, p2) - } - - val progressView = TextView(ctx, null, 0, R.i.UiKit_TextView).apply { - text = "0:00 / " + if (duration != 0L) msToTime(duration) else "??" - setPadding(p2, p2, p2, p2) - } - - val sliderView = SeekBar(ctx, null, 0, R.i.UiKit_SeekBar).apply { - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) - .apply { weight = 0.5f } - val p = 2.dp - setPadding(p, p, p, 0) - gravity = Gravity.CENTER - progress = 0 - thumb = null - max = 500 - } - - var isPrepared = false - var preparing = false - var playing = false - - var timer: Timer? = null - fun scheduleUpdater() { - timer?.cancel() - timer = Timer() - timer!!.scheduleAtFixedRate(object : TimerTask() { - override fun run() { - if (!playing || duration == 0L) return - Utils.mainThread.post { - progressView.text = - "${msToTime(currentPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" - sliderView.progress = (500 * currentPlayer!!.currentPosition / duration).toInt() - } - } - }, 2000, 250) - } - - fun updatePlaying() { - if (currentPlayer == null) - return - - if (playing) { - currentPlayer!!.start() - scheduleUpdater() - buttonView.background = playIcon - } else { - currentPlayer!!.pause() - timer?.cancel() - timer?.purge() - timer = null - buttonView.background = pauseIcon - } - } - - sliderView.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onStartTrackingTouch(seekBar: SeekBar) {} - override fun onStopTrackingTouch(seekBar: SeekBar) {} - var prevProgress = 0 - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (!fromUser) return - if (!isPrepared) { - seekBar.progress = prevProgress - return - } - prevProgress = progress - currentPlayer!!.seekTo((progress.div(500f) * duration).toInt()) - progressView.text = - "${msToTime(currentPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" - } - }) - - buttonView.setOnClickListener { - playing = !playing - - if (!isPrepared && !preparing) { - preparing = true - Utils.mainThread.post { buttonView.background = null } - - currentPlayer?.release() - currentPlayerUnsubscribe?.invoke() - onPauseListener = null - var url = messageAttachment.url - currentPlayer = MediaPlayer() - - Utils.threadPool.execute { - - if (messageAttachment.filename.endsWith(".ogg")) { - var file = File(ctx.cacheDir, "audio.ogg") - file.deleteOnExit() - Http.simpleDownload(url, file) - url = file.absolutePath - } - - currentPlayer?.apply { - setDataSource(url) - setOnPreparedListener { - seekTo((sliderView.progress.div(500f) * duration).toInt()) - Utils.mainThread.post { updatePlaying() } - duration = it.duration.toLong() - } - setOnCompletionListener { player -> - playing = false - player.seekTo(0) - Utils.mainThread.post { buttonView.background = rewindIcon } - } - currentPlayerUnsubscribe = { - playing = false - Utils.mainThread.post { updatePlaying() } - } - onPauseListener = { - playing = false - Utils.mainThread.post { updatePlaying() } - } - prepare() - isPrepared = true - preparing = false - } - } - } else { - updatePlaying() - } - } - - addView(buttonView) - addView(progressView) - addView(sliderView) - }) - } - - patcher.after("handleChannelSelected", Long::class.javaPrimitiveType!!) { - currentPlayerUnsubscribe?.invoke() - currentPlayerUnsubscribe = null - onPauseListener = null - } - - patcher.after("onCreate", Bundle::class.java) { - currentPlayerUnsubscribe?.invoke() - currentPlayerUnsubscribe = null - onPauseListener = null - } - - patcher.after("onPause") { - onPauseListener?.invoke() - } - } - - override fun stop(context: Context) { - patcher.unpatchAll() - } -} + private val playerBarId = View.generateViewId() + private val attachmentCardId = Utils.getResId("chat_list_item_attachment_card", "id") + private val validFileExtensions = arrayOf( + "webm", "mp3", "aac", "m4a", "wav", "flac", "wma", "opus", "ogg" + ) + + private val allPlayerBarResets = mutableListOf<() -> Unit>() + + private var globalCurrentPlayer: MediaPlayer? = null + private var globalCleanup: (() -> Unit)? = null + + private fun isAudioFile(filename: String?): Boolean { + if (filename == null) return false + val ext = filename.substringAfterLast('.', "").lowercase(Locale.ROOT) + return validFileExtensions.contains(ext) + } + + private fun msToTime(ms: Long): String { + val hrs = ms / 3_600_000 + val mins = ms / 60000 + val secs = ms / 1000 % 60 + + return if (hrs == 0L) + String.format("%d:%02d", mins, secs) + else + String.format("%d:%d:%02d", hrs, mins, secs) + } + + private fun stopCurrentPlayer() { + try { globalCleanup?.invoke() } catch (_: Exception) {} + try { globalCurrentPlayer?.stop() } catch (_: Exception) {} + try { globalCurrentPlayer?.release() } catch (_: Exception) {} + globalCurrentPlayer = null + globalCleanup = null + + allPlayerBarResets.forEach { it() } + } + + override fun start(context: Context) { + patcher.after( + "configureFileData", + MessageAttachment::class.java, + MessageRenderContext::class.java + ) { + val messageAttachment = it.args[0] as MessageAttachment + val root = WidgetChatListAdapterItemAttachment.`access$getBinding$p`(this).root as ConstraintLayout + val card = root.findViewById(attachmentCardId) + val ctx = root.context + + card.findViewById(playerBarId)?.let { card.removeView(it) } + val loadingBarId = playerBarId + 1 + card.findViewById(loadingBarId)?.let { card.removeView(it) } + + if (!isAudioFile(messageAttachment.filename)) return@after + + val loadingBar = ProgressBar(ctx, null, android.R.attr.progressBarStyleHorizontal).apply { + id = loadingBarId + isIndeterminate = true + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, 6.dp).apply { + gravity = Gravity.BOTTOM + } + } + card.addView(loadingBar) + + Utils.threadPool.execute { + val isOgg = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") + val localOggFile = if (isOgg) File(ctx.cacheDir, "audio.ogg") else null + if (isOgg) { + localOggFile!!.deleteOnExit() + Http.simpleDownload(messageAttachment.url, localOggFile) + } + val metadataPath = if (isOgg) localOggFile!!.absolutePath else messageAttachment.url + + var duration: Long = try { + MediaMetadataRetriever().use { retriever -> + retriever.setDataSource(metadataPath) + val durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + durationStr?.toLong() ?: 0L + } + } catch (e: Throwable) { + 0L + } + + Utils.mainThread.post { + + card.findViewById(loadingBarId)?.let { card.removeView(it) } + + if (duration == -1L) { + Toast.makeText(ctx, "Failed to load audio metadata.", Toast.LENGTH_SHORT).show() + return@post + } + + val playerCard = MaterialCardView(ctx).apply { + id = playerBarId + cardElevation = 4.dp.toFloat() + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + topMargin = 60.dp + gravity = Gravity.BOTTOM + } + + isClickable = false + isFocusable = false + foreground = null + stateListAnimator = null + } + + val playerBar = LinearLayout(ctx, null, 0, R.i.UiKit_ViewGroup).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(24, 24, 24, 24) + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + setOnClickListener { } + + var mediaPlayer: MediaPlayer? = null + var isPrepared = false + var isPreparing = false + var playing = false + var timer: Timer? = null + + var buttonView: ImageButton? = null + var progressView: TextView? = null + var sliderView: SeekBar? = null + lateinit var playIcon: android.graphics.drawable.Drawable + lateinit var pauseIcon: android.graphics.drawable.Drawable + lateinit var rewindIcon: android.graphics.drawable.Drawable + + fun resetBar() { + isPrepared = false + isPreparing = false + playing = false + timer?.cancel() + timer = null + mediaPlayer?.release() + mediaPlayer = null + Utils.mainThread.post { + buttonView?.background = playIcon + buttonView?.isEnabled = true + sliderView?.progress = 0 + progressView?.text = "0:00 / ${msToTime(duration)}" + } + } + + playIcon = ContextCompat.getDrawable(ctx, com.google.android.exoplayer2.ui.R.b.exo_controls_play)!! + pauseIcon = ContextCompat.getDrawable(ctx, com.google.android.exoplayer2.ui.R.b.exo_controls_pause)!! + rewindIcon = ContextCompat.getDrawable(ctx, com.yalantis.ucrop.R.c.ucrop_rotate)!! + + buttonView = ImageButton(ctx).apply { + background = playIcon + setPadding(16, 16, 16, 16) + isEnabled = true + } + + progressView = TextView(ctx, null, 0, R.i.UiKit_TextView).apply { + text = "0:00 / ${msToTime(duration)}" + setPadding(16, 16, 16, 16) + } + + sliderView = SeekBar(ctx, null, 0, R.i.UiKit_SeekBar).apply { + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { weight = 0.5f } + val p = 2.dp + setPadding(p, p, p, 0) + gravity = Gravity.CENTER + progress = 0 + thumb = null + max = 500 + } + + fun scheduleUpdater() { + timer?.cancel() + timer = Timer() + timer!!.scheduleAtFixedRate( + object : TimerTask() { + override fun run() { + if (!playing || duration == 0L || mediaPlayer == null) + return + Utils.mainThread.post { + progressView?.text = + "${msToTime(mediaPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" + sliderView?.progress = + (500 * mediaPlayer!!.currentPosition / duration).toInt() + } + } + }, + 2000, + 250 + ) + } + + fun updatePlaying() { + if (!isPrepared || mediaPlayer == null) return + try { + if (playing) { + mediaPlayer!!.start() + scheduleUpdater() + buttonView?.background = pauseIcon + } else { + mediaPlayer!!.pause() + timer?.cancel() + timer = null + buttonView?.background = playIcon + } + } catch (e: Exception) { + Toast.makeText(ctx, "Media error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + sliderView?.setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + var prevProgress = 0 + override fun onProgressChanged( + seekBar: SeekBar, + progress: Int, + fromUser: Boolean + ) { + if (!fromUser) return + if (!isPrepared || mediaPlayer == null) { + seekBar.progress = prevProgress + return + } + prevProgress = progress + mediaPlayer!!.seekTo( + (progress.div(500f) * duration).toInt() + ) + progressView?.text = + "${msToTime(mediaPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" + } + } + ) + + allPlayerBarResets.add(::resetBar) + + buttonView?.setOnClickListener { + val isOggFile = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") + val url = messageAttachment.url + + if (isPreparing) { + Toast.makeText(ctx, "Please wait, loading audio...", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + if (isPrepared && mediaPlayer != null) { + if (playing) { + + playing = false + mediaPlayer!!.pause() + timer?.cancel() + timer = null + buttonView?.background = playIcon + } else { + + playing = true + mediaPlayer!!.start() + scheduleUpdater() + buttonView?.background = pauseIcon + } + return@setOnClickListener + } + + stopCurrentPlayer() + resetBar() + isPreparing = true + buttonView?.isEnabled = false + Utils.mainThread.post { buttonView?.background = null } + + Utils.threadPool.execute { + var playUrl = url + if (isOggFile) { + val file = File(ctx.cacheDir, "audio.ogg") + file.deleteOnExit() + Http.simpleDownload(url, file) + playUrl = file.absolutePath + } + try { + mediaPlayer = MediaPlayer().apply { + setDataSource(playUrl) + setOnPreparedListener { + + if (duration == 0L) { + duration = this.duration.toLong() + } + Utils.mainThread.post { + isPrepared = true + isPreparing = false + playing = true + buttonView?.isEnabled = true + progressView?.text = + "${msToTime(currentPosition.toLong())} / ${msToTime(duration)}" + + mediaPlayer?.start() + scheduleUpdater() + buttonView?.background = pauseIcon + globalCurrentPlayer = this + globalCleanup = { + playing = false + isPrepared = false + isPreparing = false + timer?.cancel() + timer = null + Utils.mainThread.post { + buttonView?.background = playIcon + buttonView?.isEnabled = true + sliderView?.progress = 0 + progressView?.text = "0:00 / ${msToTime(duration)}" + } + try { stop() } catch (_: Exception) {} + try { release() } catch (_: Exception) {} + mediaPlayer = null + } + } + } + setOnCompletionListener { + playing = false + seekTo(0) + Utils.mainThread.post { + buttonView?.background = rewindIcon + } + } + prepareAsync() + } + } catch (e: Exception) { + Utils.mainThread.post { + Toast.makeText(ctx, "Failed to play audio: ${e.message}", Toast.LENGTH_SHORT).show() + buttonView?.isEnabled = true + isPreparing = false + isPrepared = false + } + resetBar() + } + } + } + + addView(buttonView) + addView(progressView) + addView(sliderView) + } + + playerCard.addView(playerBar) + card.addView(playerCard) + } + } + + } + + patcher.after("handleChannelSelected", Long::class.javaPrimitiveType!!) { + stopCurrentPlayer() + } + patcher.after("onCreate", Bundle::class.java) { + stopCurrentPlayer() + } + patcher.after("onPause") { + stopCurrentPlayer() + } + } + + override fun stop(context: Context) { + patcher.unpatchAll() + stopCurrentPlayer() + } +} \ No newline at end of file From 473a625a6a3d74e7e25ec0ba35c9352fd8df35ab Mon Sep 17 00:00:00 2001 From: Halkiion Date: Fri, 23 May 2025 00:20:47 +0100 Subject: [PATCH 02/11] Updated version number to 1.0.7 --- AudioPlayer/build.gradle.kts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AudioPlayer/build.gradle.kts b/AudioPlayer/build.gradle.kts index 45215ca..7c76426 100644 --- a/AudioPlayer/build.gradle.kts +++ b/AudioPlayer/build.gradle.kts @@ -1,9 +1,12 @@ -version = "1.0.6" +version = "1.0.7" description = "Play audio attachments directly in chat." aliucord { changelog.set( """ + # 1.0.7 + * Several bug fixes fixing crashes and wrong audio clip playing + # 1.0.6 * Fix crash on ogg files From b630ffa0d61fa7c84552907f98b28d2dc290b9fa Mon Sep 17 00:00:00 2001 From: Halkiion Date: Fri, 23 May 2025 16:36:28 +0100 Subject: [PATCH 03/11] Update AudioPlayer/build.gradle.kts Co-authored-by: rushii <33725716+rushiiMachine@users.noreply.github.com> --- AudioPlayer/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AudioPlayer/build.gradle.kts b/AudioPlayer/build.gradle.kts index 7c76426..e9e5da8 100644 --- a/AudioPlayer/build.gradle.kts +++ b/AudioPlayer/build.gradle.kts @@ -5,7 +5,7 @@ aliucord { changelog.set( """ # 1.0.7 - * Several bug fixes fixing crashes and wrong audio clip playing + * Fix bugs regarding crashes and desync # 1.0.6 * Fix crash on ogg files From 4a8a79bd31b91ad92907a3e38de5979a36ff14f0 Mon Sep 17 00:00:00 2001 From: Halkiion Date: Sun, 25 May 2025 09:34:05 +0100 Subject: [PATCH 04/11] Add OggMetadataFetcher for fileless ogg metadata, fix more bugs, and address PR feedback --- .../AudioPlayer.kt | 358 ++++++++++-------- .../OggMetadataFetcher.kt | 110 ++++++ 2 files changed, 307 insertions(+), 161 deletions(-) create mode 100644 AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index 3b85bab..a09a787 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -2,8 +2,8 @@ package com.github.diamondminer88.plugins import android.annotation.SuppressLint import android.content.Context -import android.graphics.Color -import android.media.* +import android.media.MediaMetadataRetriever +import android.media.MediaPlayer import android.os.Bundle import android.view.Gravity import android.view.View @@ -29,6 +29,10 @@ import com.google.android.material.card.MaterialCardView import com.lytefast.flexinput.R import java.io.File import java.util.* +import java.util.concurrent.ConcurrentHashMap +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager @Suppress("unused") @SuppressLint("SetTextI18n") @@ -40,10 +44,20 @@ class AudioPlayer : Plugin() { "webm", "mp3", "aac", "m4a", "wav", "flac", "wma", "opus", "ogg" ) - private val allPlayerBarResets = mutableListOf<() -> Unit>() - private var globalCurrentPlayer: MediaPlayer? = null private var globalCleanup: (() -> Unit)? = null + private var globalPlayingUrl: String? = null + private var globalIsPlaying: Boolean = false + + private var audioFocusRequest: AudioFocusRequest? = null + private var audioManager: AudioManager? = null + + private var globalIsCompleted: Boolean = false + + private val playerBarUpdaters = Collections.synchronizedMap(mutableMapOf Unit)>()) + + private val durationCache = ConcurrentHashMap() + private val oggFileCache = ConcurrentHashMap() private fun isAudioFile(filename: String?): Boolean { if (filename == null) return false @@ -55,7 +69,6 @@ class AudioPlayer : Plugin() { val hrs = ms / 3_600_000 val mins = ms / 60000 val secs = ms / 1000 % 60 - return if (hrs == 0L) String.format("%d:%02d", mins, secs) else @@ -63,16 +76,49 @@ class AudioPlayer : Plugin() { } private fun stopCurrentPlayer() { + + playerBarUpdaters.values.forEach { it() } try { globalCleanup?.invoke() } catch (_: Exception) {} + try { globalCurrentPlayer?.setOnCompletionListener(null) } catch (_: Exception) {} + try { globalCurrentPlayer?.setOnPreparedListener(null) } catch (_: Exception) {} try { globalCurrentPlayer?.stop() } catch (_: Exception) {} try { globalCurrentPlayer?.release() } catch (_: Exception) {} + globalCurrentPlayer = null globalCleanup = null + globalIsPlaying = false + globalPlayingUrl = null + globalIsCompleted = false + } - allPlayerBarResets.forEach { it() } + fun requestAudioFocus(ctx: Context) { + audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + .setOnAudioFocusChangeListener { } + .build() + audioManager!!.requestAudioFocus(focusRequest) + } + + fun deleteOggCacheFiles(cacheDir: File, oggCacheMap: Map) { + val oggFiles = cacheDir.listFiles { file -> + file.parentFile == cacheDir && + file.name.matches(Regex("^audio_-?\\d+\\.ogg$")) + } ?: emptyArray() + val filesToDelete = oggFiles.filter { file -> !oggCacheMap.values.contains(file) } + for (file in filesToDelete) { + file.delete() + } } override fun start(context: Context) { + deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) + patcher.after( "configureFileData", MessageAttachment::class.java, @@ -99,26 +145,26 @@ class AudioPlayer : Plugin() { card.addView(loadingBar) Utils.threadPool.execute { + val url = messageAttachment.url val isOgg = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") - val localOggFile = if (isOgg) File(ctx.cacheDir, "audio.ogg") else null - if (isOgg) { - localOggFile!!.deleteOnExit() - Http.simpleDownload(messageAttachment.url, localOggFile) - } - val metadataPath = if (isOgg) localOggFile!!.absolutePath else messageAttachment.url - - var duration: Long = try { - MediaMetadataRetriever().use { retriever -> - retriever.setDataSource(metadataPath) - val durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - durationStr?.toLong() ?: 0L + var duration: Long = durationCache[url] ?: run { + val dur = if (isOgg) { + OggMetadataFetcher.fetch(url)?.duration?.times(1000)?.toLong() ?: 0L + } else { + try { + MediaMetadataRetriever().use { retriever -> + retriever.setDataSource(url) + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L + } + } catch (e: Throwable) { + 0L + } } - } catch (e: Throwable) { - 0L + durationCache[url] = dur + dur } Utils.mainThread.post { - card.findViewById(loadingBarId)?.let { card.removeView(it) } if (duration == -1L) { @@ -133,7 +179,6 @@ class AudioPlayer : Plugin() { topMargin = 60.dp gravity = Gravity.BOTTOM } - isClickable = false isFocusable = false foreground = null @@ -144,13 +189,9 @@ class AudioPlayer : Plugin() { orientation = LinearLayout.HORIZONTAL setPadding(24, 24, 24, 24) layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - setOnClickListener { } + setOnClickListener { } - var mediaPlayer: MediaPlayer? = null - var isPrepared = false - var isPreparing = false - var playing = false - var timer: Timer? = null + var localTimer: Timer? = null var buttonView: ImageButton? = null var progressView: TextView? = null @@ -159,19 +200,59 @@ class AudioPlayer : Plugin() { lateinit var pauseIcon: android.graphics.drawable.Drawable lateinit var rewindIcon: android.graphics.drawable.Drawable - fun resetBar() { - isPrepared = false - isPreparing = false - playing = false - timer?.cancel() - timer = null - mediaPlayer?.release() - mediaPlayer = null - Utils.mainThread.post { - buttonView?.background = playIcon - buttonView?.isEnabled = true - sliderView?.progress = 0 - progressView?.text = "0:00 / ${msToTime(duration)}" + fun cancelTimer() { + localTimer?.cancel() + localTimer = null + } + + fun setIdleState() { + cancelTimer() + buttonView?.background = playIcon + buttonView?.isEnabled = true + sliderView?.progress = 0 + progressView?.text = "0:00 / ${msToTime(duration)}" + } + + fun updateUiFromPlayer() { + + if (globalPlayingUrl != url || globalCurrentPlayer == null) { + setIdleState() + return + } + val pos = globalCurrentPlayer?.currentPosition ?: 0 + sliderView?.progress = if (duration > 0) (500 * pos / duration).toInt() else 0 + progressView?.text = "${msToTime(pos.toLong())} / ${msToTime(duration)}" + buttonView?.background = when { + globalIsCompleted -> rewindIcon + globalIsPlaying -> pauseIcon + else -> playIcon + } + buttonView?.isEnabled = true + } + + fun startTimer() { + cancelTimer() + if (globalPlayingUrl != url || globalCurrentPlayer == null) return + localTimer = Timer() + localTimer!!.scheduleAtFixedRate(object : TimerTask() { + override fun run() { + + if (globalPlayingUrl != url || globalCurrentPlayer == null) { + cancelTimer() + Utils.mainThread.post { setIdleState() } + return + } + Utils.mainThread.post { updateUiFromPlayer() } + } + }, 250, 250) + } + + fun restoreUiToGlobalState() { + if (globalPlayingUrl == url && globalCurrentPlayer != null) { + updateUiFromPlayer() + startTimer() + } else { + setIdleState() } } @@ -200,45 +281,6 @@ class AudioPlayer : Plugin() { max = 500 } - fun scheduleUpdater() { - timer?.cancel() - timer = Timer() - timer!!.scheduleAtFixedRate( - object : TimerTask() { - override fun run() { - if (!playing || duration == 0L || mediaPlayer == null) - return - Utils.mainThread.post { - progressView?.text = - "${msToTime(mediaPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" - sliderView?.progress = - (500 * mediaPlayer!!.currentPosition / duration).toInt() - } - } - }, - 2000, - 250 - ) - } - - fun updatePlaying() { - if (!isPrepared || mediaPlayer == null) return - try { - if (playing) { - mediaPlayer!!.start() - scheduleUpdater() - buttonView?.background = pauseIcon - } else { - mediaPlayer!!.pause() - timer?.cancel() - timer = null - buttonView?.background = playIcon - } - } catch (e: Exception) { - Toast.makeText(ctx, "Media error: ${e.message}", Toast.LENGTH_SHORT).show() - } - } - sliderView?.setOnSeekBarChangeListener( object : SeekBar.OnSeekBarChangeListener { override fun onStartTrackingTouch(seekBar: SeekBar) {} @@ -250,122 +292,114 @@ class AudioPlayer : Plugin() { fromUser: Boolean ) { if (!fromUser) return - if (!isPrepared || mediaPlayer == null) { + if (globalPlayingUrl == url && globalCurrentPlayer != null && globalCurrentPlayer!!.isPlaying) { + prevProgress = progress + val seekTo = (progress / 500f * duration).toInt() + globalCurrentPlayer!!.seekTo(seekTo) + progressView?.text = + "${msToTime(globalCurrentPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" + } else { seekBar.progress = prevProgress - return } - prevProgress = progress - mediaPlayer!!.seekTo( - (progress.div(500f) * duration).toInt() - ) - progressView?.text = - "${msToTime(mediaPlayer!!.currentPosition.toLong())} / ${msToTime(duration)}" } } ) - allPlayerBarResets.add(::resetBar) + playerBarUpdaters[url] = { + cancelTimer() + setIdleState() + } buttonView?.setOnClickListener { + requestAudioFocus(ctx) + val isOggFile = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") val url = messageAttachment.url - if (isPreparing) { - Toast.makeText(ctx, "Please wait, loading audio...", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } + globalIsCompleted = false - if (isPrepared && mediaPlayer != null) { - if (playing) { + if (globalPlayingUrl == url && globalCurrentPlayer != null) { - playing = false - mediaPlayer!!.pause() - timer?.cancel() - timer = null + if (globalCurrentPlayer!!.isPlaying) { + globalCurrentPlayer!!.pause() + globalIsPlaying = false buttonView?.background = playIcon } else { - - playing = true - mediaPlayer!!.start() - scheduleUpdater() + globalCurrentPlayer!!.start() + globalIsPlaying = true buttonView?.background = pauseIcon } + restoreUiToGlobalState() return@setOnClickListener } - stopCurrentPlayer() - resetBar() - isPreparing = true - buttonView?.isEnabled = false - Utils.mainThread.post { buttonView?.background = null } + stopCurrentPlayer() + buttonView?.isEnabled = false Utils.threadPool.execute { var playUrl = url if (isOggFile) { - val file = File(ctx.cacheDir, "audio.ogg") - file.deleteOnExit() - Http.simpleDownload(url, file) - playUrl = file.absolutePath - } - try { - mediaPlayer = MediaPlayer().apply { - setDataSource(playUrl) - setOnPreparedListener { - - if (duration == 0L) { - duration = this.duration.toLong() - } + var file = oggFileCache[url] + if (file == null || !file.exists()) { + file = File(ctx.cacheDir, "audio_${url.hashCode()}.ogg") + try { + Http.simpleDownload(url, file) + oggFileCache[url] = file + } catch (e: Exception) { Utils.mainThread.post { - isPrepared = true - isPreparing = false - playing = true buttonView?.isEnabled = true - progressView?.text = - "${msToTime(currentPosition.toLong())} / ${msToTime(duration)}" - - mediaPlayer?.start() - scheduleUpdater() - buttonView?.background = pauseIcon - globalCurrentPlayer = this - globalCleanup = { - playing = false - isPrepared = false - isPreparing = false - timer?.cancel() - timer = null - Utils.mainThread.post { - buttonView?.background = playIcon - buttonView?.isEnabled = true - sliderView?.progress = 0 - progressView?.text = "0:00 / ${msToTime(duration)}" - } - try { stop() } catch (_: Exception) {} - try { release() } catch (_: Exception) {} - mediaPlayer = null - } - } - } - setOnCompletionListener { - playing = false - seekTo(0) - Utils.mainThread.post { - buttonView?.background = rewindIcon + Toast.makeText(ctx, "Failed to download Ogg file: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() } + return@execute } - prepareAsync() } - } catch (e: Exception) { - Utils.mainThread.post { + playUrl = file.absolutePath + } + + Utils.threadPool.execute { + val newPlayer = MediaPlayer() + try { + newPlayer.setDataSource(playUrl) + newPlayer.setOnPreparedListener { + globalCurrentPlayer = newPlayer + globalPlayingUrl = url + globalIsPlaying = true + globalIsCompleted = false + newPlayer.start() + restoreUiToGlobalState() + startTimer() + } + newPlayer.setOnCompletionListener { + globalIsPlaying = false + globalIsCompleted = true + restoreUiToGlobalState() + } + newPlayer.prepareAsync() + globalCleanup = { + try { newPlayer.stop() } catch (_: Exception) {} + try { newPlayer.release() } catch (_: Exception) {} + buttonView?.background = playIcon + sliderView?.progress = 0 + progressView?.text = "0:00 / ${msToTime(duration)}" + globalCurrentPlayer = null + globalPlayingUrl = null + globalIsPlaying = false + globalIsCompleted = false + cancelTimer() + } + } catch (e: Exception) { Toast.makeText(ctx, "Failed to play audio: ${e.message}", Toast.LENGTH_SHORT).show() + newPlayer.release() + restoreUiToGlobalState() + cancelTimer() + } finally { buttonView?.isEnabled = true - isPreparing = false - isPrepared = false } - resetBar() } } } + restoreUiToGlobalState() addView(buttonView) addView(progressView) addView(sliderView) @@ -375,14 +409,15 @@ class AudioPlayer : Plugin() { card.addView(playerCard) } } - } patcher.after("handleChannelSelected", Long::class.javaPrimitiveType!!) { stopCurrentPlayer() + deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) } patcher.after("onCreate", Bundle::class.java) { stopCurrentPlayer() + deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) } patcher.after("onPause") { stopCurrentPlayer() @@ -392,5 +427,6 @@ class AudioPlayer : Plugin() { override fun stop(context: Context) { patcher.unpatchAll() stopCurrentPlayer() + deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) } } \ No newline at end of file diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt new file mode 100644 index 0000000..99676c4 --- /dev/null +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt @@ -0,0 +1,110 @@ +package com.github.diamondminer88.plugins + +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.ByteBuffer +import java.nio.ByteOrder + +object OggMetadataFetcher { + data class OggMetadata( + val codec: String, + val sampleRate: Int, + val granulePos: Long, + val duration: Double + ) + + @JvmStatic + fun fetch(url: String): OggMetadata? { + var sampleRate = -1 + var codec = "" + val head = fetchBytes(url, 0, 32767) ?: return null + + // Opus + for (i in 0 until head.size - 8) { + if (head[i] == 'O'.code.toByte() && head[i+1] == 'p'.code.toByte() && head[i+2] == 'u'.code.toByte() && + head[i+3] == 's'.code.toByte() && head[i+4] == 'H'.code.toByte() && head[i+5] == 'e'.code.toByte() && + head[i+6] == 'a'.code.toByte() && head[i+7] == 'd'.code.toByte() + ) { + val offset = i + 12 + if (offset + 4 < head.size) { + val bb = ByteBuffer.wrap(head, offset, 4).order(ByteOrder.LITTLE_ENDIAN) + sampleRate = bb.int + codec = "Opus" + } + break + } + } + + // Vorbis + if (sampleRate == -1) { + for (i in 0 until head.size - 7) { + if (head[i] == 0x01.toByte() && head[i+1] == 'v'.code.toByte() && head[i+2] == 'o'.code.toByte() && + head[i+3] == 'r'.code.toByte() && head[i+4] == 'b'.code.toByte() && head[i+5] == 'i'.code.toByte() && + head[i+6] == 's'.code.toByte() + ) { + val offset = i + 12 + if (offset + 4 < head.size) { + val bb = ByteBuffer.wrap(head, offset, 4).order(ByteOrder.LITTLE_ENDIAN) + sampleRate = bb.int + codec = "Vorbis" + } + break + } + } + } + + val granulePos = fetchGranulePosition(url) + + return if (sampleRate > 0 && granulePos > 0) { + OggMetadata(codec, sampleRate, granulePos, granulePos.toDouble() / sampleRate) + } else { + null + } + } + + fun fetchBytes(urlStr: String, start: Int, end: Int): ByteArray? { + val url = URL(urlStr) + val conn = url.openConnection() as HttpURLConnection + conn.setRequestProperty("Range", "bytes=$start-$end") + conn.connect() + if (conn.responseCode != 206 && conn.responseCode != 200) return null + val out = ByteArrayOutputStream() + conn.inputStream.use { input -> + val buf = ByteArray(4096) + var n: Int + while (input.read(buf).also { n = it } > 0) { + out.write(buf, 0, n) + } + } + return out.toByteArray() + } + + fun fetchLastBytes(urlStr: String, numBytes: Int): ByteArray? { + val url = URL(urlStr) + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "HEAD" + conn.connect() + val contentLength = conn.contentLength + if (contentLength <= 0) return null + val start = maxOf(0, contentLength - numBytes) + return fetchBytes(urlStr, start, contentLength - 1) + } + + fun fetchGranulePosition(fileUrl: String): Long { + val tailBytes = 65536 + val tail = fetchLastBytes(fileUrl, tailBytes) ?: return -1 + + var lastOggS = -1 + for (i in 0 until tail.size - 4) { + if (tail[i] == 'O'.code.toByte() && tail[i+1] == 'g'.code.toByte() && + tail[i+2] == 'g'.code.toByte() && tail[i+3] == 'S'.code.toByte()) { + lastOggS = i + } + } + if (lastOggS == -1 || lastOggS + 14 > tail.size) return -1 + + val bb = ByteBuffer.wrap(tail, lastOggS + 6, 8).order(ByteOrder.LITTLE_ENDIAN) + return bb.long + } +} \ No newline at end of file From 545b001258a5f473c12b976dbf4d2e1eecf00e06 Mon Sep 17 00:00:00 2001 From: Halkiion Date: Sun, 25 May 2025 09:43:21 +0100 Subject: [PATCH 05/11] Update AudioPlayer/build.gradle.kts Co-authored-by: rushii <33725716+rushiiMachine@users.noreply.github.com> --- AudioPlayer/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AudioPlayer/build.gradle.kts b/AudioPlayer/build.gradle.kts index e9e5da8..8e73c4e 100644 --- a/AudioPlayer/build.gradle.kts +++ b/AudioPlayer/build.gradle.kts @@ -1,4 +1,4 @@ -version = "1.0.7" +version = "2.0.0" description = "Play audio attachments directly in chat." aliucord { From a54ca320ca96bedbe2fe246e5d836c8accb61314 Mon Sep 17 00:00:00 2001 From: Halkiion Date: Fri, 30 May 2025 01:20:41 +0100 Subject: [PATCH 06/11] Addressed more PR feedback --- .../AudioPlayer.kt | 126 +++++++++--------- .../OggMetadataFetcher.kt | 4 +- 2 files changed, 67 insertions(+), 63 deletions(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index a09a787..6cba1bd 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -78,11 +78,11 @@ class AudioPlayer : Plugin() { private fun stopCurrentPlayer() { playerBarUpdaters.values.forEach { it() } - try { globalCleanup?.invoke() } catch (_: Exception) {} - try { globalCurrentPlayer?.setOnCompletionListener(null) } catch (_: Exception) {} - try { globalCurrentPlayer?.setOnPreparedListener(null) } catch (_: Exception) {} - try { globalCurrentPlayer?.stop() } catch (_: Exception) {} - try { globalCurrentPlayer?.release() } catch (_: Exception) {} + globalCleanup?.invoke() + globalCurrentPlayer?.setOnCompletionListener(null) + globalCurrentPlayer?.setOnPreparedListener(null) + globalCurrentPlayer?.stop() + globalCurrentPlayer?.release() globalCurrentPlayer = null globalCleanup = null @@ -91,33 +91,35 @@ class AudioPlayer : Plugin() { globalIsCompleted = false } - fun requestAudioFocus(ctx: Context) { - audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - .setOnAudioFocusChangeListener { } - .build() - audioManager!!.requestAudioFocus(focusRequest) - } - - fun deleteOggCacheFiles(cacheDir: File, oggCacheMap: Map) { - val oggFiles = cacheDir.listFiles { file -> - file.parentFile == cacheDir && - file.name.matches(Regex("^audio_-?\\d+\\.ogg$")) - } ?: emptyArray() - val filesToDelete = oggFiles.filter { file -> !oggCacheMap.values.contains(file) } - for (file in filesToDelete) { - file.delete() + fun requestAudioFocus(ctx: Context) { + audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + .setOnAudioFocusChangeListener { } + .build() + audioManager!!.requestAudioFocus(focusRequest) + } + + private fun getOggCacheDir(cacheDir: File): File { + val oggCacheDir = File(cacheDir, "ogg") + if (!oggCacheDir.exists()) oggCacheDir.mkdirs() + return oggCacheDir + } + + fun deleteOggCacheFiles(cacheDir: File) { + val oggCacheDir = getOggCacheDir(cacheDir) + if (oggCacheDir.exists()) { + oggCacheDir.deleteRecursively() } } override fun start(context: Context) { - deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) + deleteOggCacheFiles(context.cacheDir) patcher.after( "configureFileData", @@ -129,20 +131,25 @@ class AudioPlayer : Plugin() { val card = root.findViewById(attachmentCardId) val ctx = root.context - card.findViewById(playerBarId)?.let { card.removeView(it) } + card.findViewById(playerBarId)?.visibility = View.GONE val loadingBarId = playerBarId + 1 - card.findViewById(loadingBarId)?.let { card.removeView(it) } + card.findViewById(loadingBarId)?.visibility = View.GONE if (!isAudioFile(messageAttachment.filename)) return@after - val loadingBar = ProgressBar(ctx, null, android.R.attr.progressBarStyleHorizontal).apply { - id = loadingBarId - isIndeterminate = true - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, 6.dp).apply { - gravity = Gravity.BOTTOM + val existingLoadingBar = card.findViewById(loadingBarId) + if (existingLoadingBar != null) { + existingLoadingBar.visibility = View.VISIBLE + } else { + val loadingBar = ProgressBar(ctx, null, android.R.attr.progressBarStyleHorizontal).apply { + id = loadingBarId + isIndeterminate = true + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, 6.dp).apply { + gravity = Gravity.BOTTOM + } } + card.addView(loadingBar) } - card.addView(loadingBar) Utils.threadPool.execute { val url = messageAttachment.url @@ -165,31 +172,31 @@ class AudioPlayer : Plugin() { } Utils.mainThread.post { - card.findViewById(loadingBarId)?.let { card.removeView(it) } - - if (duration == -1L) { - Toast.makeText(ctx, "Failed to load audio metadata.", Toast.LENGTH_SHORT).show() - return@post - } + card.findViewById(loadingBarId)?.visibility = View.GONE - val playerCard = MaterialCardView(ctx).apply { - id = playerBarId - cardElevation = 4.dp.toFloat() - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { - topMargin = 60.dp - gravity = Gravity.BOTTOM - } - isClickable = false - isFocusable = false - foreground = null - stateListAnimator = null + val existingPlayerCard = card.findViewById(playerBarId) + val playerCard = if (existingPlayerCard != null) { + existingPlayerCard.visibility = View.VISIBLE + existingPlayerCard + } else { + MaterialCardView(ctx).apply { + id = playerBarId + cardElevation = 4.dp.toFloat() + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + topMargin = 60.dp + gravity = Gravity.BOTTOM + } + isClickable = true + isFocusable = false + foreground = null + stateListAnimator = null + }.also { card.addView(it) } } val playerBar = LinearLayout(ctx, null, 0, R.i.UiKit_ViewGroup).apply { orientation = LinearLayout.HORIZONTAL setPadding(24, 24, 24, 24) layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - setOnClickListener { } var localTimer: Timer? = null @@ -214,7 +221,6 @@ class AudioPlayer : Plugin() { } fun updateUiFromPlayer() { - if (globalPlayingUrl != url || globalCurrentPlayer == null) { setIdleState() return @@ -236,7 +242,6 @@ class AudioPlayer : Plugin() { localTimer = Timer() localTimer!!.scheduleAtFixedRate(object : TimerTask() { override fun run() { - if (globalPlayingUrl != url || globalCurrentPlayer == null) { cancelTimer() Utils.mainThread.post { setIdleState() } @@ -339,9 +344,10 @@ class AudioPlayer : Plugin() { Utils.threadPool.execute { var playUrl = url if (isOggFile) { + val oggCacheDir = getOggCacheDir(ctx.cacheDir) var file = oggFileCache[url] if (file == null || !file.exists()) { - file = File(ctx.cacheDir, "audio_${url.hashCode()}.ogg") + file = File(oggCacheDir, "audio_${url.hashCode()}.ogg") try { Http.simpleDownload(url, file) oggFileCache[url] = file @@ -405,19 +411,17 @@ class AudioPlayer : Plugin() { addView(sliderView) } + playerCard.removeAllViews() playerCard.addView(playerBar) - card.addView(playerCard) } } } patcher.after("handleChannelSelected", Long::class.javaPrimitiveType!!) { stopCurrentPlayer() - deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) } patcher.after("onCreate", Bundle::class.java) { stopCurrentPlayer() - deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) } patcher.after("onPause") { stopCurrentPlayer() @@ -427,6 +431,6 @@ class AudioPlayer : Plugin() { override fun stop(context: Context) { patcher.unpatchAll() stopCurrentPlayer() - deleteOggCacheFiles(cacheDir = context.cacheDir, oggCacheMap = oggFileCache) + deleteOggCacheFiles(context.cacheDir) } } \ No newline at end of file diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt index 99676c4..ea5b27a 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/OggMetadataFetcher.kt @@ -18,7 +18,7 @@ object OggMetadataFetcher { fun fetch(url: String): OggMetadata? { var sampleRate = -1 var codec = "" - val head = fetchBytes(url, 0, 32767) ?: return null + val head = fetchBytes(url, 0, 8191) ?: return null // Opus for (i in 0 until head.size - 8) { @@ -92,7 +92,7 @@ object OggMetadataFetcher { } fun fetchGranulePosition(fileUrl: String): Long { - val tailBytes = 65536 + val tailBytes = 16384 val tail = fetchLastBytes(fileUrl, tailBytes) ?: return -1 var lastOggS = -1 From 8f9e840c9df746c8cb45b48c1dead4937a83cb6a Mon Sep 17 00:00:00 2001 From: Halkiion Date: Sun, 8 Jun 2025 08:56:08 +0100 Subject: [PATCH 07/11] Update AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt Co-authored-by: rushii <33725716+rushiiMachine@users.noreply.github.com> --- .../kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index 6cba1bd..c54144a 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -354,7 +354,7 @@ class AudioPlayer : Plugin() { } catch (e: Exception) { Utils.mainThread.post { buttonView?.isEnabled = true - Toast.makeText(ctx, "Failed to download Ogg file: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() + Toast.makeText(ctx, "Failed to download audio: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() } return@execute } From 65743931fa2c3ad2168784c44bd327df8f302b27 Mon Sep 17 00:00:00 2001 From: Halkiion Date: Sun, 8 Jun 2025 10:50:27 +0100 Subject: [PATCH 08/11] Address more PR feedback Address more PR feedback Address more PR feedback Address more PR feedback --- .../com.github.diamondminer88.plugins/AudioPlayer.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index c54144a..b2ccecd 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -39,6 +39,7 @@ import android.media.AudioManager @AliucordPlugin class AudioPlayer : Plugin() { private val playerBarId = View.generateViewId() + private val loadingBarId = View.generateViewId() private val attachmentCardId = Utils.getResId("chat_list_item_attachment_card", "id") private val validFileExtensions = arrayOf( "webm", "mp3", "aac", "m4a", "wav", "flac", "wma", "opus", "ogg" @@ -106,16 +107,14 @@ class AudioPlayer : Plugin() { } private fun getOggCacheDir(cacheDir: File): File { - val oggCacheDir = File(cacheDir, "ogg") + val oggCacheDir = File(cacheDir, "audio") if (!oggCacheDir.exists()) oggCacheDir.mkdirs() return oggCacheDir } fun deleteOggCacheFiles(cacheDir: File) { val oggCacheDir = getOggCacheDir(cacheDir) - if (oggCacheDir.exists()) { - oggCacheDir.deleteRecursively() - } + oggCacheDir.deleteRecursively() } override fun start(context: Context) { @@ -132,7 +131,6 @@ class AudioPlayer : Plugin() { val ctx = root.context card.findViewById(playerBarId)?.visibility = View.GONE - val loadingBarId = playerBarId + 1 card.findViewById(loadingBarId)?.visibility = View.GONE if (!isAudioFile(messageAttachment.filename)) return@after @@ -433,4 +431,4 @@ class AudioPlayer : Plugin() { stopCurrentPlayer() deleteOggCacheFiles(context.cacheDir) } -} \ No newline at end of file +} From 05c4a391c18b74d9a1ea8359fd86858aec2df9ad Mon Sep 17 00:00:00 2001 From: Halkiion Date: Mon, 9 Jun 2025 21:06:13 +0100 Subject: [PATCH 09/11] Add request audio support for Android <8 (API level <26) Add request audio support for Android <8 (API level <26) Add request audio support for Android <8 (API level <26) Add request audio support for Android <8 (API level <26) --- .../AudioPlayer.kt | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index b2ccecd..eacabc3 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -2,10 +2,16 @@ package com.github.diamondminer88.plugins import android.annotation.SuppressLint import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager import android.media.MediaMetadataRetriever import android.media.MediaPlayer +import android.os.Build import android.os.Bundle import android.view.Gravity +import android.view.KeyEvent import android.view.View import android.widget.* import android.widget.FrameLayout.LayoutParams.MATCH_PARENT @@ -30,9 +36,6 @@ import com.lytefast.flexinput.R import java.io.File import java.util.* import java.util.concurrent.ConcurrentHashMap -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager @Suppress("unused") @SuppressLint("SetTextI18n") @@ -92,19 +95,30 @@ class AudioPlayer : Plugin() { globalIsCompleted = false } - fun requestAudioFocus(ctx: Context) { - audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - .setOnAudioFocusChangeListener { } - .build() - audioManager!!.requestAudioFocus(focusRequest) - } + fun requestAudioFocus(ctx: Context) { + if (Build.VERSION.SDK_INT >= 26) { + audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + .setOnAudioFocusChangeListener { } + .build() + audioManager!!.requestAudioFocus(focusRequest) + } else { + val intent = Intent(Intent.ACTION_MEDIA_BUTTON) + var keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_STOP) + intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) + ctx.sendOrderedBroadcast(intent, null) + + keyEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_STOP) + intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) + ctx.sendOrderedBroadcast(intent, null) + } + } private fun getOggCacheDir(cacheDir: File): File { val oggCacheDir = File(cacheDir, "audio") From e94d84406b37f96ff73e46f6fff55ccc0c5aec7f Mon Sep 17 00:00:00 2001 From: Halkiion Date: Thu, 19 Jun 2025 12:28:43 +0100 Subject: [PATCH 10/11] Add support for .opus duration --- .../com.github.diamondminer88.plugins/AudioPlayer.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index eacabc3..3ce3e16 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -165,9 +165,10 @@ class AudioPlayer : Plugin() { Utils.threadPool.execute { val url = messageAttachment.url - val isOgg = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") + val isOggOrOpus = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") || + messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".opus") var duration: Long = durationCache[url] ?: run { - val dur = if (isOgg) { + val dur = if (isOggOrOpus) { OggMetadataFetcher.fetch(url)?.duration?.times(1000)?.toLong() ?: 0L } else { try { @@ -330,7 +331,8 @@ class AudioPlayer : Plugin() { buttonView?.setOnClickListener { requestAudioFocus(ctx) - val isOggFile = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") + val isOggOrOpusFile = messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".ogg") || + messageAttachment.filename.lowercase(Locale.ROOT).endsWith(".opus") val url = messageAttachment.url globalIsCompleted = false @@ -355,7 +357,7 @@ class AudioPlayer : Plugin() { buttonView?.isEnabled = false Utils.threadPool.execute { var playUrl = url - if (isOggFile) { + if (isOggOrOpusFile) { val oggCacheDir = getOggCacheDir(ctx.cacheDir) var file = oggFileCache[url] if (file == null || !file.exists()) { @@ -445,4 +447,4 @@ class AudioPlayer : Plugin() { stopCurrentPlayer() deleteOggCacheFiles(context.cacheDir) } -} +} \ No newline at end of file From 237c62eb31ad354f439c418334d1ea2a798a3530 Mon Sep 17 00:00:00 2001 From: Halkiion Date: Tue, 1 Jul 2025 07:01:20 +0100 Subject: [PATCH 11/11] Fix crash when switching guild/chat and optimise player code --- .../AudioPlayer.kt | 80 ++++++++++++------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt index 3ce3e16..d923acc 100644 --- a/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt +++ b/AudioPlayer/src/main/kotlin/com.github.diamondminer88.plugins/AudioPlayer.kt @@ -58,7 +58,8 @@ class AudioPlayer : Plugin() { private var globalIsCompleted: Boolean = false - private val playerBarUpdaters = Collections.synchronizedMap(mutableMapOf Unit)>()) + private var currentActiveBarReset: (() -> Unit)? = null + private var previousActiveBarReset: (() -> Unit)? = null private val durationCache = ConcurrentHashMap() private val oggFileCache = ConcurrentHashMap() @@ -80,8 +81,11 @@ class AudioPlayer : Plugin() { } private fun stopCurrentPlayer() { + currentActiveBarReset?.invoke() + previousActiveBarReset?.invoke() + currentActiveBarReset = null + previousActiveBarReset = null - playerBarUpdaters.values.forEach { it() } globalCleanup?.invoke() globalCurrentPlayer?.setOnCompletionListener(null) globalCurrentPlayer?.setOnPreparedListener(null) @@ -95,30 +99,30 @@ class AudioPlayer : Plugin() { globalIsCompleted = false } - fun requestAudioFocus(ctx: Context) { - if (Build.VERSION.SDK_INT >= 26) { - audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - .setOnAudioFocusChangeListener { } - .build() - audioManager!!.requestAudioFocus(focusRequest) - } else { - val intent = Intent(Intent.ACTION_MEDIA_BUTTON) - var keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_STOP) - intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) - ctx.sendOrderedBroadcast(intent, null) - - keyEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_STOP) - intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) - ctx.sendOrderedBroadcast(intent, null) - } - } + fun requestAudioFocus(ctx: Context) { + if (Build.VERSION.SDK_INT >= 26) { + audioManager = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + .setOnAudioFocusChangeListener { } + .build() + audioManager!!.requestAudioFocus(focusRequest) + } else { + val intent = Intent(Intent.ACTION_MEDIA_BUTTON) + var keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_STOP) + intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) + ctx.sendOrderedBroadcast(intent, null) + + keyEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_STOP) + intent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) + ctx.sendOrderedBroadcast(intent, null) + } + } private fun getOggCacheDir(cacheDir: File): File { val oggCacheDir = File(cacheDir, "audio") @@ -238,7 +242,13 @@ class AudioPlayer : Plugin() { setIdleState() return } - val pos = globalCurrentPlayer?.currentPosition ?: 0 + val pos = try { + globalCurrentPlayer?.currentPosition ?: 0 + } catch (e: IllegalStateException) { + setIdleState() + cancelTimer() + return + } sliderView?.progress = if (duration > 0) (500 * pos / duration).toInt() else 0 progressView?.text = "${msToTime(pos.toLong())} / ${msToTime(duration)}" buttonView?.background = when { @@ -323,11 +333,19 @@ class AudioPlayer : Plugin() { } ) - playerBarUpdaters[url] = { + val resetThisBar = { cancelTimer() setIdleState() } + if (globalPlayingUrl == url && globalCurrentPlayer != null) { + updateUiFromPlayer() + startTimer() + currentActiveBarReset = resetThisBar + } else { + setIdleState() + } + buttonView?.setOnClickListener { requestAudioFocus(ctx) @@ -338,7 +356,6 @@ class AudioPlayer : Plugin() { globalIsCompleted = false if (globalPlayingUrl == url && globalCurrentPlayer != null) { - if (globalCurrentPlayer!!.isPlaying) { globalCurrentPlayer!!.pause() globalIsPlaying = false @@ -352,6 +369,10 @@ class AudioPlayer : Plugin() { return@setOnClickListener } + previousActiveBarReset?.invoke() + previousActiveBarReset = currentActiveBarReset + currentActiveBarReset = resetThisBar + stopCurrentPlayer() buttonView?.isEnabled = false @@ -419,7 +440,6 @@ class AudioPlayer : Plugin() { } } - restoreUiToGlobalState() addView(buttonView) addView(progressView) addView(sliderView)