Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dff22c9
Feat: Add media output switcher support for original equipment manufa…
ghhccghk Aug 8, 2025
bd2a745
Fix: Refactored the SystemMediaControlResolver
ghhccghk Aug 9, 2025
72481fe
Refactor: Improve One UI version detection
ghhccghk Aug 9, 2025
d75f038
fix:Merge code for calling media controls on Android 11 and lower ver…
ghhccghk Aug 12, 2025
b23f50c
Fix: Refactored the SystemMediaControlResolver
ghhccghk Aug 9, 2025
fba5958
Remove One UI media activity calls
ghhccghk Aug 17, 2025
d007e98
feat: Optimized media output panel availability checks
ghhccghk Sep 22, 2025
4a684a0
feat: Add playback speed control
Nov 5, 2025
8f3d7a8
Move playback speed button where requested
Nov 6, 2025
a90544c
Feat: Add media output switcher support for original equipment manufa…
ghhccghk Aug 8, 2025
97da03b
Modify the position of the media control buttons in the full_player.x…
ghhccghk Aug 16, 2025
264d95a
feat: Optimized media output panel availability checks
ghhccghk Sep 22, 2025
3f63259
I addressed the differences between the media control buttons and the…
ghhccghk Dec 19, 2025
ed9ac79
fix: Addressing issues caused by variable base code
ghhccghk Dec 19, 2025
a2689b5
fix:Move the media control buttons to the top bar
ghhccghk Dec 30, 2025
b48dc46
feat: Converted media control icon to vector graphics
ghhccghk Dec 30, 2025
7f2766a
Optimize code by removing redundant logic
ghhccghk Jan 3, 2026
abc6b0c
feat(UI): Refactor full player layout and build configurations
ghhccghk Feb 17, 2026
ee04b54
fix merger error
ghhccghk Feb 17, 2026
87fae94
remove userdebug signingConfig
ghhccghk Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
@file:Suppress("unused", "SpellCheckingInspection")

package org.akanework.gramophone.logic.utils.exoplayer.oem

/**
* Media Kit
* Copyright (C) 2025 Moriafly
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/



import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager

/**
* MiPlay Audio Support
*/
object MiPlayAudioSupport {
private const val ACTION_MIPLAY_DETAIL = "miui.intent.action.ACTIVITY_MIPLAY_DETAIL"
private const val AUDIO_RECORD_CLASS = "miui.media.MiuiAudioPlaybackRecorder"
private const val PACKAGE_NAME = "com.milink.service"
private const val SERVICE_NAME = "com.miui.miplay.audio.service.CoreService"
private const val WHITE_TARGET = "com.milink.service:hide_foreground"

/**
* Check if MiaoBo service is supported
*
* https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1944
*/
fun supportMiPlay(context: Context): Boolean {
try {
// 未找到抛出 PackageManager.NameNotFoundException
context.packageManager.getServiceInfo(
ComponentName(PACKAGE_NAME, SERVICE_NAME),
PackageManager.MATCH_ALL
)
// 未找到抛出 ClassNotFoundException
context.classLoader.loadClass(AUDIO_RECORD_CLASS)

val isInternationalBuild = isInternationalBuild()
val systemUIReady = systemUIReady(context)
val notificationReady = notificationReady(context)
return !isInternationalBuild && systemUIReady && notificationReady
} catch (_: Exception) {
return false
}
}

/**
* Is it the international version
*/
private fun isInternationalBuild(): Boolean =
try {
val clazz = Class.forName("miui.os.Build")
val field = clazz.getField("IS_INTERNATIONAL_BUILD")
field.isAccessible = true
field.getBoolean(null)
} catch (_: Exception) {
false
}

/**
* Check whether SystemUI contains an Activity that handles the Miaobo intent.
*/
private fun systemUIReady(context: Context): Boolean {
val intent =
Intent(ACTION_MIPLAY_DETAIL).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// TODO 是否需要 try catch?
return try {
context.packageManager
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null
} catch (_: ActivityNotFoundException) {
false
}
}

/**
* Check whether the Miaobo service is included in the SystemUI foreground service notification whitelist.
*/
private fun notificationReady(context: Context): Boolean =
try {
val systemUiAppInfo =
context.packageManager.getApplicationInfo(
"com.android.systemui",
0
)
val resources = context.packageManager.getResourcesForApplication(systemUiAppInfo)
val identifier =
@SuppressLint("DiscouragedApi")
resources.getIdentifier(
"system_foreground_notification_whitelist",
"array",
"com.android.systemui"
)

if (identifier > 0) {
val whiteList = resources.getStringArray(identifier)
val contains = whiteList.contains(WHITE_TARGET)
contains
} else {
false
}
} catch (_: Exception) {
false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package org.akanework.gramophone.logic.utils.exoplayer.oem

import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ResolveInfo
import android.media.MediaRouter2
import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import org.akanework.gramophone.R
import org.akanework.gramophone.logic.utils.exoplayer.oem.MiPlayAudioSupport.supportMiPlay

object SystemMediaControlResolver {
fun intentSystemMediaDialog(context: Context) {
// val manufacturer = Build.MANUFACTURER.lowercase()
when {
supportMiPlay(context) -> {
val intent = Intent().apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
setClassName(
"miui.systemui.plugin",
"miui.systemui.miplay.MiPlayDetailActivity"
)
}
if (!startIntent(intent,context = context,)) {
startSystemMediaControl(context = context)
}
}
(getOneUIVersionReadable() != null) -> {
val intent = Intent().apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
setClassName(
"com.samsung.android.mdx.quickboard",
"com.samsung.android.mdx.quickboard.view.MediaActivity"
)
}
if (!startIntent(intent,context = context)) {
startSystemMediaControl(context = context,)
}
}

else -> {
startSystemMediaControl(context = context,)
}
}
}

private fun startSystemMediaControl(context: Context){
if (Build.VERSION.SDK_INT >= 34) {
// zh: Android 14 及以上
// en:Android 14 and above
val tag = startNativeMediaDialogForAndroid14(context)
if (!tag) {
Toast.makeText(context, R.string.media_control_text_error, Toast.LENGTH_SHORT).show()
}
} else if (Build.VERSION.SDK_INT >= 31) {
// zh: Android 12 及以上
// en: Android 14 and above
val intent = Intent().apply {
action = "com.android.systemui.action.LAUNCH_MEDIA_OUTPUT_DIALOG"
setPackage("com.android.systemui")
putExtra("package_name", context.packageName)
}
val tag = startNativeMediaDialog(context = context,intent)
if (!tag) {
Toast.makeText(context, R.string.media_control_text_error, Toast.LENGTH_SHORT).show()
}
} else{
// zh: Android 11 及以下
// en: Android 11 and below
val tag = startNativeMediaDialogForAndroid11(context)
if (!tag) {
Toast.makeText(context, R.string.media_control_text_error, Toast.LENGTH_SHORT).show()
}
}
}

Check warning on line 78 in app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/SystemMediaControlResolver.kt

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (beta)

❌ New issue: Bumpy Road Ahead

SystemMediaControlResolver.startSystemMediaControl has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function. The Bumpy Road code smell is a function that contains multiple chunks of nested conditional logic. The deeper the nesting and the more bumps, the lower the code health.

private fun startNativeMediaDialog(context: Context,intent: Intent): Boolean {
val resolveInfoList: List<ResolveInfo> =
context.packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfoList) {
val activityInfo = resolveInfo.activityInfo
val applicationInfo: ApplicationInfo? = activityInfo?.applicationInfo
if (applicationInfo != null && (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0) {
context.startActivity(intent)
return true
}
}
return false
}

private fun startNativeMediaDialogForAndroid11(context: Context): Boolean {
val intent = Intent().apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
action = "com.android.settings.panel.action.MEDIA_OUTPUT"
putExtra("com.android.settings.panel.extra.PACKAGE_NAME", context.packageName)
}
val resolveInfoList: List<ResolveInfo> =
context.packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfoList) {
val activityInfo = resolveInfo.activityInfo
val applicationInfo: ApplicationInfo? = activityInfo?.applicationInfo
if (applicationInfo != null && (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0) {
context.startActivity(intent)
return true
}
}
return false
}

@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun startNativeMediaDialogForAndroid14(context: Context): Boolean {
val mediaRouter2 = MediaRouter2.getInstance(context)
return mediaRouter2.showSystemOutputSwitcher()
}

private fun startIntent(intent: Intent,context: Context): Boolean {
return try {
context.startActivity(intent)
true
} catch (_: Exception) {
false
}
}

/**
* zh: 获取 One UI 版本字符串(如 6.0.0),非三星或无此属性则返回 null
* en: Get One UI version string (e.g. 6.0.0), return null if not Samsung or no such property
*/
@SuppressLint("PrivateApi")
private fun getOneUIVersionReadable(): String? {
return try {
val systemProperties = Class.forName("android.os.SystemProperties")
val get = systemProperties.getMethod("get", String::class.java)
val value = (get.invoke(null, "ro.build.version.oneui") as String).trim()
if (value.isEmpty()) return null
val code = value.toIntOrNull() ?: return null
val major = code / 10000
val minor = (code / 100) % 100
val patch = code % 100
"$major.$minor.$patch"
} catch (e: Exception) {
null
}
}

fun isMediaOutputPanelSupported(context: Context): Boolean {
return when {
Build.VERSION.SDK_INT >= 34 -> {
// Android 14+ is support
true
}
Build.VERSION.SDK_INT >= 31 -> {
// Android 12~13
val intent = Intent().apply {
action = "com.android.systemui.action.LAUNCH_MEDIA_OUTPUT_DIALOG"
setPackage("com.android.systemui")
putExtra("package_name", context.packageName)
}
isSystemIntentAvailable(context, intent)
}
Build.VERSION.SDK_INT == 30 -> {
// Android 11
val intent = Intent().apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
action = "com.android.settings.panel.action.MEDIA_OUTPUT"
putExtra("com.android.settings.panel.extra.PACKAGE_NAME", context.packageName)
}
isSystemIntentAvailable(context, intent)
}
else -> {
// Android 10 and below
val intent = Intent().apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
action = "com.android.settings.panel.action.MEDIA_OUTPUT"
putExtra("com.android.settings.panel.extra.PACKAGE_NAME", context.packageName)
}
isSystemIntentAvailable(context, intent)
}
}
}

private fun isSystemIntentAvailable(context: Context, intent: Intent): Boolean {
val resolveInfoList = context.packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfoList) {
val activityInfo = resolveInfo.activityInfo
val applicationInfo: ApplicationInfo? = activityInfo?.applicationInfo
if (applicationInfo != null && (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0) {
return true
}
}
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
import org.akanework.gramophone.logic.utils.CalculationUtils
import org.akanework.gramophone.logic.utils.ColorUtils
import org.akanework.gramophone.logic.utils.Flags
import org.akanework.gramophone.logic.utils.exoplayer.oem.SystemMediaControlResolver
import org.akanework.gramophone.ui.MainActivity
import org.akanework.gramophone.ui.fragments.ArtistSubFragment
import org.akanework.gramophone.ui.fragments.DetailDialogFragment
Expand Down Expand Up @@ -199,6 +200,7 @@
private val bottomSheetShuffleButton: MaterialButton
private val bottomSheetLoopButton: MaterialButton
private val bottomSheetPlaylistButton: MaterialButton
private val bottomSheetMediaControl: MaterialButton
private val bottomSheetTimerButton: MaterialButton
private val bottomSheetPlaybackSpeedButton: MaterialButton
private val bottomSheetFavoriteButton: MaterialButton
Expand Down Expand Up @@ -237,181 +239,192 @@
if (!Flags.FAVORITE_SONGS)
bottomSheetFavoriteButton.visibility = GONE
bottomSheetPlaylistButton = findViewById(R.id.playlist)
bottomSheetMediaControl = findViewById(R.id.media_control)
bottomSheetLyricButton = findViewById(R.id.lyrics)
bottomSheetFullLyricView = findViewById(R.id.lyric_frame)
bottomSheetFullQualityDetails = findViewById(R.id.quality_details)
fullPlayerFinalColor = MaterialColors.getColor(
this,
com.google.android.material.R.attr.colorSurface
)
colorPrimaryFinalColor = MaterialColors.getColor(
this,
androidx.appcompat.R.attr.colorPrimary
)
colorOnSecondaryContainerFinalColor = MaterialColors.getColor(
this,
com.google.android.material.R.attr.colorOnSecondaryContainer
)
colorSecondaryContainerFinalColor = MaterialColors.getColor(
this,
com.google.android.material.R.attr.colorSecondaryContainer
)
ViewCompat.setOnApplyWindowInsetsListener(bottomSheetFullLyricView) { v, insets ->
val myInsets = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updateMargin {
left = -myInsets.left
top = -myInsets.top
right = -myInsets.right
bottom = -myInsets.bottom
}
v.setPadding(myInsets.left, myInsets.top, myInsets.right, myInsets.bottom)
return@setOnApplyWindowInsetsListener WindowInsetsCompat.Builder(insets)
.setInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout(), Insets.NONE
)
.setInsetsIgnoringVisibility(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout(), Insets.NONE
)
.build()
}
refreshSettings(null)
prefs.registerOnSharedPreferenceChangeListener(this)
activity.controllerViewModel.customCommandListeners.addCallback(activity.lifecycle) { _, command, _ ->
when (command.customAction) {
GramophonePlaybackService.SERVICE_TIMER_CHANGED -> updateTimer()

GramophonePlaybackService.SERVICE_GET_LYRICS -> {
val parsedLyrics = instance?.getLyrics()
bottomSheetFullLyricView.updateLyrics(parsedLyrics)
}

GramophonePlaybackService.SERVICE_GET_AUDIO_FORMAT -> {
val format = instance?.getAudioFormat()
this.currentFormat = format
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
!handler.hasCallbacks(formatUpdateRunnable)
) {
// TODO: is 300ms long enough wait for stuff like bitrate? 100ms isn't.
handler.postDelayed(formatUpdateRunnable, 300)
}
}

else -> {
return@addCallback Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
}
}
return@addCallback Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}

val seekBarProgressWavelength =
context.resources
.getDimensionPixelSize(R.dimen.media_seekbar_progress_wavelength)
.toFloat()
val seekBarProgressAmplitude =
context.resources
.getDimensionPixelSize(R.dimen.media_seekbar_progress_amplitude)
.toFloat()
val seekBarProgressPhase =
context.resources
.getDimensionPixelSize(R.dimen.media_seekbar_progress_phase)
.toFloat()
val seekBarProgressStrokeWidth =
context.resources
.getDimensionPixelSize(R.dimen.media_seekbar_progress_stroke_width)
.toFloat()

bottomSheetFullSeekBar.progressDrawable = SquigglyProgress().also {
progressDrawable = it
it.waveLength = seekBarProgressWavelength
it.lineAmplitude = seekBarProgressAmplitude
it.phaseSpeed = seekBarProgressPhase
it.strokeWidth = seekBarProgressStrokeWidth
it.transitionEnabled = true
it.animate = false
it.setTint(
MaterialColors.getColor(
bottomSheetFullSeekBar,
androidx.appcompat.R.attr.colorPrimary,
)
)
}

bottomSheetFullCover.setOnClickListener {
activity.startFragment(DetailDialogFragment()) {
putString("Id", instance?.currentMediaItem?.mediaId)
}
}

bottomSheetFullTitle.setOnClickListener {
minimize?.invoke()
activity.startFragment(GeneralSubFragment()) {
putString("Id", instance?.currentMediaItem?.mediaMetadata?.albumId?.toString())
putInt("Item", R.id.album)
}
}

if (Flags.FORMAT_INFO_DIALOG) {
bottomSheetFullQualityDetails.setOnClickListener {
MaterialAlertDialogBuilder(wrappedContext ?: context)
.setTitle(R.string.audio_signal_chain)
.setMessage(
currentFormat?.prettyToString(context)
?: context.getString(R.string.audio_not_initialized)
)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.show()
}
}

bottomSheetFullSubtitle.setOnClickListener {
minimize?.invoke()
activity.startFragment(ArtistSubFragment()) {
putString("Id", instance?.currentMediaItem?.mediaMetadata?.artistId?.toString())
putInt("Item", R.id.artist)
}
}

bottomSheetTimerButton.setOnClickListener {
// TODO(ASAP): expose wait until song end in ui
ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
val picker =
MaterialTimePicker
.Builder()
.setHour((instance?.getTimer()?.first ?: 0) / 3600 / 1000)
.setMinute(((instance?.getTimer()?.first ?: 0) % (3600 * 1000)) / (60 * 1000))
.setTimeFormat(TimeFormat.CLOCK_24H)
.setInputMode(MaterialTimePicker.INPUT_MODE_KEYBOARD)
.build()
picker.addOnPositiveButtonClickListener {
val destinationTime: Int = picker.hour * 1000 * 3600 + picker.minute * 1000 * 60
instance?.setTimer(destinationTime, false)
}
picker.show(activity.supportFragmentManager, "timer")
}

bottomSheetLoopButton.setOnClickListener {
ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
instance?.repeatMode = when (instance?.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF
else -> throw IllegalStateException()
}
}

bottomSheetPlaybackSpeedButton.setOnClickListener {
ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
if (instance != null)
showPlaybackSpeedDialog()
}

bottomSheetFavoriteButton.addOnCheckedChangeListener(this)

if (SystemMediaControlResolver.isMediaOutputPanelSupported(context)){
bottomSheetMediaControl.setOnClickListener {
SystemMediaControlResolver.intentSystemMediaDialog(context)
}
} else {
bottomSheetMediaControl.visibility = GONE
}



Check warning on line 427 in app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (beta)

❌ Getting worse: Complex Method

FullBottomSheet.init increases in cyclomatic complexity from 14 to 15, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
bottomSheetPlaylistButton.setOnClickListener {
ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
if (instance != null)
Expand Down Expand Up @@ -1331,4 +1344,4 @@
}
}

}
}
13 changes: 13 additions & 0 deletions app/src/main/res/drawable/ic_media_control.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="84dp"
android:height="84dp"
android:viewportWidth="84"
android:viewportHeight="84">
<group>
<clip-path
android:pathData="M0,0h84v84h-84zM0,0L0,84L84,84L84,0L0,0M20.887,12.867C24.712,12.867 23.932,16.847 22.537,19.004C19.854,23.153 16.442,26.429 14.324,31C8.85,42.812 13.665,53.441 20.945,63C22.279,64.752 25.75,69.934 21.681,71.029C16.282,72.483 10.883,62.782 8.992,59C3.255,47.526 3.865,32.857 10.711,22C12.796,18.692 16.387,12.867 20.887,12.867M63.113,12.867C67.613,12.867 71.204,18.692 73.289,22C80.135,32.857 80.745,47.526 75.008,59C73.117,62.782 67.718,72.483 62.319,71.029C58.25,69.934 61.721,64.752 63.055,63C70.335,53.441 75.15,42.812 69.676,31C67.558,26.429 64.146,23.153 61.463,19.004C60.068,16.847 59.288,12.867 63.113,12.867M29.853,23.867C32.185,23.867 32.896,26.109 32.342,28.044C30.413,34.775 24.161,37.999 26.653,46C27.816,49.735 31.283,52.26 32.342,55.956C32.896,57.891 32.185,60.133 29.853,60.133C26.122,60.133 23.331,55.906 21.836,52.999C18.107,45.754 18.257,37.071 22.367,30.004C23.87,27.419 26.495,23.867 29.853,23.867M54.147,23.867C57.505,23.867 60.13,27.419 61.633,30.004C65.743,37.071 65.893,45.754 62.164,52.999C60.668,55.906 57.878,60.133 54.147,60.133C51.815,60.133 51.104,57.891 51.658,55.956C53.586,49.225 59.839,46.001 57.347,38C56.184,34.265 52.717,31.74 51.658,28.044C51.104,26.109 51.815,23.867 54.147,23.867M40.004,33.449C50.947,30.761 55.032,47.84 43.996,50.551C33.053,53.238 28.968,36.16 40.004,33.449z"/>
<path
android:pathData="M0,0h84v84h-84z"
android:fillColor="#ffffff"/>
</group>
</vector>
Loading