Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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 标为 compileOnly(AGP 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"))))
|
||||
}
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user