diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/MiPlayAudioSupport.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/MiPlayAudioSupport.kt new file mode 100644 index 000000000..e9296c51a --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/MiPlayAudioSupport.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/SystemMediaControlResolver.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/SystemMediaControlResolver.kt new file mode 100644 index 000000000..fea9f2fa7 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/exoplayer/oem/SystemMediaControlResolver.kt @@ -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() + } + } + } + + private fun startNativeMediaDialog(context: Context,intent: Intent): Boolean { + val resolveInfoList: List = + 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 = + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt b/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt index 82b30e5fd..1cfb76df8 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/components/FullBottomSheet.kt @@ -4,7 +4,10 @@ import android.animation.ValueAnimator 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 @@ -90,6 +93,7 @@ import org.akanework.gramophone.logic.utils.AudioFormatDetector.SpatialFormat 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 @@ -198,6 +202,7 @@ class FullBottomSheet 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 @@ -236,6 +241,7 @@ class FullBottomSheet 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) @@ -410,10 +416,20 @@ class FullBottomSheet 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() } bottomSheetFullControllerButton.setOnClickListener { ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK) @@ -1299,4 +1315,6 @@ class FullBottomSheet } } + + } diff --git a/app/src/main/res/drawable/ic_media_control.xml b/app/src/main/res/drawable/ic_media_control.xml new file mode 100644 index 000000000..4bb2560ee --- /dev/null +++ b/app/src/main/res/drawable/ic_media_control.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout-w600dp-land/full_player.xml b/app/src/main/res/layout-w600dp-land/full_player.xml index ce9bf1b80..e09f33a40 100644 --- a/app/src/main/res/layout-w600dp-land/full_player.xml +++ b/app/src/main/res/layout-w600dp-land/full_player.xml @@ -48,7 +48,26 @@ app:layout_constraintTop_toBottomOf="@id/slide_down" app:layout_constraintVertical_bias="0.0" /> - + + + + + Grid (compact) Album year year]]> + System Media Controller + Unable to open media output settings + Invalid character in the name Translators ReplayGain