Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+108
View File
@@ -0,0 +1,108 @@
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val localProperties = Properties().apply {
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { load(it) }
}
}
val developmentVersionCode = System.getenv("SMPLAYER_ANDROID_VERSION_CODE")?.let { rawVersionCode ->
rawVersionCode.toIntOrNull()?.takeIf { versionCode -> versionCode > 0 }
?: error("SMPLAYER_ANDROID_VERSION_CODE must be a positive integer")
}
val flutterVersionCode = developmentVersionCode
?: localProperties.getProperty("flutter.versionCode")?.toIntOrNull()
?: 1
val flutterVersionName = localProperties.getProperty("flutter.versionName") ?: "1.0.0"
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
android {
namespace = "com.smplayer.smplayer"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.smplayer.smplayer"
// fvp(libmdk) 与 media_kit(libmpv) 在 Android 上的硬解基线。
minSdk = maxOf(flutter.minSdkVersion, 24)
targetSdk = flutter.targetSdkVersion
versionCode = flutterVersionCode
versionName = flutterVersionName
// ABI 限制策略:见下方 buildTypes。defaultConfig 不收缩,保留多 ABI 让
// debug 在 x86_64 emulator 上仍能加载 fvp libmdk.so / media_kit libmpv.so
// release 包仅 arm64-v8a 以匹配 media3 FFmpeg AAR 并控制包体。
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
// 本地 debug 在没有 key.properties 时使用 Android debug 签名;release 构建
// 绝不回退到临时 debug 证书,避免发布无法覆盖安装的 APK。
val releaseSigningConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
null
}
buildTypes {
debug {
applicationIdSuffix = ".dev"
manifestPlaceholders["appLabel"] = "smplayer-dev"
signingConfig = releaseSigningConfig ?: signingConfigs.getByName("debug")
}
release {
manifestPlaceholders["appLabel"] = "smplayer"
if (releaseSigningConfig != null) {
signingConfig = releaseSigningConfig
}
ndk {
// 仅 release 收缩到 arm64-v8a:与 media3_engine plugin 自编译的
// FFmpeg decoder AAR 对齐(其只含 arm64-v8a),包体最小化。
// debug 不限制 → x86_64 / arm64 emulator 上 fvp / media_kit 仍可运行。
abiFilters += listOf("arm64-v8a")
}
}
}
}
flutter {
source = "../.."
}
// media3_engine plugin 把 media3-decoder-ffmpeg-*.aar 标为 compileOnlyAGP 8
// 禁止 library module 直接 implementation 本地 .aar)。在 app 这一层把所有本地
// .aar 实际打入 APK;目前只有 media3 FFmpeg decoder AAR,使用 include 通配避免
// 后续新增 AAR 时再改这里。
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
}
+11
View File
@@ -0,0 +1,11 @@
# Media3 FFmpeg decoder AAR 校验清单。
#
# 格式:<SHA-256> <文件名>
#
# 由 scripts/build_media3_ffmpeg.sh 产出 AAR 后维护此文件(自动覆写对应文件名条目)。
# Gradle 任务 `verifyMedia3FfmpegAar` 在 preBuild 前会读取此清单 + 计算 AAR 实际
# SHA-256,不一致即 fail-fast(见 plugins/media3_engine/android/build.gradle.kts)。
#
# 升级版本时同时更新下方文件名(含 media3 tag)与 SHA-256。
8e4979b0b9f1408314b149a9740310034238cb80f55167f388f5dbc310e8229e media3-decoder-ffmpeg-1.10.1-arm64.aar
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="${appLabel}"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Trakt OAuth deep link callback: smplayer://oauth-trakt -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="smplayer" android:host="oauth-trakt"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,109 @@
package com.smplayer.smplayer
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Base64
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.security.KeyStore
import java.security.cert.X509Certificate
import java.util.concurrent.Executors
class AndroidTlsBridge(
private val context: Context,
flutterEngine: FlutterEngine,
) {
companion object {
private const val CHANNEL_NAME = "smplayer/android_tls"
private const val CA_BUNDLE_FILE_NAME = "android-trusted-ca-bundle.pem"
}
private val mainHandler = Handler(Looper.getMainLooper())
private val worker = Executors.newSingleThreadExecutor()
private val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL_NAME,
)
@Volatile
private var disposed = false
init {
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"exportTrustedCaBundle" -> worker.execute {
try {
val bundlePath = exportTrustedCaBundle()
postResult { result.success(bundlePath) }
} catch (exception: Exception) {
postResult {
result.error(
"CA_EXPORT_FAILED",
exception.message ?: "Failed to export Android trusted CAs",
null,
)
}
}
}
else -> result.notImplemented()
}
}
}
private fun exportTrustedCaBundle(): String {
val trustStore = KeyStore.getInstance("AndroidCAStore").apply {
load(null)
}
val encodedCertificates = linkedSetOf<String>()
val aliases = trustStore.aliases().toList().sorted()
for (alias in aliases) {
val certificate = trustStore.getCertificate(alias) as? X509Certificate
?: continue
encodedCertificates += Base64.encodeToString(
certificate.encoded,
Base64.NO_WRAP,
)
}
check(encodedCertificates.isNotEmpty()) {
"AndroidCAStore contained no X.509 trust anchors"
}
val outputFile = File(context.cacheDir, CA_BUNDLE_FILE_NAME)
val temporaryFile = File(context.cacheDir, "$CA_BUNDLE_FILE_NAME.tmp")
temporaryFile.bufferedWriter(Charsets.US_ASCII).use { writer ->
for (encodedCertificate in encodedCertificates) {
writer.appendLine("-----BEGIN CERTIFICATE-----")
encodedCertificate.chunked(64).forEach(writer::appendLine)
writer.appendLine("-----END CERTIFICATE-----")
}
}
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Could not replace cached CA bundle")
}
if (!temporaryFile.renameTo(outputFile)) {
temporaryFile.copyTo(outputFile, overwrite = true)
temporaryFile.delete()
}
return outputFile.absolutePath
}
private fun postResult(callback: () -> Unit) {
if (disposed) return
mainHandler.post {
if (!disposed) callback()
}
}
fun dispose() {
disposed = true
methodChannel.setMethodCallHandler(null)
worker.shutdownNow()
}
}
@@ -0,0 +1,80 @@
package com.smplayer.smplayer
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.FileProvider
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
class ApkInstallerBridge(
private val context: Context,
flutterEngine: FlutterEngine,
) {
companion object {
private const val CHANNEL_NAME = "smplayer/apk_installer"
}
private val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME
)
init {
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"installApk" -> {
val filePath = call.argument<String>("path")
if (filePath.isNullOrEmpty()) {
result.error("INVALID_PATH", "APK file path is null or empty", null)
return@setMethodCallHandler
}
try {
launchApkInstall(filePath)
result.success(true)
} catch (exception: Exception) {
result.error(
"INSTALL_FAILED",
exception.message ?: "Failed to launch APK installer",
null,
)
}
}
"canRequestInstall" -> {
val canRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
result.success(canRequest)
}
else -> result.notImplemented()
}
}
}
private fun launchApkInstall(filePath: String) {
val apkFile = File(filePath)
if (!apkFile.exists()) {
throw IllegalArgumentException("APK file does not exist: $filePath")
}
val authority = "${context.packageName}.fileprovider"
val contentUri = FileProvider.getUriForFile(context, authority, apkFile)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(contentUri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(installIntent)
}
fun dispose() {
methodChannel.setMethodCallHandler(null)
}
}
@@ -0,0 +1,71 @@
package com.smplayer.smplayer
import android.content.res.Configuration
import android.media.AudioManager
import android.os.Bundle
import android.view.KeyEvent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var systemAudio: SystemAudioBridge? = null
private var pipBridge: PipBridge? = null
private var apkInstaller: ApkInstallerBridge? = null
private var androidTls: AndroidTlsBridge? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
systemAudio = SystemAudioBridge(applicationContext, flutterEngine)
pipBridge = PipBridge(this, flutterEngine)
apkInstaller = ApkInstallerBridge(applicationContext, flutterEngine)
androidTls = AndroidTlsBridge(applicationContext, flutterEngine)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
volumeControlStream = AudioManager.STREAM_MUSIC
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val bridge = systemAudio
if (bridge != null && bridge.keyInterceptEnabled) {
val isVolumeUp = event.keyCode == KeyEvent.KEYCODE_VOLUME_UP
val isVolumeDown = event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
if (isVolumeUp || isVolumeDown) {
if (event.action == KeyEvent.ACTION_DOWN) {
bridge.adjustVolume(
if (isVolumeUp) 1 else -1,
showSystemUi = false,
)
}
return true
}
}
return super.dispatchKeyEvent(event)
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
pipBridge?.onUserLeaveHint()
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pipBridge?.onPipModeChanged(isInPictureInPictureMode)
}
override fun onDestroy() {
pipBridge?.dispose()
pipBridge = null
apkInstaller?.dispose()
apkInstaller = null
androidTls?.dispose()
androidTls = null
systemAudio?.dispose()
systemAudio = null
super.onDestroy()
}
}
@@ -0,0 +1,325 @@
package com.smplayer.smplayer
import android.app.Activity
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Rational
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class PipBridge(
private val activity: Activity,
flutterEngine: FlutterEngine,
) {
companion object {
private const val METHOD_CHANNEL = "smplayer/pip"
private const val EVENT_CHANNEL = "smplayer/pip_events"
private const val ACTION_PLAY = "com.smplayer.pip.PLAY"
private const val ACTION_PAUSE = "com.smplayer.pip.PAUSE"
private const val ACTION_NEXT = "com.smplayer.pip.NEXT"
private const val ACTION_PREV = "com.smplayer.pip.PREV"
private const val ACTION_SEEK_FWD = "com.smplayer.pip.SEEK_FWD"
private const val ACTION_SEEK_BWD = "com.smplayer.pip.SEEK_BWD"
private const val REQUEST_PLAY = 1
private const val REQUEST_PAUSE = 2
private const val REQUEST_NEXT = 3
private const val REQUEST_PREV = 4
private const val REQUEST_SEEK_FWD = 5
private const val REQUEST_SEEK_BWD = 6
}
private val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL
)
private val eventChannel = EventChannel(
flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL
)
private var eventSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var autoEnterOnLeave = false
private var aspectX = 16
private var aspectY = 9
private var isPlaying = false
private var hasNext = false
private var hasPrev = false
private var useEpisodeActions = true
private val actionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
val dartAction = when (action) {
ACTION_PLAY -> "play"
ACTION_PAUSE -> "pause"
ACTION_NEXT -> "next"
ACTION_PREV -> "prev"
ACTION_SEEK_FWD -> "seekForward"
ACTION_SEEK_BWD -> "seekBackward"
else -> return
}
mainHandler.post {
eventSink?.success(mapOf("type" to "action", "action" to dartAction))
}
}
}
private var receiverRegistered = false
init {
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
}
"isInPipMode" -> {
result.success(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
activity.isInPictureInPictureMode
else false
)
}
"configure" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
aspectX = (args["aspectX"] as? Number)?.toInt() ?: 16
aspectY = (args["aspectY"] as? Number)?.toInt() ?: 9
autoEnterOnLeave = args["autoEnterOnLeave"] as? Boolean ?: false
isPlaying = args["isPlaying"] as? Boolean ?: false
hasNext = args["hasNext"] as? Boolean ?: false
hasPrev = args["hasPrev"] as? Boolean ?: false
useEpisodeActions = args["useEpisodeActions"] as? Boolean ?: true
updatePipParams()
}
result.success(true)
}
"enterPip" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
aspectX = (args["aspectX"] as? Number)?.toInt() ?: aspectX
aspectY = (args["aspectY"] as? Number)?.toInt() ?: aspectY
}
result.success(enterPip())
}
"updatePlaybackState" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
isPlaying = args["isPlaying"] as? Boolean ?: isPlaying
hasNext = args["hasNext"] as? Boolean ?: hasNext
hasPrev = args["hasPrev"] as? Boolean ?: hasPrev
useEpisodeActions = args["useEpisodeActions"] as? Boolean ?: useEpisodeActions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
activity.isInPictureInPictureMode
) {
updatePipParams()
}
}
result.success(true)
}
else -> result.notImplemented()
}
}
eventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
registerActionReceiver()
}
override fun onCancel(arguments: Any?) {
eventSink = null
unregisterActionReceiver()
}
})
}
private fun registerActionReceiver() {
if (receiverRegistered) return
val filter = IntentFilter().apply {
addAction(ACTION_PLAY)
addAction(ACTION_PAUSE)
addAction(ACTION_NEXT)
addAction(ACTION_PREV)
addAction(ACTION_SEEK_FWD)
addAction(ACTION_SEEK_BWD)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
activity.registerReceiver(actionReceiver, filter)
}
receiverRegistered = true
}
private fun unregisterActionReceiver() {
if (!receiverRegistered) return
try {
activity.unregisterReceiver(actionReceiver)
} catch (_: IllegalArgumentException) {}
receiverRegistered = false
}
private fun buildRemoteActions(): List<RemoteAction> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
val actions = mutableListOf<RemoteAction>()
if (useEpisodeActions && (hasNext || hasPrev)) {
actions.add(
makeAction(
android.R.drawable.ic_media_previous,
"上一集",
ACTION_PREV,
REQUEST_PREV,
enabled = hasPrev,
)
)
actions.add(
if (isPlaying) makeAction(
android.R.drawable.ic_media_pause,
"暂停",
ACTION_PAUSE,
REQUEST_PAUSE,
) else makeAction(
android.R.drawable.ic_media_play,
"播放",
ACTION_PLAY,
REQUEST_PLAY,
)
)
actions.add(
makeAction(
android.R.drawable.ic_media_next,
"下一集",
ACTION_NEXT,
REQUEST_NEXT,
enabled = hasNext,
)
)
} else {
actions.add(
makeAction(
android.R.drawable.ic_media_rew,
"快退",
ACTION_SEEK_BWD,
REQUEST_SEEK_BWD,
)
)
actions.add(
if (isPlaying) makeAction(
android.R.drawable.ic_media_pause,
"暂停",
ACTION_PAUSE,
REQUEST_PAUSE,
) else makeAction(
android.R.drawable.ic_media_play,
"播放",
ACTION_PLAY,
REQUEST_PLAY,
)
)
actions.add(
makeAction(
android.R.drawable.ic_media_ff,
"快进",
ACTION_SEEK_FWD,
REQUEST_SEEK_FWD,
)
)
}
return actions
}
private fun makeAction(
iconRes: Int,
title: String,
action: String,
requestCode: Int,
enabled: Boolean = true,
): RemoteAction {
val intent = Intent(action).setPackage(activity.packageName)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pendingIntent = PendingIntent.getBroadcast(activity, requestCode, intent, flags)
val icon = Icon.createWithResource(activity, iconRes)
return RemoteAction(icon, title, title, pendingIntent).apply {
isEnabled = enabled
}
}
private fun buildPipParams(): PictureInPictureParams? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(aspectX, aspectY))
.setActions(buildRemoteActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(autoEnterOnLeave && isPlaying)
builder.setSeamlessResizeEnabled(true)
}
return builder.build()
}
private fun updatePipParams() {
val params = buildPipParams() ?: return
try {
activity.setPictureInPictureParams(params)
} catch (e: Exception) {
}
}
fun enterPip(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
val params = buildPipParams() ?: return false
return try {
activity.enterPictureInPictureMode(params)
true
} catch (e: Exception) {
false
}
}
fun onUserLeaveHint() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
if (!autoEnterOnLeave || !isPlaying) return
enterPip()
}
fun onPipModeChanged(isInPip: Boolean) {
mainHandler.post {
eventSink?.success(mapOf("type" to "modeChanged", "isInPip" to isInPip))
}
}
fun dispose() {
unregisterActionReceiver()
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
eventSink = null
}
}
@@ -0,0 +1,123 @@
package com.smplayer.smplayer
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class SystemAudioBridge(
private val context: Context,
flutterEngine: FlutterEngine,
) {
companion object {
private const val METHOD_CHANNEL = "smplayer/system_audio"
private const val EVENT_CHANNEL = "smplayer/system_audio_events"
private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION"
private const val STREAM_TYPE = AudioManager.STREAM_MUSIC
}
private val audioManager: AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL
)
private val eventChannel = EventChannel(
flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL
)
private var eventSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile
var keyInterceptEnabled: Boolean = false
private set
private val volumeReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.action == VOLUME_CHANGED_ACTION) {
val streamType = intent.getIntExtra(
"android.media.EXTRA_VOLUME_STREAM_TYPE", -1
)
if (streamType == STREAM_TYPE) {
postVolumeEvent()
}
}
}
}
init {
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"getVolume" -> result.success(currentVolumeMap())
"adjustVolume" -> {
val delta = (call.argument<Int>("delta") ?: 0)
adjustVolume(delta, showSystemUi = false)
result.success(currentVolumeMap())
}
"setKeyIntercept" -> {
keyInterceptEnabled = call.argument<Boolean>("enabled") ?: false
result.success(null)
}
else -> result.notImplemented()
}
}
eventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
context.registerReceiver(
volumeReceiver, IntentFilter(VOLUME_CHANGED_ACTION)
)
}
override fun onCancel(arguments: Any?) {
try {
context.unregisterReceiver(volumeReceiver)
} catch (_: IllegalArgumentException) {
}
eventSink = null
}
})
}
fun adjustVolume(delta: Int, showSystemUi: Boolean) {
val direction = when {
delta > 0 -> AudioManager.ADJUST_RAISE
delta < 0 -> AudioManager.ADJUST_LOWER
else -> return
}
val flag = if (showSystemUi) AudioManager.FLAG_SHOW_UI else 0
repeat(kotlin.math.abs(delta).coerceAtMost(20)) {
audioManager.adjustStreamVolume(STREAM_TYPE, direction, flag)
}
postVolumeEvent()
}
private fun currentVolumeMap(): Map<String, Int> = mapOf(
"current" to audioManager.getStreamVolume(STREAM_TYPE),
"max" to audioManager.getStreamMaxVolume(STREAM_TYPE),
)
private fun postVolumeEvent() {
val payload = currentVolumeMap()
mainHandler.post {
eventSink?.success(payload)
}
}
fun dispose() {
try {
context.unregisterReceiver(volumeReceiver)
} catch (_: IllegalArgumentException) {
}
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
eventSink = null
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- UpdateService 下载 APK 至 getTemporaryDirectory() 下的 smplayer-update/ -->
<cache-path name="update_apk" path="smplayer-update/" />
</paths>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+8
View File
@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m
# 让 Gradle 直连、不读 macOS 系统代理,避免 Reqable/Charles 等抓包代理残留导致依赖下载挂起或连接被拒
systemProp.java.net.useSystemProxies=false
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+14
View File
@@ -0,0 +1,14 @@
# 本地 release 签名配置。
# 复制为 key.properties 并填入你的 keystore 信息。
# key.properties 已被 .gitignore 忽略,不会提交到仓库。
#
# 生成 keystore
# keytool -genkey -v -keystore release.keystore -alias smplayer \
# -keyalg RSA -keysize 2048 -validity 10000
#
# storeFile 路径相对于 android/app/ 目录解析。
storeFile=release.keystore
storePassword=
keyAlias=smplayer
keyPassword=
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")