diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 316b7f48..954d2f4a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,7 +22,7 @@ android {
minSdk = 24
targetSdk = 35
versionCode = 24
- versionName = "2.1-beta15"
+ versionName = "2.1-beta16"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -190,6 +190,7 @@ dependencies {
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.profileinstaller)
+ implementation(libs.androidx.media)
implementation(libs.material)
implementation(libs.material.kolor)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 12696ef2..af7eeda2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
+
+
+
+
{
+ private const val BACKGROUND_PLAY = "backgroundPlay"
+ override val default = false
+
+ val key = booleanPreferencesKey(BACKGROUND_PLAY)
+
+ fun put(context: Context, scope: CoroutineScope, value: Boolean) {
+ scope.launch(Dispatchers.IO) {
+ context.dataStore.put(key, value)
+ }
+ }
+
+ override fun fromPreferences(preferences: Preferences): Boolean = preferences[key] ?: default
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/skyd/anivu/model/repository/PlayerRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/PlayerRepository.kt
index 74481953..440270ab 100644
--- a/app/src/main/java/com/skyd/anivu/model/repository/PlayerRepository.kt
+++ b/app/src/main/java/com/skyd/anivu/model/repository/PlayerRepository.kt
@@ -21,7 +21,7 @@ class PlayerRepository @Inject constructor(
fun requestLastPlayPosition(path: String): Flow {
return flow {
- emit(mediaPlayHistoryDao.getMediaPlayHistory(path).lastPlayPosition)
+ emit(mediaPlayHistoryDao.getMediaPlayHistory(path)?.lastPlayPosition ?: 0L)
}.flowOn(Dispatchers.IO)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/skyd/anivu/ui/activity/PlayActivity.kt b/app/src/main/java/com/skyd/anivu/ui/activity/PlayActivity.kt
deleted file mode 100644
index 120e396c..00000000
--- a/app/src/main/java/com/skyd/anivu/ui/activity/PlayActivity.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package com.skyd.anivu.ui.activity
-
-import android.app.Activity
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.WindowManager
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.core.content.IntentCompat
-import androidx.core.util.Consumer
-import com.skyd.anivu.base.BaseComposeActivity
-import com.skyd.anivu.ext.savePictureToMediaStore
-import com.skyd.anivu.ui.component.showToast
-import com.skyd.anivu.ui.mpv.MPVView
-import com.skyd.anivu.ui.mpv.PlayerView
-import com.skyd.anivu.ui.mpv.copyAssetsForMpv
-import java.io.File
-
-
-class PlayActivity : BaseComposeActivity() {
- companion object {
- const val VIDEO_URI_KEY = "videoUri"
- const val VIDEO_TITLE_KEY = "videoTitle"
-
- fun play(activity: Activity, uri: Uri, title: String? = null) {
- activity.startActivity(
- Intent(activity, PlayActivity::class.java).apply {
- putExtra(VIDEO_URI_KEY, uri)
- putExtra(VIDEO_TITLE_KEY, title)
- }
- )
- }
- }
-
- private var player: MPVView? = null
- private lateinit var picture: File
- private val requestPermissionLauncher = registerForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { isGranted: Boolean ->
- if (isGranted) {
- picture.savePictureToMediaStore(this)
- } else {
- getString(com.skyd.anivu.R.string.player_no_permission_cannot_save_screenshot).showToast()
- }
- }
-
- private var videoUri by mutableStateOf(null)
- private var videoTitle by mutableStateOf(null)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- copyAssetsForMpv(this)
-
- super.onCreate(savedInstanceState)
-
- // Keep screen on
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-
- handleIntent(intent)
-
- setContentBase {
- DisposableEffect(Unit) {
- val listener = Consumer { newIntent -> handleIntent(newIntent) }
- addOnNewIntentListener(listener)
- onDispose { removeOnNewIntentListener(listener) }
- }
- videoUri?.let { uri ->
- PlayerView(
- uri = uri,
- title = videoTitle,
- onBack = { finish() },
- onSaveScreenshot = {
- picture = it
- saveScreenshot()
- },
- onPlayerChanged = { player = it }
- )
- }
- }
- }
-
- private fun handleIntent(intent: Intent?) {
- intent ?: return
-
- videoUri = IntentCompat.getParcelableExtra(
- intent, VIDEO_URI_KEY, Uri::class.java
- ) ?: intent.data
- videoTitle = intent.getStringExtra(VIDEO_TITLE_KEY)
- }
-
- private fun saveScreenshot() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- picture.savePictureToMediaStore(this)
- } else {
- requestPermissionLauncher.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
- }
- }
-
- override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- if (player?.onKey(event) == true) {
- return true
- }
- return super.dispatchKeyEvent(event)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/skyd/anivu/ui/activity/player/PlayActivity.kt b/app/src/main/java/com/skyd/anivu/ui/activity/player/PlayActivity.kt
new file mode 100644
index 00000000..7f642c6f
--- /dev/null
+++ b/app/src/main/java/com/skyd/anivu/ui/activity/player/PlayActivity.kt
@@ -0,0 +1,162 @@
+package com.skyd.anivu.ui.activity.player
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.ServiceConnection
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import android.view.KeyEvent
+import android.view.WindowManager
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
+import androidx.core.util.Consumer
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.skyd.anivu.R
+import com.skyd.anivu.base.BaseComposeActivity
+import com.skyd.anivu.ext.dataStore
+import com.skyd.anivu.ext.getOrDefault
+import com.skyd.anivu.ext.savePictureToMediaStore
+import com.skyd.anivu.model.preference.player.BackgroundPlayPreference
+import com.skyd.anivu.ui.component.showToast
+import com.skyd.anivu.ui.mpv.service.PlayerNotificationReceiver
+import com.skyd.anivu.ui.mpv.service.PlayerService
+import com.skyd.anivu.ui.mpv.PlayerViewRoute
+import com.skyd.anivu.ui.mpv.copyAssetsForMpv
+import java.io.File
+
+
+class PlayActivity : BaseComposeActivity() {
+ companion object {
+ const val VIDEO_URI_KEY = "videoUri"
+ const val VIDEO_TITLE_KEY = "videoTitle"
+
+ fun play(activity: Activity, uri: Uri, title: String? = null) {
+ activity.startActivity(
+ Intent(activity, PlayActivity::class.java).apply {
+ putExtra(VIDEO_URI_KEY, uri)
+ putExtra(VIDEO_TITLE_KEY, title)
+ }
+ )
+ }
+ }
+
+ private val viewModel: PlayerViewModel by viewModels()
+ private lateinit var picture: File
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ picture.savePictureToMediaStore(this)
+ } else {
+ getString(R.string.player_no_permission_cannot_save_screenshot).showToast()
+ }
+ }
+
+ private lateinit var service: PlayerService
+ private var serviceBound by mutableStateOf(false)
+ private val connection = object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ val binder = service as PlayerService.PlayerServiceBinder
+ this@PlayActivity.service = binder.getService().apply {
+ if (uri != Uri.EMPTY && viewModel.uri.value == Uri.EMPTY) {
+ viewModel.uri.tryEmit(uri)
+ }
+ }
+ serviceBound = true
+ }
+
+ override fun onServiceDisconnected(arg0: ComponentName) {
+ serviceBound = false
+ finish()
+ }
+ }
+
+ private val serviceStopReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (context == null || intent == null) return
+ if (intent.action == PlayerNotificationReceiver.FINISH_PLAY_ACTIVITY_ACTION) {
+ finish()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ copyAssetsForMpv(this)
+
+ super.onCreate(savedInstanceState)
+
+ // Keep screen on
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ viewModel.handleIntent(intent)
+
+ ContextCompat.registerReceiver(
+ this,
+ serviceStopReceiver,
+ IntentFilter(PlayerNotificationReceiver.FINISH_PLAY_ACTIVITY_ACTION),
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+
+ val serviceIntent = Intent(this, PlayerService::class.java)
+ ContextCompat.startForegroundService(this, serviceIntent)
+ bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
+
+ setContentBase {
+ DisposableEffect(Unit) {
+ val listener = Consumer { newIntent -> viewModel.handleIntent(newIntent) }
+ addOnNewIntentListener(listener)
+ onDispose { removeOnNewIntentListener(listener) }
+ }
+ val uri by viewModel.uri.collectAsStateWithLifecycle()
+ val title by viewModel.title.collectAsStateWithLifecycle()
+ if (uri != Uri.EMPTY) {
+ PlayerViewRoute(
+ service = if (serviceBound) service else null,
+ uri = uri,
+ title = title,
+ onBack = { finish() },
+ onSaveScreenshot = {
+ picture = it
+ saveScreenshot()
+ },
+ )
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ unregisterReceiver(serviceStopReceiver)
+ unbindService(connection)
+ serviceBound = false
+ if (!dataStore.getOrDefault(BackgroundPlayPreference)) {
+ stopService(Intent(this, PlayerService::class.java))
+ }
+ }
+
+ private fun saveScreenshot() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ picture.savePictureToMediaStore(this)
+ } else {
+ requestPermissionLauncher.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (serviceBound && service.player.onKey(event)) {
+ return true
+ }
+ return super.dispatchKeyEvent(event)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/skyd/anivu/ui/activity/player/PlayerViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/activity/player/PlayerViewModel.kt
new file mode 100644
index 00000000..200bebf2
--- /dev/null
+++ b/app/src/main/java/com/skyd/anivu/ui/activity/player/PlayerViewModel.kt
@@ -0,0 +1,27 @@
+package com.skyd.anivu.ui.activity.player
+
+import android.content.Intent
+import android.net.Uri
+import androidx.core.content.IntentCompat
+import androidx.lifecycle.ViewModel
+import com.skyd.anivu.ui.activity.player.PlayActivity.Companion.VIDEO_TITLE_KEY
+import com.skyd.anivu.ui.activity.player.PlayActivity.Companion.VIDEO_URI_KEY
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+
+@HiltViewModel
+class PlayerViewModel @Inject constructor() : ViewModel() {
+ val uri: MutableStateFlow = MutableStateFlow(Uri.EMPTY)
+ val title: MutableStateFlow = MutableStateFlow(null)
+
+ fun handleIntent(intent: Intent?) {
+ intent ?: return
+
+ val uri = IntentCompat.getParcelableExtra(
+ intent, VIDEO_URI_KEY, Uri::class.java
+ ) ?: intent.data ?: return
+ this.uri.tryEmit(uri)
+ this.title.tryEmit(intent.getStringExtra(VIDEO_TITLE_KEY))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/skyd/anivu/ui/local/LocalValue.kt b/app/src/main/java/com/skyd/anivu/ui/local/LocalValue.kt
index b2098177..9e429140 100644
--- a/app/src/main/java/com/skyd/anivu/ui/local/LocalValue.kt
+++ b/app/src/main/java/com/skyd/anivu/ui/local/LocalValue.kt
@@ -39,6 +39,7 @@ import com.skyd.anivu.model.preference.data.autodelete.AutoDeleteArticleKeepFavo
import com.skyd.anivu.model.preference.data.autodelete.AutoDeleteArticleKeepUnreadPreference
import com.skyd.anivu.model.preference.data.autodelete.UseAutoDeletePreference
import com.skyd.anivu.model.preference.data.medialib.MediaLibLocationPreference
+import com.skyd.anivu.model.preference.player.BackgroundPlayPreference
import com.skyd.anivu.model.preference.player.HardwareDecodePreference
import com.skyd.anivu.model.preference.player.PlayerAutoPipPreference
import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference
@@ -135,6 +136,7 @@ val LocalPlayerAutoPip = compositionLocalOf { PlayerAutoPipPreference.default }
val LocalPlayerMaxCacheSize = compositionLocalOf { PlayerMaxCacheSizePreference.default }
val LocalPlayerMaxBackCacheSize = compositionLocalOf { PlayerMaxBackCacheSizePreference.default }
val LocalPlayerSeekOption = compositionLocalOf { PlayerSeekOptionPreference.default }
+val LocalBackgroundPlay = compositionLocalOf { BackgroundPlayPreference.default }
// Data
val LocalUseAutoDelete = compositionLocalOf { UseAutoDeletePreference.default }
diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/MPVPlayer.kt
similarity index 82%
rename from app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt
rename to app/src/main/java/com/skyd/anivu/ui/mpv/MPVPlayer.kt
index a9fc5c80..f837fd68 100644
--- a/app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt
+++ b/app/src/main/java/com/skyd/anivu/ui/mpv/MPVPlayer.kt
@@ -1,14 +1,13 @@
package com.skyd.anivu.ui.mpv
-import android.content.Context
-import android.os.Build
-import android.util.AttributeSet
+
+import android.app.Application
+import android.graphics.Bitmap
import android.util.Log
import android.view.KeyCharacterMap
import android.view.KeyEvent
import android.view.SurfaceHolder
-import android.view.SurfaceView
-import android.view.WindowManager
+import androidx.core.content.ContextCompat
import com.skyd.anivu.config.Const
import com.skyd.anivu.ext.dataStore
import com.skyd.anivu.ext.getOrDefault
@@ -35,19 +34,38 @@ import kotlin.math.log
import kotlin.random.Random
import kotlin.reflect.KProperty
-class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, attrs),
- SurfaceHolder.Callback {
- private var surfaceCreated = false
-
- private val scope = CoroutineScope(Dispatchers.IO)
-
+class MPVPlayer(private val context: Application) : SurfaceHolder.Callback, MPVLib.EventObserver {
companion object {
- private const val TAG = "mpv"
+ private const val TAG = "MPVPlayer"
+
+ // resolution (px) of the thumbnail
+ private const val THUMB_SIZE = 1024
@Volatile
private var initialized = false
+
+ @Volatile
+ private var instance: MPVPlayer? = null
+
+ fun getInstance(context: Application): MPVPlayer {
+ if (instance == null) {
+ synchronized(MPVPlayer::class.java) {
+ if (instance == null) {
+ instance = MPVPlayer(context)
+ }
+ }
+ }
+ instance?.initialize(
+ configDir = Const.MPV_CONFIG_DIR.path,
+ cacheDir = Const.MPV_CACHE_DIR.path,
+ fontDir = Const.MPV_FONT_DIR.path,
+ )
+ return instance!!
+ }
}
+ private val scope = CoroutineScope(Dispatchers.IO)
+
fun initialize(
configDir: String,
cacheDir: String,
@@ -56,12 +74,12 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
vo: String = "gpu",
) {
if (initialized) return
- synchronized(MPVView::class.java) {
+ synchronized(MPVPlayer::class.java) {
if (initialized) return
initialized = true
}
- MPVLib.create(this.context, logLvl)
+ MPVLib.create(context, logLvl)
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", configDir)
for (opt in arrayOf("gpu-shader-cache-dir", "icc-cache-dir"))
@@ -78,8 +96,9 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
MPVLib.setPropertyString("sub-fonts-dir", fontDir)
MPVLib.setPropertyString("osd-fonts-dir", fontDir)
- holder.addCallback(this)
observeProperties()
+
+ MPVLib.addObserver(this)
}
private var voInUse: String = ""
@@ -92,12 +111,7 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
voInUse = vo
// vo: set display fps as reported by android
- val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- context.display
- } else {
- @Suppress("DEPRECATION")
- (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
- }
+ val disp = ContextCompat.getDisplayOrDefault(context)
val refreshRate = disp.mode.refreshRate
val dataStore = context.dataStore
@@ -127,16 +141,13 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
MPVLib.setOptionString("screenshot-directory", Const.PICTURES_DIR.path)
}
- private var filePath: String? = null
-
// Called when back button is pressed, or app is shutting down
fun destroy() {
- // Disable surface callbacks to avoid using unintialized mpv state
- holder.removeCallback(this)
-
- MPVLib.destroy()
-
- initialized = false
+ if (initialized) {
+ MPVLib.destroy()
+ MPVLib.removeObserver(this)
+ initialized = false
+ }
}
fun onKey(event: KeyEvent): Boolean {
@@ -193,6 +204,9 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
Property("pause", MPV_FORMAT_FLAG),
Property("eof-reached", MPV_FORMAT_FLAG),
Property("paused-for-cache", MPV_FORMAT_FLAG),
+ Property("idle-active", MPV_FORMAT_FLAG),
+ Property("aid", MPV_FORMAT_INT64),
+ Property("sid", MPV_FORMAT_INT64),
Property("track-list"),
// observing double properties is not hooked up in the JNI code, but doing this
// will restrict updates to when it actually changes
@@ -206,8 +220,7 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
Property("playlist-count", MPV_FORMAT_INT64),
Property("video-format"),
Property("media-title", MPV_FORMAT_STRING),
- Property("metadata/by-key/Artist", MPV_FORMAT_STRING),
- Property("metadata/by-key/Album", MPV_FORMAT_STRING),
+ Property("metadata"),
Property("loop-playlist"),
Property("loop-file"),
Property("shuffle", MPV_FORMAT_FLAG),
@@ -219,26 +232,20 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att
}
}
- fun addObserver(o: MPVLib.EventObserver) {
- MPVLib.addObserver(o)
- }
-
- fun removeObserver(o: MPVLib.EventObserver) {
- MPVLib.removeObserver(o)
- }
-
data class Track(val trackId: Int, val name: String)
- var tracks = mapOf>(
- "audio" to arrayListOf(),
- "video" to arrayListOf(),
- "sub" to arrayListOf()
+ private var tracks = mapOf>(
+ "audio" to mutableListOf(),
+ "video" to mutableListOf(),
+ "sub" to mutableListOf()
)
val subtitleTrack: List