Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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 @@ -4,7 +4,10 @@
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
Expand Down Expand Up @@ -90,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 @@ -198,6 +202,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 @@ -236,184 +241,195 @@
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
}



bottomSheetPlaylistButton.setOnClickListener {
ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
if (instance != null)
PlaylistQueueSheet(wrappedContext ?: context, activity).show()
PlaylistQueueSheet(wrappedContext ?: context, activity).show()

Check warning on line 432 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.
}
bottomSheetFullControllerButton.setOnClickListener {
ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK)
Expand Down Expand Up @@ -1299,4 +1315,6 @@
}
}



}
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