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 get() = tracks["sub"].orEmpty().toList() val audioTrack: List get() = tracks["audio"].orEmpty().toList() + val videoTrack: List + get() = tracks["video"].orEmpty().toList() private fun getTrackDisplayName(mpvId: Int, lang: String?, title: String?): String { return if (!lang.isNullOrEmpty() && !title.isNullOrEmpty()) { @@ -335,6 +342,8 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att // Property getters/setters val filename: String? get() = MPVLib.getPropertyString("filename") + val mediaTitle: String? + get() = MPVLib.getPropertyString("media-title") var paused: Boolean get() = MPVLib.getPropertyBoolean("pause") set(paused) = MPVLib.setPropertyBoolean("pause", paused) @@ -345,11 +354,11 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att val keepOpen: Boolean get() = MPVLib.getPropertyBoolean("keep-open") ?: false - val duration: Int? - get() = MPVLib.getPropertyInt("duration") + val duration: Int + get() = MPVLib.getPropertyInt("duration") ?: 0 var timePos: Int - get() = MPVLib.getPropertyInt("time-pos") + get() = MPVLib.getPropertyInt("time-pos") ?: 0 set(progress) = MPVLib.setPropertyInt("time-pos", progress) val hwdecActive: String @@ -389,9 +398,29 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att val videoAspect: Double? get() = MPVLib.getPropertyDouble("video-params/aspect") + val videoFormat: String? + get() = MPVLib.getPropertyString("video-format") + val demuxerCacheDuration: Double get() = MPVLib.getPropertyDouble("demuxer-cache-duration") ?: 0.0 + val artist: String + get() = MPVLib.getPropertyString("metadata/by-key/Artist").orEmpty() + + val album: String + get() = MPVLib.getPropertyString("metadata/by-key/Album").orEmpty() + + var thumbnail: Bitmap? = null + private set + get() { + field = if (videoFormat.isNullOrEmpty()) { + null + } else { + MPVLib.grabThumbnail(THUMB_SIZE) + } + return field + } + class TrackDelegate(private val name: String) { operator fun getValue(thisRef: Any?, property: KProperty<*>): Int { val v = MPVLib.getPropertyString(name) @@ -400,10 +429,7 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { - if (value == -1) - MPVLib.setPropertyString(name, "no") - else - MPVLib.setPropertyInt(name, value) + MPVLib.setPropertyString(name, if (value == -1) "no" else value.toString()) } } @@ -412,9 +438,20 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att var secondarySid: Int by TrackDelegate("secondary-sid") var aid: Int by TrackDelegate("aid") + fun resetAid() = MPVLib.setPropertyString("aid", "auto") + fun resetVid() = MPVLib.setPropertyString("vid", "auto") + fun resetSid() = MPVLib.setPropertyString("sid", "auto") + // Commands - fun cyclePause() = MPVLib.command(arrayOf("cycle", "pause")) + fun cyclePause() { + if (keepOpen && eofReached) { + seek(0) + } else { + MPVLib.command(arrayOf("cycle", "pause")) + } + } + fun cycleAudio() = MPVLib.command(arrayOf("cycle", "audio")) fun cycleSub() = MPVLib.command(arrayOf("cycle", "sub")) fun cycleHwdec() = MPVLib.command(arrayOf("cycle-values", "hwdec", "auto", "no")) @@ -461,38 +498,8 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att MPVLib.setPropertyBoolean("shuffle", newState) } - // Surface callbacks - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - MPVLib.setPropertyString("android-surface-size", "${width}x$height") - } - - override fun surfaceCreated(holder: SurfaceHolder) { - Log.w(TAG, "attaching surface") - MPVLib.attachSurface(holder.surface) - // This forces mpv to render subs/osd/whatever into our surface even if it would ordinarily not - MPVLib.setOptionString("force-window", "yes") - - surfaceCreated = true - if (filePath != null) { - MPVLib.command(arrayOf("loadfile", filePath as String)) - filePath = null - } else { - // We disable video output when the context disappears, enable it back - MPVLib.setPropertyString("vo", voInUse) - } - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - Log.w(TAG, "detaching surface") - MPVLib.setPropertyString("vo", "null") - MPVLib.setOptionString("force-window", "no") - MPVLib.detachSurface() - } - fun loadFile(filePath: String) { - if (surfaceCreated) MPVLib.command(arrayOf("loadfile", filePath)) - else this.filePath = filePath + MPVLib.command(arrayOf("loadfile", filePath)) } fun stop() { @@ -564,4 +571,55 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att MPVLib.command(arrayOf("audio-add", filePath, "cached")) loadAudioTrack() } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + MPVLib.setPropertyString("android-surface-size", "${width}x$height") + } + + override fun surfaceCreated(holder: SurfaceHolder) { + Log.w(TAG, "attaching surface") + MPVLib.attachSurface(holder.surface) + // This forces mpv to render subs/osd/whatever into our surface even if it would ordinarily not + MPVLib.setOptionString("force-window", "yes") + // We disable video output when the context disappears, enable it back + MPVLib.setPropertyString("vo", voInUse) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + if (initialized) { + Log.w(TAG, "detaching surface") + MPVLib.setPropertyString("vo", "null") + MPVLib.setOptionString("force-window", "no") + MPVLib.detachSurface() + } + } + + override fun eventProperty(property: String) { + when (property) { + "track-list" -> loadTracks() + } + } + + override fun eventProperty(property: String, value: Long) { + } + + override fun eventProperty(property: String, value: Boolean) { + } + + override fun eventProperty(property: String, value: String) { + } + + override fun event(eventId: Int) { + when (eventId) { + MPVLib.mpvEventId.MPV_EVENT_FILE_LOADED -> { + resetAid() + resetVid() + resetSid() + } + } + } + + override fun efEvent(err: String?) { + Log.e(TAG, "efEvent: $err") + } } diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerCommand.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerCommand.kt index b5e71600..aa62d267 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerCommand.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerCommand.kt @@ -1,30 +1,61 @@ package com.skyd.anivu.ui.mpv +import android.graphics.Bitmap import android.net.Uri +import android.view.Surface +import android.view.SurfaceHolder import androidx.compose.ui.geometry.Offset +import com.skyd.anivu.ui.mpv.MPVPlayer.Track +import com.skyd.anivu.ui.mpv.service.PlayerState +import java.io.File sealed interface PlayerCommand { + data class Attach(val surfaceHolder: SurfaceHolder) : PlayerCommand + data class Detach(val surface: Surface) : PlayerCommand data class SetUri(val uri: Uri) : PlayerCommand data object Destroy : PlayerCommand - data class Paused(val paused: Boolean) : PlayerCommand - data object GetPaused : PlayerCommand + data class Paused(val paused: Boolean, val uri: Uri) : PlayerCommand data object PlayOrPause : PlayerCommand data class SeekTo(val position: Int) : PlayerCommand data class Rotate(val rotate: Int) : PlayerCommand data class Zoom(val zoom: Float) : PlayerCommand - data object GetZoom : PlayerCommand data class VideoOffset(val offset: Offset) : PlayerCommand - data object GetVideoOffsetX : PlayerCommand - data object GetVideoOffsetY : PlayerCommand data class SetSpeed(val speed: Float) : PlayerCommand - data object GetSpeed : PlayerCommand - data object LoadAllTracks : PlayerCommand - data object GetSubtitleTrack : PlayerCommand data class SetSubtitleTrack(val trackId: Int) : PlayerCommand - data object GetAudioTrack : PlayerCommand data class SetAudioTrack(val trackId: Int) : PlayerCommand - data object Screenshot : PlayerCommand + data class Screenshot(val onSaveScreenshot: (File) -> Unit) : PlayerCommand data class AddSubtitle(val filePath: String) : PlayerCommand data class AddAudio(val filePath: String) : PlayerCommand - data object GetBuffer : PlayerCommand +} + +sealed interface PlayerEvent { + data object ServiceDestroy : PlayerEvent + data object Shutdown : PlayerEvent + data class Idling(val value: Boolean) : PlayerEvent + data class Position(val value: Long) : PlayerEvent + data class Duration(val value: Long) : PlayerEvent + data class Title(val value: String) : PlayerEvent + data class Paused(val value: Boolean) : PlayerEvent + data class PausedForCache(val value: Boolean) : PlayerEvent + data object Seek : PlayerEvent + data object EndFile : PlayerEvent + data object FileLoaded : PlayerEvent + data object PlaybackRestart : PlayerEvent + data class Zoom(val value: Float) : PlayerEvent + data class VideoOffsetX(val value: Float) : PlayerEvent + data class VideoOffsetY(val value: Float) : PlayerEvent + data class Rotate(val value: Float) : PlayerEvent + data class Speed(val value: Float) : PlayerEvent + data class AllSubtitleTracks(val tracks: List) : PlayerEvent + data class SubtitleTrackChanged(val trackId: Int) : PlayerEvent + data class AllAudioTracks(val tracks: List) : PlayerEvent + data class AudioTrackChanged(val trackId: Int) : PlayerEvent + data class Buffer(val bufferDuration: Int) : PlayerEvent + data class Shuffle(val value: Boolean) : PlayerEvent + data class Loop(val value: Int) : PlayerEvent + data class PlaylistPosition(val value: Int) : PlayerEvent + data class PlaylistCount(val value: Int) : PlayerEvent + data class Artist(val value: String) : PlayerEvent + data class Album(val value: String) : PlayerEvent + data class Thumbnail(val value: Bitmap?) : PlayerEvent } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerView.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerView.kt index a81f2b99..4ef51b56 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerView.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/PlayerView.kt @@ -1,13 +1,13 @@ package com.skyd.anivu.ui.mpv import android.net.Uri +import android.view.SurfaceView import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -16,17 +16,13 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.doOnAttach -import androidx.core.view.doOnDetach -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.skyd.anivu.base.mvi.MviEventListener -import com.skyd.anivu.base.mvi.getDispatcher -import com.skyd.anivu.config.Const +import androidx.lifecycle.compose.LocalLifecycleOwner import com.skyd.anivu.ext.activity +import com.skyd.anivu.ext.collectIn import com.skyd.anivu.ui.component.OnLifecycleEvent import com.skyd.anivu.ui.component.rememberSystemUiController +import com.skyd.anivu.ui.local.LocalBackgroundPlay import com.skyd.anivu.ui.local.LocalPlayerAutoPip import com.skyd.anivu.ui.mpv.controller.PlayerController import com.skyd.anivu.ui.mpv.controller.bar.BottomBarCallback @@ -44,125 +40,44 @@ import com.skyd.anivu.ui.mpv.controller.state.dialog.track.AudioTrackDialogCallb import com.skyd.anivu.ui.mpv.controller.state.dialog.track.AudioTrackDialogState import com.skyd.anivu.ui.mpv.controller.state.dialog.track.SubtitleTrackDialogCallback import com.skyd.anivu.ui.mpv.controller.state.dialog.track.SubtitleTrackDialogState -import com.skyd.anivu.ui.mpv.mvi.PlayerEvent -import com.skyd.anivu.ui.mpv.mvi.PlayerIntent -import com.skyd.anivu.ui.mpv.mvi.PlayerViewModel import com.skyd.anivu.ui.mpv.pip.PipBroadcastReceiver import com.skyd.anivu.ui.mpv.pip.PipListenerPreAPI12 import com.skyd.anivu.ui.mpv.pip.manualEnterPictureInPictureMode import com.skyd.anivu.ui.mpv.pip.pipParams import com.skyd.anivu.ui.mpv.pip.rememberIsInPipMode -import `is`.xyz.mpv.MPVLib -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch +import com.skyd.anivu.ui.mpv.service.PlayerService import java.io.File -import kotlin.math.pow -private fun MPVView.solveCommand( - command: PlayerCommand, - uri: () -> Uri, - isPlayingChanged: (Boolean) -> Unit, - onSubtitleTrack: (subtitleTrack: List) -> Unit, - onSubtitleTrackChanged: (Int) -> Unit, - onAudioTrack: (subtitleTrack: List) -> Unit, - onAudioTrackChanged: (Int) -> Unit, - transformState: () -> TransformState, - onVideoZoom: (Float) -> Unit, - onVideoOffset: (Offset) -> Unit, - onSpeedChanged: (Float) -> Unit, +@Composable +fun PlayerViewRoute( + service: PlayerService?, + uri: Uri, + title: String? = null, + onBack: () -> Unit, onSaveScreenshot: (File) -> Unit, - onCacheBufferStateChanged: (Float) -> Unit, ) { - when (command) { - is PlayerCommand.SetUri -> command.uri.resolveUri(context)?.let { loadFile(it) } - PlayerCommand.Destroy -> destroy() - is PlayerCommand.Paused -> { - if (!command.paused) { - if (keepOpen && eofReached) { - seek(0) - } else if (isIdling) { - uri().resolveUri(context)?.let { loadFile(it) } - } - } - paused = command.paused - } - - PlayerCommand.GetPaused -> isPlayingChanged(!paused) - PlayerCommand.PlayOrPause -> cyclePause() - is PlayerCommand.SeekTo -> seek(command.position.coerceIn(0..(duration ?: 0))) - is PlayerCommand.Rotate -> rotate(command.rotate) - is PlayerCommand.Zoom -> zoom(command.zoom) - PlayerCommand.GetZoom -> onVideoZoom(2.0.pow(videoZoom).toFloat()) - is PlayerCommand.VideoOffset -> offset(command.offset.x.toInt(), command.offset.y.toInt()) - PlayerCommand.GetVideoOffsetX -> videoDW?.let { dw -> - onVideoOffset(transformState().videoOffset.copy(x = (videoPanX * dw).toFloat())) - } - - PlayerCommand.GetVideoOffsetY -> videoDH?.let { dh -> - onVideoOffset(transformState().videoOffset.copy(y = (videoPanY * dh).toFloat())) - } - - is PlayerCommand.SetSpeed -> playbackSpeed = command.speed.toDouble() - PlayerCommand.GetSpeed -> onSpeedChanged(playbackSpeed.toFloat()) - PlayerCommand.LoadAllTracks -> loadTracks() - PlayerCommand.GetSubtitleTrack -> onSubtitleTrack(subtitleTrack) - PlayerCommand.GetAudioTrack -> onAudioTrack(audioTrack) - is PlayerCommand.SetSubtitleTrack -> { - sid = command.trackId - onSubtitleTrackChanged(command.trackId) - } - - is PlayerCommand.SetAudioTrack -> { - aid = command.trackId - onAudioTrackChanged(command.trackId) - } - - PlayerCommand.Screenshot -> screenshot(onSaveScreenshot = onSaveScreenshot) - is PlayerCommand.AddSubtitle -> { - addSubtitle(command.filePath) - onSubtitleTrack(subtitleTrack) - } - - is PlayerCommand.AddAudio -> { - addAudio(command.filePath) - onAudioTrack(audioTrack) - } - - PlayerCommand.GetBuffer -> onCacheBufferStateChanged(demuxerCacheDuration.toFloat()) + if (service != null) { + PlayerView(service, uri, title, onBack, onSaveScreenshot) } } @Composable fun PlayerView( + service: PlayerService, uri: Uri, title: String? = null, onBack: () -> Unit, onSaveScreenshot: (File) -> Unit, - configDir: String = Const.MPV_CONFIG_DIR.path, - cacheDir: String = Const.MPV_CACHE_DIR.path, - fontDir: String = Const.MPV_FONT_DIR.path, - onPlayerChanged: (MPVView?) -> Unit, - viewModel: PlayerViewModel = hiltViewModel(), ) { val systemUiController = rememberSystemUiController().apply { isSystemBarsVisible = false systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } - val commandQueue = remember { Channel(capacity = UNLIMITED) } - val scope = rememberCoroutineScope() - val context = LocalContext.current - val uiState by viewModel.viewState.collectAsStateWithLifecycle() - val dispatcher = viewModel.getDispatcher(startWith = null) - - val currentUri by rememberUpdatedState(newValue = uri) + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current var mediaLoaded by rememberSaveable { mutableStateOf(false) } @@ -176,19 +91,13 @@ fun PlayerView( var speedDialogState by remember { mutableStateOf(SpeedDialogState.initial) } LaunchedEffect(title) { if (title != null) { - playState = playState.copy(title = title) + playState = playState.copyIfNecessary(title = title) } } LaunchedEffect(playState.speed) { speedDialogState = speedDialogState.copy(currentSpeed = playState.speed) } - LaunchedEffect(mediaLoaded) { - if (uiState.needLoadLastPlayPosition && mediaLoaded && playState.duration > 0) { - uri.resolveUri(context)?.let { mediaId -> - dispatcher(PlayerIntent.TrySeekToLast(mediaId, playState.duration * 1000L)) - } - } - } + val dialogState by remember { mutableStateOf( DialogState( @@ -201,34 +110,36 @@ fun PlayerView( val playStateCallback = remember { PlayStateCallback( - onPlayStateChanged = { commandQueue.trySend(PlayerCommand.Paused(playState.isPlaying)) }, - onPlayOrPause = { commandQueue.trySend(PlayerCommand.PlayOrPause) }, + onPlayStateChanged = { + service.onCommand(PlayerCommand.Paused(playState.isPlaying, uri)) + }, + onPlayOrPause = { service.onCommand(PlayerCommand.PlayOrPause) }, onSeekTo = { - playState = playState.copy(isSeeking = true) - commandQueue.trySend(PlayerCommand.SeekTo(it)) + playState = playState.copyIfNecessary(isSeeking = true) + service.onCommand(PlayerCommand.SeekTo(it)) }, - onSpeedChanged = { commandQueue.trySend(PlayerCommand.SetSpeed(it)) }, + onSpeedChanged = { service.onCommand(PlayerCommand.SetSpeed(it)) }, ) } val transformStateCallback = remember { TransformStateCallback( - onVideoRotate = { commandQueue.trySend(PlayerCommand.Rotate(it.toInt())) }, - onVideoZoom = { commandQueue.trySend(PlayerCommand.Zoom(it)) }, - onVideoOffset = { commandQueue.trySend(PlayerCommand.VideoOffset(it)) }, + onVideoRotate = { service.onCommand(PlayerCommand.Rotate(it.toInt())) }, + onVideoZoom = { service.onCommand(PlayerCommand.Zoom(it)) }, + onVideoOffset = { service.onCommand(PlayerCommand.VideoOffset(it)) }, ) } val dialogCallback = remember { DialogCallback( speedDialogCallback = SpeedDialogCallback( - onSpeedChanged = { commandQueue.trySend(PlayerCommand.SetSpeed(it)) }, + onSpeedChanged = { service.onCommand(PlayerCommand.SetSpeed(it)) }, ), audioTrackDialogCallback = AudioTrackDialogCallback( - onAudioTrackChanged = { commandQueue.trySend(PlayerCommand.SetAudioTrack(it.trackId)) }, - onAddAudioTrack = { commandQueue.trySend(PlayerCommand.AddAudio(it)) }, + onAudioTrackChanged = { service.onCommand(PlayerCommand.SetAudioTrack(it.trackId)) }, + onAddAudioTrack = { service.onCommand(PlayerCommand.AddAudio(it)) }, ), subtitleTrackDialogCallback = SubtitleTrackDialogCallback( - onSubtitleTrackChanged = { commandQueue.trySend(PlayerCommand.SetSubtitleTrack(it.trackId)) }, - onAddSubtitle = { commandQueue.trySend(PlayerCommand.AddSubtitle(it)) }, + onSubtitleTrackChanged = { service.onCommand(PlayerCommand.SetSubtitleTrack(it.trackId)) }, + onAddSubtitle = { service.onCommand(PlayerCommand.AddSubtitle(it)) }, ) ) } @@ -245,75 +156,49 @@ fun PlayerView( val bottomBarCallback = remember { BottomBarCallback( onSpeedClick = { speedDialogState = speedDialogState.copy(show = true) }, - onAudioTrackClick = { commandQueue.trySend(PlayerCommand.GetAudioTrack) }, - onSubtitleTrackClick = { commandQueue.trySend(PlayerCommand.GetSubtitleTrack) }, + onAudioTrackClick = { + audioTrackDialogState = audioTrackDialogState.copyIfNecessary(show = true) + }, + onSubtitleTrackClick = { + subtitleTrackDialogState = subtitleTrackDialogState.copyIfNecessary(show = true) + }, ) } - val mpvObserver = remember { - object : MPVLib.EventObserver { - override fun eventProperty(property: String) { - when (property) { - "video-zoom" -> commandQueue.trySend(PlayerCommand.GetZoom) - "video-pan-x" -> commandQueue.trySend(PlayerCommand.GetVideoOffsetX) - "video-pan-y" -> commandQueue.trySend(PlayerCommand.GetVideoOffsetY) - "speed" -> commandQueue.trySend(PlayerCommand.GetSpeed) - "track-list" -> commandQueue.trySend(PlayerCommand.LoadAllTracks) - "demuxer-cache-duration" -> commandQueue.trySend(PlayerCommand.GetBuffer) - } - } - - override fun eventProperty(property: String, value: Long) { - when (property) { - "time-pos" -> playState = playState.copy(currentPosition = value.toInt()) - "duration" -> playState = playState.copy(duration = value.toInt()) - "video-rotate" -> transformState = - transformState.copy(videoRotate = value.toFloat()) - } - } - - override fun eventProperty(property: String, value: Boolean) { - when (property) { - "pause" -> playState = playState.copy(isPlaying = !value) - } - } - - override fun eventProperty(property: String, value: String) { - when (property) { - "media-title" -> playState = playState.copy(mediaTitle = value) - } - } - - override fun event(eventId: Int) { - when (eventId) { - MPVLib.mpvEventId.MPV_EVENT_SEEK -> playState = - playState.copy(isSeeking = false) - - MPVLib.mpvEventId.MPV_EVENT_END_FILE -> { - mediaLoaded = false - playState = playState.copy(isPlaying = false) - } - - MPVLib.mpvEventId.MPV_EVENT_FILE_LOADED, - MPVLib.mpvEventId.MPV_EVENT_PLAYBACK_RESTART -> { - mediaLoaded = true - commandQueue.trySend(PlayerCommand.GetPaused) - } - } - } - - override fun efEvent(err: String?) { - } + LaunchedEffect(Unit) { + service.playerState.collectIn(lifecycleOwner) { state -> + mediaLoaded = state.mediaLoaded + audioTrackDialogState = audioTrackDialogState.copyIfNecessary( + audioTrack = state.audioTracks, + currentAudioTrack = state.audioTracks.find { it.trackId == state.audioTrackId } + ?: audioTrackDialogState.currentAudioTrack + ) + subtitleTrackDialogState = subtitleTrackDialogState.copyIfNecessary( + subtitleTrack = state.subtitleTracks, + currentSubtitleTrack = state.subtitleTracks.find { it.trackId == state.subtitleTrackId } + ?: subtitleTrackDialogState.currentSubtitleTrack + ) + playState = playState.copyIfNecessary( + isPlaying = !state.paused && state.mediaLoaded, + bufferDuration = state.buffer, + duration = state.duration.toInt(), + currentPosition = state.position.toInt(), + speed = state.speed, + mediaTitle = state.title.orEmpty() + ) + transformState = transformState.copyIfNecessary( + videoRotate = state.rotate, + videoOffset = Offset(x = state.offsetX, y = state.offsetY), + videoZoom = state.zoom, + ) } } - MviEventListener(viewModel.singleEvent) { event -> - when (event) { - is PlayerEvent.TrySeekToLastResultEvent.Success -> commandQueue.trySend( - PlayerCommand.SeekTo((event.position / 1000).toInt().coerceAtLeast(0)) - ) - - PlayerEvent.TrySeekToLastResultEvent.NoNeed -> Unit + val playerObserver = PlayerService.Observer { command -> + when (command) { + is PlayerEvent.Shutdown -> context.activity.finish() + PlayerEvent.Seek -> playState = playState.copyIfNecessary(isSeeking = false) + else -> Unit } } @@ -329,74 +214,18 @@ fun PlayerView( playState = playState, ), factory = { c -> - MPVView(c, null).apply { - initialize( - configDir = configDir, - cacheDir = cacheDir, - fontDir = fontDir, - ) - addObserver(mpvObserver) - scope.launch(Dispatchers.Main.immediate) { - commandQueue - .consumeAsFlow() - .onEach { command -> - solveCommand( - command = command, - uri = { currentUri }, - isPlayingChanged = { - playState = playState.copy(isPlaying = it) - }, - onSubtitleTrack = { - subtitleTrackDialogState = subtitleTrackDialogState.copy( - show = true, - currentSubtitleTrack = subtitleTrack.find { it.trackId == sid }!!, - subtitleTrack = subtitleTrack, - ) - }, - onSubtitleTrackChanged = { newTrackId -> - subtitleTrackDialogState = subtitleTrackDialogState.copy( - currentSubtitleTrack = subtitleTrack.find { it.trackId == newTrackId }!!, - ) - }, - onAudioTrack = { - audioTrackDialogState = audioTrackDialogState.copy( - show = true, - currentAudioTrack = audioTrack.find { it.trackId == aid }!!, - audioTrack = audioTrack, - ) - }, - onAudioTrackChanged = { newTrackId -> - audioTrackDialogState = audioTrackDialogState.copy( - currentAudioTrack = audioTrack.find { it.trackId == newTrackId }!!, - ) - }, - transformState = { transformState }, - onVideoZoom = { - transformState = transformState.copy(videoZoom = it) - }, - onVideoOffset = { - transformState = transformState.copy(videoOffset = it) - }, - onSpeedChanged = { playState = playState.copy(speed = it) }, - onSaveScreenshot = onSaveScreenshot, - onCacheBufferStateChanged = { - playState = playState.copy(bufferDuration = it.toInt()) - } - ) - } - .collect() - } - doOnAttach { onPlayerChanged(this) } - doOnDetach { onPlayerChanged(null) } + SurfaceView(c, null).apply { + service.onCommand(PlayerCommand.Attach(holder)) } }, onRelease = { - it.destroy() + service.onCommand(PlayerCommand.Detach(it.holder.surface)) } ) LaunchedEffect(uri) { - commandQueue.trySend(PlayerCommand.SetUri(uri)) + service.addObserver(playerObserver) + service.onCommand(PlayerCommand.SetUri(uri)) } val inPipMode = rememberIsInPipMode() @@ -416,16 +245,17 @@ fun PlayerView( speedDialogState = speedDialogState.copy(show = false) }, onDismissAudioTrackDialog = { - audioTrackDialogState = audioTrackDialogState.copy(show = false) + audioTrackDialogState = audioTrackDialogState.copyIfNecessary(show = false) }, onDismissSubtitleTrackDialog = { - subtitleTrackDialogState = subtitleTrackDialogState.copy(show = false) + subtitleTrackDialogState = + subtitleTrackDialogState.copyIfNecessary(show = false) }, ) }, transformState = { transformState }, transformStateCallback = transformStateCallback, - onScreenshot = { commandQueue.trySend(PlayerCommand.Screenshot) }, + onScreenshot = { service.onCommand(PlayerCommand.Screenshot(onSaveScreenshot)) }, ) } @@ -433,6 +263,7 @@ fun PlayerView( var needPlayWhenResume by rememberSaveable { mutableStateOf(false) } + val backgroundPlay = LocalBackgroundPlay.current OnLifecycleEvent { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { @@ -440,7 +271,7 @@ fun PlayerView( if (manualPip) manualPip = false } if (needPlayWhenResume) { - commandQueue.trySend(PlayerCommand.Paused(false)) + service.onCommand(PlayerCommand.Paused(false, uri)) } systemUiController.isSystemBarsVisible = false systemUiController.systemBarsBehavior = @@ -448,10 +279,10 @@ fun PlayerView( } Lifecycle.Event.ON_PAUSE -> { - (playState.isPlaying && !autoPip && !manualPip).let { condition -> + (playState.isPlaying && !backgroundPlay && !autoPip && !manualPip).let { condition -> needPlayWhenResume = condition if (condition) { - commandQueue.trySend(PlayerCommand.Paused(true)) + service.onCommand(PlayerCommand.Paused(true, uri)) } } } @@ -463,10 +294,9 @@ fun PlayerView( } Lifecycle.Event.ON_DESTROY -> { - uri.resolveUri(context)?.let { mediaId -> - viewModel.updatePlayHistory( - mediaId, playState.currentPosition * 1000L, - ) + if (!backgroundPlay) { + service.onCommand(PlayerCommand.Destroy) + service.removeObserver(playerObserver) } } diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/PlayState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/PlayState.kt index 517323b7..54fa955d 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/PlayState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/PlayState.kt @@ -24,6 +24,36 @@ data class PlayState( mediaTitle = "", ) } + + fun copyIfNecessary( + isPlaying: Boolean = this.isPlaying, + isSeeking: Boolean = this.isSeeking, + currentPosition: Int = this.currentPosition, + duration: Int = this.duration, + bufferDuration: Int = this.bufferDuration, + speed: Float = this.speed, + title: String = this.title, + mediaTitle: String = this.mediaTitle, + ): PlayState { + return if (isPlaying != this.isPlaying || + isSeeking != this.isSeeking || + currentPosition != this.currentPosition || + duration != this.duration || + bufferDuration != this.bufferDuration || + speed != this.speed || + title != this.title || + mediaTitle != this.mediaTitle + ) copy( + isPlaying = isPlaying, + isSeeking = isSeeking, + currentPosition = currentPosition, + duration = duration, + bufferDuration = bufferDuration, + speed = speed, + title = title, + mediaTitle = mediaTitle + ) else this + } } @Immutable diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/TransformState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/TransformState.kt index d4093453..40225846 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/TransformState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/TransformState.kt @@ -15,6 +15,21 @@ data class TransformState( videoOffset = Offset.Zero, ) } + + fun copyIfNecessary( + videoRotate: Float = this.videoRotate, + videoZoom: Float = this.videoZoom, + videoOffset: Offset = this.videoOffset, + ): TransformState { + return if (videoRotate != this.videoRotate || + videoZoom != this.videoZoom || + videoOffset != this.videoOffset + ) copy( + videoRotate = videoRotate, + videoZoom = videoZoom, + videoOffset = videoOffset, + ) else this + } } @Immutable diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/AudioTrackDialogState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/AudioTrackDialogState.kt index 3c887508..1a6d5a9c 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/AudioTrackDialogState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/AudioTrackDialogState.kt @@ -1,24 +1,39 @@ package com.skyd.anivu.ui.mpv.controller.state.dialog.track import androidx.compose.runtime.Immutable -import com.skyd.anivu.ui.mpv.MPVView +import com.skyd.anivu.ui.mpv.MPVPlayer data class AudioTrackDialogState( val show: Boolean, - val currentAudioTrack: MPVView.Track, - val audioTrack: List, + val currentAudioTrack: MPVPlayer.Track, + val audioTrack: List, ) { companion object { val initial = AudioTrackDialogState( show = false, - currentAudioTrack = MPVView.Track(0, ""), + currentAudioTrack = MPVPlayer.Track(0, ""), audioTrack = emptyList(), ) } + + fun copyIfNecessary( + show: Boolean = this.show, + currentAudioTrack: MPVPlayer.Track = this.currentAudioTrack, + audioTrack: List = this.audioTrack, + ): AudioTrackDialogState { + return if (show != this.show || + currentAudioTrack != this.currentAudioTrack || + audioTrack != this.audioTrack + ) copy( + show = show, + currentAudioTrack = currentAudioTrack, + audioTrack = audioTrack, + ) else this + } } @Immutable data class AudioTrackDialogCallback( - val onAudioTrackChanged: (MPVView.Track) -> Unit, + val onAudioTrackChanged: (MPVPlayer.Track) -> Unit, val onAddAudioTrack: (String) -> Unit, ) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/SubtitleTrackDialogState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/SubtitleTrackDialogState.kt index e88c16d6..8cc52ea9 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/SubtitleTrackDialogState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/controller/state/dialog/track/SubtitleTrackDialogState.kt @@ -1,24 +1,39 @@ package com.skyd.anivu.ui.mpv.controller.state.dialog.track import androidx.compose.runtime.Immutable -import com.skyd.anivu.ui.mpv.MPVView +import com.skyd.anivu.ui.mpv.MPVPlayer data class SubtitleTrackDialogState( val show: Boolean, - val currentSubtitleTrack: MPVView.Track, - val subtitleTrack: List, + val currentSubtitleTrack: MPVPlayer.Track, + val subtitleTrack: List, ) { companion object { val initial = SubtitleTrackDialogState( show = false, - currentSubtitleTrack = MPVView.Track(0, ""), + currentSubtitleTrack = MPVPlayer.Track(0, ""), subtitleTrack = emptyList(), ) } + + fun copyIfNecessary( + show: Boolean = this.show, + currentSubtitleTrack: MPVPlayer.Track = this.currentSubtitleTrack, + subtitleTrack: List = this.subtitleTrack, + ): SubtitleTrackDialogState { + return if (show != this.show || + currentSubtitleTrack != this.currentSubtitleTrack || + subtitleTrack != this.subtitleTrack + ) copy( + show = show, + currentSubtitleTrack = currentSubtitleTrack, + subtitleTrack = subtitleTrack, + ) else this + } } @Immutable data class SubtitleTrackDialogCallback( - val onSubtitleTrackChanged: (MPVView.Track) -> Unit, + val onSubtitleTrackChanged: (MPVPlayer.Track) -> Unit, val onAddSubtitle: (String) -> Unit, ) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerEvent.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerEvent.kt deleted file mode 100644 index 4c86da3c..00000000 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerEvent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.skyd.anivu.ui.mpv.mvi - -import com.skyd.anivu.base.mvi.MviSingleEvent - -sealed interface PlayerEvent : MviSingleEvent { - sealed interface TrySeekToLastResultEvent : PlayerEvent { - data class Success(val position: Long) : TrySeekToLastResultEvent - data object NoNeed : TrySeekToLastResultEvent - } -} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerIntent.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerIntent.kt deleted file mode 100644 index a0f2b8e7..00000000 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerIntent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.skyd.anivu.ui.mpv.mvi - -import com.skyd.anivu.base.mvi.MviIntent - -sealed interface PlayerIntent : MviIntent { - data class TrySeekToLast(val path: String, val duration: Long) : PlayerIntent -} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerPartialStateChange.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerPartialStateChange.kt deleted file mode 100644 index 7c1652aa..00000000 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerPartialStateChange.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.skyd.anivu.ui.mpv.mvi - - -internal sealed interface PlayerPartialStateChange { - fun reduce(oldState: PlayerState): PlayerState - - sealed interface LoadingDialog : PlayerPartialStateChange { - data object Show : LoadingDialog { - override fun reduce(oldState: PlayerState) = oldState.copy(loadingDialog = true) - } - } - - sealed interface TrySeekToLast : PlayerPartialStateChange { - override fun reduce(oldState: PlayerState): PlayerState { - return when (this) { - is Success, - NoNeed, - is Failed -> oldState.copy( - needLoadLastPlayPosition = false, - loadingDialog = false, - ) - } - } - - data class Success(val position: Long) : TrySeekToLast - data object NoNeed : TrySeekToLast - data class Failed(val msg: String) : TrySeekToLast - } -} diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerState.kt deleted file mode 100644 index 094fcdf7..00000000 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.skyd.anivu.ui.mpv.mvi - -import com.skyd.anivu.base.mvi.MviViewState - -data class PlayerState( - val needLoadLastPlayPosition: Boolean, - val loadingDialog: Boolean, -) : MviViewState { - companion object { - fun initial() = PlayerState( - needLoadLastPlayPosition = true, - loadingDialog = true, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerViewModel.kt deleted file mode 100644 index bc3ff788..00000000 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/mvi/PlayerViewModel.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.skyd.anivu.ui.mpv.mvi - -import com.skyd.anivu.base.mvi.AbstractMviViewModel -import com.skyd.anivu.ext.catchMap -import com.skyd.anivu.model.bean.MediaPlayHistoryBean -import com.skyd.anivu.model.repository.PlayerRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class PlayerViewModel @Inject constructor( - private val playerRepo: PlayerRepository, -) : AbstractMviViewModel() { - - private val globalScope = CoroutineScope(Dispatchers.IO) - - fun updatePlayHistory(path: String, lastPlayPosition: Long) { - globalScope.launch { - playerRepo.updatePlayHistory( - MediaPlayHistoryBean(path, lastPlayPosition) - ).collect() - } - } - - override val viewState: StateFlow - - init { - val initialVS = PlayerState.initial() - - viewState = merge( - intentFlow.filterIsInstance().take(1), - intentFlow.filterNot { it is PlayerIntent.TrySeekToLast } - ) - .toPlayerPartialStateChangeFlow() - .debugLog("PlayerPartialStateChange") - .sendSingleEvent() - .scan(initialVS) { vs, change -> change.reduce(vs) } - .debugLog("ViewState") - .toState(initialVS) - } - - private fun Flow.sendSingleEvent(): Flow { - return onEach { change -> - val event = when (change) { - is PlayerPartialStateChange.TrySeekToLast.Success -> { - PlayerEvent.TrySeekToLastResultEvent.Success(change.position) - } - - is PlayerPartialStateChange.TrySeekToLast.NoNeed -> { - PlayerEvent.TrySeekToLastResultEvent.NoNeed - } - - else -> return@onEach - } - sendEvent(event) - } - } - - private fun Flow.toPlayerPartialStateChangeFlow(): Flow { - return merge( - filterIsInstance().flatMapConcat { intent -> - playerRepo.requestLastPlayPosition(intent.path).map { - if (it.toDouble() / intent.duration > 0.9) { - PlayerPartialStateChange.TrySeekToLast.NoNeed - } else { - PlayerPartialStateChange.TrySeekToLast.Success(it) - } - }.catchMap { - PlayerPartialStateChange.TrySeekToLast.Failed(it.message.toString()) - } - }, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/pip/PipUtil.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/pip/PipUtil.kt index b33f9a2c..c35eec75 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/pip/PipUtil.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/pip/PipUtil.kt @@ -11,10 +11,12 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toAndroidRectF @@ -52,32 +54,49 @@ internal fun PipListenerPreAPI12(shouldEnterPipMode: Boolean) { } } +@Composable internal fun Modifier.pipParams( context: Context, shouldEnterPipMode: Boolean, playState: PlayState, ): Modifier = run { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - onGloballyPositioned { layoutCoordinates -> - val builder = PictureInPictureParams.Builder() - if (shouldEnterPipMode) { - builder.setSourceRectHint( - layoutCoordinates - .boundsInWindow() - .toAndroidRectF() - .toRect() + var builder by rememberSaveable { mutableStateOf(null) } + val currentPlayState by rememberUpdatedState(playState) + val setActionsAndApplyBuilder: (PictureInPictureParams.Builder) -> Unit = remember { + { builder -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setActions( + listOfRemoteActions( + playState = currentPlayState, + context = context, + ), ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(shouldEnterPipMode) + } + context.activity.setPictureInPictureParams(builder.build()) } - builder.setActions( - listOfRemoteActions( - playState = playState, - context = context, - ), - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(shouldEnterPipMode) + } + } + + LaunchedEffect(playState.isPlaying) { + builder?.let { setActionsAndApplyBuilder(it) } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + onGloballyPositioned { layoutCoordinates -> + (builder ?: PictureInPictureParams.Builder()).let { b -> + builder = b + if (shouldEnterPipMode) { + b.setSourceRectHint( + layoutCoordinates + .boundsInWindow() + .toAndroidRectF() + .toRect() + ) + } + setActionsAndApplyBuilder(b) } - context.activity.setPictureInPictureParams(builder.build()) } } else this } @@ -130,7 +149,7 @@ fun PipBroadcastReceiver(playStateCallback: PlayStateCallback) { } } -internal fun Activity.manualEnterPictureInPictureMode(){ +internal fun Activity.manualEnterPictureInPictureMode() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) } diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/service/MediaSessionManager.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/service/MediaSessionManager.kt new file mode 100644 index 00000000..59bc262c --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/service/MediaSessionManager.kt @@ -0,0 +1,180 @@ +package com.skyd.anivu.ui.mpv.service + +import android.content.Context +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import com.skyd.anivu.ui.mpv.PlayerEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn + +class MediaSessionManager( + private val context: Context, + private val callback: MediaSessionCompat.Callback, +) : PlayerService.Observer { + val mediaSession: MediaSessionCompat = initMediaSession() + private val mediaMetadataBuilder = MediaMetadataCompat.Builder() + private val playbackStateBuilder = PlaybackStateCompat.Builder() + + private val scope = CoroutineScope(Dispatchers.Main) + private val eventFlow = Channel(Channel.UNLIMITED) + + private val initialPlayerState = PlayerState() + val playerState = eventFlow + .consumeAsFlow() + .scan(initialPlayerState) { old, event -> + val newState = event.reduce(old) + event.updateMediaSession(newState) + return@scan newState + } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, initialPlayerState) + + var state: Int = PlaybackStateCompat.STATE_NONE + private set + + private fun initMediaSession(): MediaSessionCompat { + /* + https://developer.android.com/guide/topics/media-apps/working-with-a-media-session + https://developer.android.com/guide/topics/media-apps/audio-app/mediasession-callbacks + https://developer.android.com/reference/android/support/v4/media/session/MediaSessionCompat + */ + val session = MediaSessionCompat(context, TAG) + session.setFlags(0) + session.setCallback(callback) + return session + } + + override fun onCommand(command: PlayerEvent) { + eventFlow.trySend(command) + } + + private fun PlayerState.buildMediaMetadata(): MediaMetadataCompat { + // TODO could provide: genre, num_tracks, track_number, year + return with(mediaMetadataBuilder) { + putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + // put even if it's null to reset any previous art + putBitmap(MediaMetadataCompat.METADATA_KEY_ART, thumbnail) + putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + putLong( + MediaMetadataCompat.METADATA_KEY_DURATION, + (duration * 1000).takeIf { it > 0 } ?: -1) + putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) + build() + } + } + + private fun PlayerState.buildPlaybackState(): PlaybackStateCompat { + state = when { + idling || position < 0 || duration <= 0 || playlistCount == 0 -> { + PlaybackStateCompat.STATE_NONE + } + + pausedForCache -> PlaybackStateCompat.STATE_BUFFERING + paused -> PlaybackStateCompat.STATE_PAUSED + else -> PlaybackStateCompat.STATE_PLAYING + } + var actions = PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_SET_REPEAT_MODE + if (duration > 0) + actions = actions or PlaybackStateCompat.ACTION_SEEK_TO + if (playlistCount > 1) { + // we could be very pedantic here but it's probably better to either show both or none + actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE + } + mediaSession.isActive = state != PlaybackStateCompat.STATE_NONE + return with(playbackStateBuilder) { + setState(state, position * 1000, speed) + setActions(actions) + //setActiveQueueItemId(0) TODO + build() + } + } + + private fun PlayerEvent.reduce(old: PlayerState): PlayerState = when (this) { + is PlayerEvent.Album -> old.copy(album = value) + is PlayerEvent.AllAudioTracks -> old.copy(audioTracks = tracks) + is PlayerEvent.AllSubtitleTracks -> old.copy(subtitleTracks = tracks) + is PlayerEvent.Artist -> old.copy(artist = value) + is PlayerEvent.AudioTrackChanged -> old.copy(audioTrackId = trackId) + is PlayerEvent.Buffer -> old.copy(buffer = bufferDuration) + is PlayerEvent.Duration -> old.copy(duration = value) + is PlayerEvent.Idling -> old.copy(idling = value) + is PlayerEvent.Paused -> old.copy(paused = value) + PlayerEvent.EndFile -> old.copy(paused = true) + is PlayerEvent.PausedForCache -> old.copy(pausedForCache = value) + is PlayerEvent.PlaylistCount -> old.copy(playlistCount = value) + is PlayerEvent.PlaylistPosition -> old.copy(playlistPosition = value) + is PlayerEvent.Position -> old.copy(position = value) + is PlayerEvent.Rotate -> old.copy(rotate = value) + is PlayerEvent.Shuffle -> old.copy(shuffle = value) + is PlayerEvent.Speed -> old.copy(speed = value) + is PlayerEvent.SubtitleTrackChanged -> old.copy(subtitleTrackId = trackId) + is PlayerEvent.Thumbnail -> old.copy(thumbnail = value) + is PlayerEvent.Title -> old.copy(title = value) + is PlayerEvent.VideoOffsetX -> old.copy(offsetX = value) + is PlayerEvent.VideoOffsetY -> old.copy(offsetY = value) + is PlayerEvent.Zoom -> old.copy(zoom = value) + is PlayerEvent.PlaybackRestart, + is PlayerEvent.FileLoaded -> old.copy(mediaLoaded = true) + + is PlayerEvent.EndFile -> old.copy(mediaLoaded = false) + else -> old + } + + private fun PlayerEvent.updateMediaSession(newState: PlayerState) { + when (this) { + is PlayerEvent.Shuffle -> mediaSession.setShuffleMode( + if (value) PlaybackStateCompat.SHUFFLE_MODE_ALL + else PlaybackStateCompat.SHUFFLE_MODE_NONE + ) + + is PlayerEvent.Loop -> mediaSession.setRepeatMode( + when (value) { + 2 -> PlaybackStateCompat.REPEAT_MODE_ONE + 1 -> PlaybackStateCompat.REPEAT_MODE_ALL + else -> PlaybackStateCompat.REPEAT_MODE_NONE + } + ) + + is PlayerEvent.Paused, + is PlayerEvent.EndFile, + is PlayerEvent.Speed, + is PlayerEvent.Position, + is PlayerEvent.PlaylistCount, + is PlayerEvent.PausedForCache -> { + mediaSession.setPlaybackState(newState.buildPlaybackState()) + } + + is PlayerEvent.Duration -> { + mediaSession.setPlaybackState(newState.buildPlaybackState()) + mediaSession.setMetadata(newState.buildMediaMetadata()) + } + + is PlayerEvent.Idling, + is PlayerEvent.Title, + is PlayerEvent.Artist, + is PlayerEvent.Album, + is PlayerEvent.Thumbnail -> { + mediaSession.setMetadata(newState.buildMediaMetadata()) + } + + is PlayerEvent.PlaylistPosition -> Unit + else -> Unit + } + } + + companion object { + private const val TAG = "MediaSessionManager" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerNotificationManager.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerNotificationManager.kt new file mode 100644 index 00000000..808f10ab --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerNotificationManager.kt @@ -0,0 +1,159 @@ +package com.skyd.anivu.ui.mpv.service + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.os.Build +import android.support.v4.media.session.PlaybackStateCompat +import androidx.annotation.DrawableRes +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import com.skyd.anivu.R +import com.skyd.anivu.ui.activity.player.PlayActivity + +class PlayerNotificationManager( + private val context: Context, + private val sessionManager: MediaSessionManager, +) { + private val playerState get() = sessionManager.playerState + + val notificationBuilder: NotificationCompat.Builder + get() { + val openActivityPendingIntent = PendingIntentCompat.getActivity( + context, + 0, + Intent(context, PlayActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) + val style = androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(sessionManager.mediaSession.sessionToken) + return NotificationCompat.Builder(context, CHANNEL_ID) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setSmallIcon(R.drawable.ic_icon_2_24) + .setStyle(style) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(openActivityPendingIntent) + .setOngoing(true) + .setContentTitle() + .setContentText() + .setThumbnail() + .addAllAction(style) + } + + private fun buildNotificationAction( + @DrawableRes icon: Int, + title: CharSequence, + intentAction: String, + ): NotificationCompat.Action { + val intent = PlayerNotificationReceiver.createIntent(context, intentAction) + val builder = NotificationCompat.Action.Builder(icon, title, intent) + with(builder) { + setContextual(false) + setShowsUserInterface(false) + return build() + } + } + + fun update() { + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + NotificationManagerCompat.from(context) + .notify(NOTIFICATION_ID, notificationBuilder.build()) + } + + private fun NotificationCompat.Builder.addAllAction( + style: androidx.media.app.NotificationCompat.MediaStyle, + ) = apply { + val playPendingIntent = + if (sessionManager.state != PlaybackStateCompat.STATE_PLAYING) buildNotificationAction( + icon = R.drawable.ic_play_arrow_24, + title = context.getString(R.string.play), + intentAction = PlayerNotificationReceiver.PLAY_ACTION + ) else buildNotificationAction( + icon = R.drawable.ic_pause_24, + title = context.getString(R.string.pause), + intentAction = PlayerNotificationReceiver.PLAY_ACTION + ) + val closePendingIntent = buildNotificationAction( + icon = R.drawable.ic_close_24, + title = context.getString(R.string.close), + intentAction = PlayerNotificationReceiver.CLOSE_ACTION + ) + addAction(playPendingIntent) + addAction(closePendingIntent) + + style.setShowActionsInCompactView(0, 1) + } + + fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + if (notificationManager.getNotificationChannel(CHANNEL_ID) != null) { + return + } + val name = context.getString(R.string.player_notification_channel_name) + val descriptionText = + context.getString(R.string.player_notification_channel_description) + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + notificationManager.createNotificationChannel(channel) + } + } + + fun cancel() { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun NotificationCompat.Builder.setContentTitle() = apply { + setContentTitle(playerState.value.title) + } + + private fun NotificationCompat.Builder.setContentText() = apply { + with(playerState.value) { + val artistEmpty = artist.isNullOrEmpty() + val albumEmpty = album.isNullOrEmpty() + setContentText( + when { + !artistEmpty && !albumEmpty -> "$artist / $album" + !artistEmpty -> album + !albumEmpty -> artist + else -> null + } + ) + } + } + + private fun NotificationCompat.Builder.setThumbnail() = apply { + playerState.value.thumbnail?.also { + setLargeIcon(it) + setColorized(true) + // scale thumbnail to a single color in two steps + val b1 = Bitmap.createScaledBitmap(it, 16, 16, true) + val b2 = Bitmap.createScaledBitmap(b1, 1, 1, true) + setColor(b2.getPixel(0, 0)) + b2.recycle() + b1.recycle() + } + } + + companion object { + const val CHANNEL_ID = "PlayerChannel" + const val NOTIFICATION_ID = 0x0d000721 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerNotificationReceiver.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerNotificationReceiver.kt new file mode 100644 index 00000000..dea2b9f3 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerNotificationReceiver.kt @@ -0,0 +1,39 @@ +package com.skyd.anivu.ui.mpv.service + +import android.app.Application +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import com.skyd.anivu.ui.mpv.MPVPlayer + +class PlayerNotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) return + + val application = context.applicationContext as Application + + when (intent.action) { + PLAY_ACTION -> MPVPlayer.getInstance(application).cyclePause() + CLOSE_ACTION -> { + MPVPlayer.getInstance(application).destroy() + context.stopService(Intent(context, PlayerService::class.java)) + context.sendBroadcast(Intent(FINISH_PLAY_ACTIVITY_ACTION)) + } + } + } + + companion object { + const val PLAY_ACTION = "play" + const val CLOSE_ACTION = "close" + const val FINISH_PLAY_ACTIVITY_ACTION = "finishPlayActivity" + + fun createIntent(context: Context, action: String): PendingIntent { + val intent = Intent(action).apply { + component = ComponentName(context, PlayerNotificationReceiver::class.java) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerService.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerService.kt new file mode 100644 index 00000000..f6d7dc02 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerService.kt @@ -0,0 +1,293 @@ +package com.skyd.anivu.ui.mpv.service + +import android.app.Application +import android.app.ForegroundServiceStartNotAllowedException +import android.app.Notification +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.app.ServiceCompat +import com.skyd.anivu.appContext +import com.skyd.anivu.model.bean.MediaPlayHistoryBean +import com.skyd.anivu.model.repository.PlayerRepository +import com.skyd.anivu.ui.mpv.MPVPlayer +import com.skyd.anivu.ui.mpv.PlayerCommand +import com.skyd.anivu.ui.mpv.PlayerEvent +import com.skyd.anivu.ui.mpv.resolveUri +import dagger.hilt.android.AndroidEntryPoint +import `is`.xyz.mpv.MPVLib +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.math.pow + +@AndroidEntryPoint +class PlayerService : Service() { + @Inject + lateinit var playerRepo: PlayerRepository + + private val lifecycleScope = CoroutineScope(Dispatchers.Main) + + private val binder = PlayerServiceBinder() + var uri: Uri = Uri.EMPTY + private set + private val sessionManager = MediaSessionManager(appContext, createMediaSessionCallback()) + private val notificationManager = PlayerNotificationManager(appContext, sessionManager) + val player = MPVPlayer.getInstance(appContext as Application) + val playerState get() = sessionManager.playerState + + private val observers = mutableSetOf() + + private val mpvObserver = object : MPVLib.EventObserver { + override fun eventProperty(property: String) { + when (property) { + "aid" -> sendEvent(PlayerEvent.AudioTrackChanged(player.aid)) + "sid" -> sendEvent(PlayerEvent.SubtitleTrackChanged(player.sid)) + "video-zoom" -> sendEvent(PlayerEvent.Zoom(2.0.pow(player.videoZoom).toFloat())) + "video-pan-x" -> sendEvent( + PlayerEvent.VideoOffsetX((player.videoPanX * (player.videoDW ?: 0)).toFloat()) + ) + + "video-pan-y" -> sendEvent( + PlayerEvent.VideoOffsetY((player.videoPanY * (player.videoDH ?: 0)).toFloat()) + ) + + "speed" -> sendEvent(PlayerEvent.Speed(player.playbackSpeed.toFloat())) + "track-list" -> { + player.loadTracks() + sendEvent(PlayerEvent.AllSubtitleTracks(player.subtitleTrack)) + sendEvent(PlayerEvent.AllAudioTracks(player.audioTrack)) + } + + "demuxer-cache-duration" -> sendEvent(PlayerEvent.Buffer(player.demuxerCacheDuration.toInt())) + "loop-file", "loop-playlist" -> Unit//sendEvent(PlayerEvent.Buffer(player.getRepeat())) + "metadata" -> { + sendEvent(PlayerEvent.Artist(player.artist)) + sendEvent(PlayerEvent.Album(player.album)) + } + } + } + + override fun eventProperty(property: String, value: Long) { + when (property) { + "aid" -> sendEvent(PlayerEvent.AudioTrackChanged(value.toInt())) + "sid" -> sendEvent(PlayerEvent.SubtitleTrackChanged(value.toInt())) + "time-pos" -> sendEvent(PlayerEvent.Position(value)) + "duration" -> sendEvent(PlayerEvent.Duration(value)) + "video-rotate" -> sendEvent(PlayerEvent.Rotate(value.toFloat())) + "playlist-pos" -> sendEvent(PlayerEvent.PlaylistPosition(value.toInt())) + "playlist-count" -> sendEvent(PlayerEvent.PlaylistCount(value.toInt())) + } + } + + override fun eventProperty(property: String, value: Boolean) { + when (property) { + "pause" -> sendEvent(PlayerEvent.Paused(value)) + "paused-for-cache" -> sendEvent(PlayerEvent.PausedForCache(value)) + "shuffle" -> sendEvent(PlayerEvent.Shuffle(value)) + "idle-active" -> sendEvent(PlayerEvent.Idling(value)) + } + } + + override fun eventProperty(property: String, value: String) { + when (property) { + "media-title" -> sendEvent(PlayerEvent.Title(value)) + } + } + + override fun event(eventId: Int) { + when (eventId) { + MPVLib.mpvEventId.MPV_EVENT_SEEK -> sendEvent(PlayerEvent.Seek) + MPVLib.mpvEventId.MPV_EVENT_END_FILE -> sendEvent(PlayerEvent.EndFile) + MPVLib.mpvEventId.MPV_EVENT_FILE_LOADED -> { + sendEvent(PlayerEvent.FileLoaded) + sendEvent(PlayerEvent.Paused(player.paused)) + loadLastPosition().invokeOnCompletion { + sendEvent(PlayerEvent.Thumbnail(player.thumbnail)) + } + } + + MPVLib.mpvEventId.MPV_EVENT_PLAYBACK_RESTART -> { + sendEvent(PlayerEvent.PlaybackRestart) + sendEvent(PlayerEvent.Paused(player.paused)) + } + + MPVLib.mpvEventId.MPV_EVENT_SHUTDOWN -> { + sendEvent(PlayerEvent.Shutdown) + stopSelf() + } + } + } + + override fun efEvent(err: String?) { + } + } + + override fun onCreate() { + super.onCreate() + addObserver(sessionManager) + MPVLib.addObserver(mpvObserver) + notificationManager.createNotificationChannel() + + lifecycleScope.launch { + playerState.collectLatest { + notificationManager.update() + } + } + } + + override fun onDestroy() { + savePosition() + + sendEvent(PlayerEvent.ServiceDestroy) + player.destroy() + MPVLib.removeObserver(mpvObserver) + sessionManager.mediaSession.isActive = false + sessionManager.mediaSession.release() + notificationManager.cancel() + removeAllObserver() + lifecycleScope.cancel() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + startForeground(notificationManager.notificationBuilder.build()) + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder = binder + + inner class PlayerServiceBinder : Binder() { + fun getService(): PlayerService = this@PlayerService + } + + fun interface Observer { + fun onCommand(command: PlayerEvent) + } + + fun addObserver(observer: Observer) = observers.add(observer) + fun removeObserver(observer: Observer) = observers.remove(observer) + fun removeAllObserver() = observers.clear() + private fun sendEvent(command: PlayerEvent) { + observers.forEach { it.onCommand(command) } + } + + fun onCommand(command: PlayerCommand) = player.apply { + when (command) { + is PlayerCommand.Attach -> command.surfaceHolder.addCallback(this) + is PlayerCommand.Detach -> command.surface.release() + is PlayerCommand.SetUri -> { + if (uri != command.uri) { + savePosition() // Save last media position + uri = command.uri + command.uri.resolveUri(this@PlayerService)?.let { loadFile(it) } + } + } + + PlayerCommand.Destroy -> stopSelf() + is PlayerCommand.Paused -> { + if (!command.paused) { + if (keepOpen && eofReached) { + seek(0) + } else if (isIdling) { + command.uri.resolveUri(this@PlayerService)?.let { loadFile(it) } + } + } + paused = command.paused + } + + PlayerCommand.PlayOrPause -> cyclePause() + is PlayerCommand.SeekTo -> seek(command.position.coerceIn(0..duration)) + is PlayerCommand.Rotate -> rotate(command.rotate) + is PlayerCommand.Zoom -> zoom(command.zoom) + is PlayerCommand.VideoOffset -> offset( + command.offset.x.toInt(), + command.offset.y.toInt() + ) + + is PlayerCommand.SetSpeed -> playbackSpeed = command.speed.toDouble() + is PlayerCommand.SetSubtitleTrack -> sid = command.trackId + is PlayerCommand.SetAudioTrack -> aid = command.trackId + is PlayerCommand.Screenshot -> screenshot(onSaveScreenshot = command.onSaveScreenshot) + is PlayerCommand.AddSubtitle -> addSubtitle(command.filePath) + is PlayerCommand.AddAudio -> addAudio(command.filePath) + } + } + + private fun startForeground(notification: Notification) { + try { + ServiceCompat.startForeground( + this, PlayerNotificationManager.NOTIFICATION_ID, notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } else { + 0 + }, + ) + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is ForegroundServiceStartNotAllowedException + ) { + // App not in a valid state to start foreground service (e.g. started from bg) + e.printStackTrace() + } + } + } + + private fun createMediaSessionCallback() = object : MediaSessionCompat.Callback() { + override fun onPause() { + player.paused = true + } + + override fun onPlay() { + player.paused = false + } + + override fun onSeekTo(pos: Long) { + player.timePos = (pos / 1000).toInt() + } + + override fun onSkipToNext() = Unit + override fun onSkipToPrevious() = Unit + override fun onSetRepeatMode(repeatMode: Int) = Unit + + override fun onSetShuffleMode(shuffleMode: Int) { + player.changeShuffle(false, shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL) + } + } + + private fun loadLastPosition() = uri.resolveUri(this@PlayerService)?.let { path -> + scope.launch { + val lastPos = playerRepo.requestLastPlayPosition(path).first() + if (lastPos > 0 && lastPos.toDouble() / (player.duration * 1000) < 0.9) { + player.seek((lastPos / 1000).toInt().coerceAtLeast(0)) + } + } + } ?: Job().apply { complete() } + + private fun savePosition() = uri.resolveUri(this@PlayerService)?.let { path -> + val position = sessionManager.playerState.value.position * 1000L + scope.launch { + playerRepo.updatePlayHistory( + MediaPlayHistoryBean(path = path, lastPlayPosition = position) + ).collect() + } + } + + companion object { + private val scope = CoroutineScope(Dispatchers.IO) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerState.kt new file mode 100644 index 00000000..8b256623 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/service/PlayerState.kt @@ -0,0 +1,31 @@ +package com.skyd.anivu.ui.mpv.service + +import coil3.Bitmap +import com.skyd.anivu.ui.mpv.MPVPlayer + + +data class PlayerState( + val mediaLoaded: Boolean = false, + val audioTrackId: Int = 0, + val subtitleTrackId: Int = 0, + val zoom: Float = 1f, + val offsetX: Float = 0f, + val offsetY: Float = 0f, + val speed: Float = 1f, + val audioTracks: List = listOf(), + val subtitleTracks: List = listOf(), + val buffer: Int = 0, + val artist: String? = null, + val album: String? = null, + val position: Long = 0L, + val duration: Long = 0L, + val rotate: Float = 0f, + val playlistPosition: Int = -1, + val playlistCount: Int = 0, + val paused: Boolean = true, + val pausedForCache: Boolean = false, + val shuffle: Boolean = false, + val idling: Boolean = true, + val title: String? = null, + val thumbnail: Bitmap? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt b/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt index fff6ef8d..21b1e0aa 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/article/enclosure/EnclosureBottomSheet.kt @@ -37,12 +37,11 @@ import com.skyd.anivu.ext.fileSize import com.skyd.anivu.ext.getOrDefault import com.skyd.anivu.model.bean.LinkEnclosureBean import com.skyd.anivu.model.bean.article.ArticleWithEnclosureBean -import com.skyd.anivu.model.bean.article.ArticleWithFeed import com.skyd.anivu.model.bean.article.EnclosureBean import com.skyd.anivu.model.preference.rss.ParseLinkTagAsEnclosurePreference import com.skyd.anivu.model.repository.download.DownloadStarter import com.skyd.anivu.model.worker.download.doIfMagnetOrTorrentLink -import com.skyd.anivu.ui.activity.PlayActivity +import com.skyd.anivu.ui.activity.player.PlayActivity import com.skyd.anivu.ui.component.AniVuIconButton fun getEnclosuresList( diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/MediaScreen.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/MediaScreen.kt index 8fd7008f..02bc74e5 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/MediaScreen.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/MediaScreen.kt @@ -56,7 +56,7 @@ import com.skyd.anivu.ext.activity import com.skyd.anivu.ext.isCompact import com.skyd.anivu.model.bean.MediaGroupBean import com.skyd.anivu.model.preference.data.medialib.MediaLibLocationPreference -import com.skyd.anivu.ui.activity.PlayActivity +import com.skyd.anivu.ui.activity.player.PlayActivity import com.skyd.anivu.ui.component.AniVuFloatingActionButton import com.skyd.anivu.ui.component.AniVuIconButton import com.skyd.anivu.ui.component.AniVuTopBar diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt index 8f7f5d90..5a0fc027 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/media/list/MediaList.kt @@ -37,7 +37,7 @@ import com.skyd.anivu.ext.plus import com.skyd.anivu.ext.toUri import com.skyd.anivu.model.bean.MediaBean import com.skyd.anivu.model.bean.MediaGroupBean -import com.skyd.anivu.ui.activity.PlayActivity +import com.skyd.anivu.ui.activity.player.PlayActivity import com.skyd.anivu.ui.component.CircularProgressPlaceholder import com.skyd.anivu.ui.component.EmptyPlaceholder import com.skyd.anivu.ui.local.LocalNavController diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt b/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt index 89606e2c..8f7e9965 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt @@ -96,7 +96,7 @@ import com.skyd.anivu.model.bean.article.ArticleWithFeed import com.skyd.anivu.model.bean.article.EnclosureBean import com.skyd.anivu.model.bean.article.RssMediaBean import com.skyd.anivu.model.preference.appearance.read.ReadTextSizePreference -import com.skyd.anivu.ui.activity.PlayActivity +import com.skyd.anivu.ui.activity.player.PlayActivity import com.skyd.anivu.ui.component.AniVuFloatingActionButton import com.skyd.anivu.ui.component.AniVuIconButton import com.skyd.anivu.ui.component.AniVuImage diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/settings/playerconfig/PlayerConfigScreen.kt b/app/src/main/java/com/skyd/anivu/ui/screen/settings/playerconfig/PlayerConfigScreen.kt index e6afabe8..4a5c8fbb 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/settings/playerconfig/PlayerConfigScreen.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/settings/playerconfig/PlayerConfigScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.outlined.PhotoCamera import androidx.compose.material.icons.outlined.PictureInPictureAlt import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.Speaker import androidx.compose.material.icons.outlined.Timelapse import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material3.Icon @@ -39,6 +40,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.skyd.anivu.R import com.skyd.anivu.ext.fileSize +import com.skyd.anivu.model.preference.player.BackgroundPlayPreference import com.skyd.anivu.model.preference.player.PlayerAutoPipPreference import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference import com.skyd.anivu.model.preference.player.PlayerMaxBackCacheSizePreference @@ -55,6 +57,7 @@ import com.skyd.anivu.ui.component.CategorySettingsItem import com.skyd.anivu.ui.component.CheckableListMenu import com.skyd.anivu.ui.component.SwitchSettingsItem import com.skyd.anivu.ui.component.dialog.SliderDialog +import com.skyd.anivu.ui.local.LocalBackgroundPlay import com.skyd.anivu.ui.local.LocalNavController import com.skyd.anivu.ui.local.LocalPlayerAutoPip import com.skyd.anivu.ui.local.LocalPlayerDoubleTap @@ -98,6 +101,20 @@ fun PlayerConfigScreen() { item { CategorySettingsItem(text = stringResource(id = R.string.player_config_screen_behavior_category)) } + item { + SwitchSettingsItem( + imageVector = Icons.Outlined.Speaker, + text = stringResource(id = R.string.player_config_screen_background_play), + checked = LocalBackgroundPlay.current, + onCheckedChange = { + BackgroundPlayPreference.put( + context = context, + scope = scope, + value = it, + ) + } + ) + } item { BaseSettingsItem( icon = rememberVectorPainter(image = Icons.Outlined.TouchApp), diff --git a/app/src/main/res/drawable/ic_close_24.xml b/app/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 00000000..2d675706 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9bf2fdcb..1af01572 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -358,6 +358,9 @@ BT 任务 继续下载 取消下载 + 播放器 + 播放器通知栏控制 + 后台播放 已读 %d 项 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce44f4c8..23a394ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,6 +365,9 @@ BT Tasks Resume Cancel + Player + Player controller + Background playback Read %d item Read %d items diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17e78303..de0bc4ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] adaptive = "1.0.0" coil = "3.0.4" -hilt = "2.52" +hilt = "2.53" libtorrent4j = "2.1.0-35" composeMaterial = "1.7.5" composeMaterial3 = "1.4.0-alpha04" @@ -17,6 +17,7 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.15.0" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.0" } androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version = "1.9.3" } androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version = "1.1.0" } +androidx-media = { module = "androidx.media:media", version = "1.7.0" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version = "2.8.4" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.7" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "composeMaterial" } @@ -62,7 +63,7 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil" } -lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.6.0" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.6.1" } rome = { module = "com.rometools:rome", version.ref = "rome" } rome-modules = { module = "com.rometools:rome-modules", version.ref = "rome" }