diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbc73e0d..e6dae690 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,7 +21,7 @@ android { minSdk = 24 targetSdk = 34 versionCode = 16 - versionName = "1.1-beta27" + versionName = "1.1-beta28" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c4f570d6..58c13d38 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,21 @@ + xmlns:tools="http://schemas.android.com/tools" + tools:node="remove"> + + Boolean = { true }): Boolean = walkBottomUp().fold(true) { res, it -> (it != this && hook(it) && (it.delete() || !it.exists())) && res } + +fun File.savePictureToMediaStore(context: Context, autoDelete: Boolean = true) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues() + contentValues.put( + MediaStore.Images.Media.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/AniVu", + ) + contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, name) + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + )!! + context.contentResolver.openOutputStream(uri)?.use { output -> + inputStream().use { input -> + input.copyTo(output) + } + } + } else { + val dir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "AniVu" + ) + if (!dir.exists()) dir.mkdirs() + this.copyTo(File(dir, name)) + } + if (autoDelete) delete() +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/PreferenceExt.kt b/app/src/main/java/com/skyd/anivu/ext/PreferenceExt.kt index eda756ad..cb273c77 100644 --- a/app/src/main/java/com/skyd/anivu/ext/PreferenceExt.kt +++ b/app/src/main/java/com/skyd/anivu/ext/PreferenceExt.kt @@ -15,6 +15,7 @@ import com.skyd.anivu.model.preference.behavior.article.DeduplicateTitleInDescPr import com.skyd.anivu.model.preference.behavior.feed.HideEmptyDefaultPreference import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference import com.skyd.anivu.model.preference.player.PlayerShow85sButtonPreference +import com.skyd.anivu.model.preference.player.PlayerShowScreenshotButtonPreference fun Preferences.toSettings(): Settings { return Settings( @@ -38,5 +39,6 @@ fun Preferences.toSettings(): Settings { // Player playerDoubleTap = PlayerDoubleTapPreference.fromPreferences(this), playerShow85sButton = PlayerShow85sButtonPreference.fromPreferences(this), + playerShowScreenshotButton = PlayerShowScreenshotButtonPreference.fromPreferences(this), ) } diff --git a/app/src/main/java/com/skyd/anivu/model/preference/Settings.kt b/app/src/main/java/com/skyd/anivu/model/preference/Settings.kt index ccb48b5b..47af13e5 100644 --- a/app/src/main/java/com/skyd/anivu/model/preference/Settings.kt +++ b/app/src/main/java/com/skyd/anivu/model/preference/Settings.kt @@ -20,6 +20,7 @@ import com.skyd.anivu.model.preference.behavior.article.DeduplicateTitleInDescPr import com.skyd.anivu.model.preference.behavior.feed.HideEmptyDefaultPreference import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference import com.skyd.anivu.model.preference.player.PlayerShow85sButtonPreference +import com.skyd.anivu.model.preference.player.PlayerShowScreenshotButtonPreference import com.skyd.anivu.ui.local.LocalArticleSwipeLeftAction import com.skyd.anivu.ui.local.LocalArticleTapAction import com.skyd.anivu.ui.local.LocalDarkMode @@ -31,6 +32,7 @@ import com.skyd.anivu.ui.local.LocalIgnoreUpdateVersion import com.skyd.anivu.ui.local.LocalNavigationBarLabel import com.skyd.anivu.ui.local.LocalPlayerDoubleTap import com.skyd.anivu.ui.local.LocalPlayerShow85sButton +import com.skyd.anivu.ui.local.LocalPlayerShowScreenshotButton import com.skyd.anivu.ui.local.LocalTextFieldStyle import com.skyd.anivu.ui.local.LocalTheme import kotlinx.coroutines.Dispatchers @@ -54,6 +56,7 @@ data class Settings( // Player val playerDoubleTap: String = PlayerDoubleTapPreference.default, val playerShow85sButton: Boolean = PlayerShow85sButtonPreference.default, + val playerShowScreenshotButton: Boolean = PlayerShowScreenshotButtonPreference.default, ) @Composable @@ -82,6 +85,7 @@ fun SettingsProvider( // Player LocalPlayerDoubleTap provides settings.playerDoubleTap, LocalPlayerShow85sButton provides settings.playerShow85sButton, + LocalPlayerShowScreenshotButton provides settings.playerShowScreenshotButton, ) { content() } diff --git a/app/src/main/java/com/skyd/anivu/model/preference/player/PlayerShowScreenshotButtonPreference.kt b/app/src/main/java/com/skyd/anivu/model/preference/player/PlayerShowScreenshotButtonPreference.kt new file mode 100644 index 00000000..a8a844a2 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/preference/player/PlayerShowScreenshotButtonPreference.kt @@ -0,0 +1,26 @@ +package com.skyd.anivu.model.preference.player + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import com.skyd.anivu.base.BasePreference +import com.skyd.anivu.ext.dataStore +import com.skyd.anivu.ext.put +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object PlayerShowScreenshotButtonPreference : BasePreference { + private const val PLAYER_SHOW_SCREENSHOT_BUTTON = "playerShowScreenshotButton" + override val default = true + + val key = booleanPreferencesKey(PLAYER_SHOW_SCREENSHOT_BUTTON) + + 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/worker/download/DownloadTorrentWorker.kt b/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt index 94fb8b66..2ae1c2ef 100644 --- a/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt +++ b/app/src/main/java/com/skyd/anivu/model/worker/download/DownloadTorrentWorker.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.navigation.NavDeepLinkBuilder @@ -340,7 +341,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } is TorrentErrorAlert -> { - // 下载错误 + // Download error pauseWorker( handle = alert.handle(), state = DownloadInfoBean.DownloadState.ErrorPaused, @@ -360,7 +361,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : is StorageMovedAlert -> { alert.handle().saveResumeData() - updateNotificationAsync() // 更新Notification + updateNotificationAsync() // update Notification updateDownloadStateAndSessionParams( link = torrentLink, sessionStateData = sessionManager.saveState() ?: byteArrayOf(), @@ -369,6 +370,13 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } is StorageMovedFailedAlert -> { + Log.e( + TAG, + "StorageMovedFailedAlert: " + + "Message: ${alert.message()}\n" + + "${alert.error()}\n" + + "Path: ${alert.filePath()}" + ) // 文件移动,例如存储空间已满 alert.handle().saveResumeData() pauseWorker( @@ -378,12 +386,12 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } is TorrentFinishedAlert -> { - // 下载完成更新 + // Torrent download finished val handle = alert.handle() progress = 1f name = handle.name handle.saveResumeData() - updateNotificationAsync() // 更新Notification + updateNotificationAsync() // update Notification moveFromDownloadingDirToVideoDir(handle = handle) updateDownloadStateAndSessionParams( link = torrentLink, @@ -409,7 +417,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : val handle = alert.handle() updateTorrentFilesToDb(link = torrentLink, files = handle.torrentFile().files()) name = handle.name - updateNotificationAsync() // 更新Notification + updateNotificationAsync() // update Notification updateNameInfoToDb(link = torrentLink, name = name) } @@ -420,7 +428,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } is StateChangedAlert -> { - // 下载状态更新 + // Download state update if (alert.state == TorrentStatus.State.SEEDING) { updateDownloadStateAndSessionParams( link = torrentLink, @@ -433,7 +441,7 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : val handle = alert.handle() if (handle.isValid) { progress = handle.status().progress() - updateNotificationAsync() // 更新Notification + updateNotificationAsync() // update Notification updateProgressInfoToDb(link = torrentLink, progress = progress) } } @@ -451,13 +459,13 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : is TorrentAlert<*> -> { // Log.e("TAG", "onAlert: ${alert}") - // 下载进度更新 + // Download progress update val handle = alert.handle() if (handle.isValid) { updateTorrentStatusMapFlow(id.toString(), handle.status()) if (progress != handle.status().progress()) { progress = handle.status().progress() - updateNotificationAsync() // 更新Notification + updateNotificationAsync() // update Notification updateProgressInfoToDb(link = torrentLink, progress = progress) updateSizeInfoToDb( link = torrentLink, @@ -497,8 +505,10 @@ class DownloadTorrentWorker(context: Context, parameters: WorkerParameters) : } private fun moveFromDownloadingDirToVideoDir(handle: TorrentHandle) { - if (handle.savePath() != Const.VIDEO_DIR.path && File(handle.savePath()).exists()) { + if (handle.savePath() != Const.VIDEO_DIR.path) { handle.moveStorage(Const.VIDEO_DIR.path, MoveFlags.ALWAYS_REPLACE_FILES) + } else { + Log.w(TAG, "handle.savePath() != Const.VIDEO_DIR.path: ${handle.savePath()}") } } 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 index 76b546e6..d8312c5e 100644 --- a/app/src/main/java/com/skyd/anivu/ui/activity/PlayActivity.kt +++ b/app/src/main/java/com/skyd/anivu/ui/activity/PlayActivity.kt @@ -2,8 +2,10 @@ package com.skyd.anivu.ui.activity import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle 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 @@ -15,8 +17,11 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat 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.PlayerView import com.skyd.anivu.ui.mpv.copyAssetsForMpv +import java.io.File class PlayActivity : BaseComposeActivity() { @@ -24,6 +29,18 @@ class PlayActivity : BaseComposeActivity() { const val VIDEO_URI_KEY = "videoUri" } + + 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() + } + } + override fun onCreate(savedInstanceState: Bundle?) { copyAssetsForMpv(this) @@ -56,6 +73,10 @@ class PlayActivity : BaseComposeActivity() { PlayerView( uri = uri, onBack = { finish() }, + onSaveScreenshot = { + picture = it + saveScreenshot() + } ) } } @@ -66,4 +87,12 @@ class PlayActivity : BaseComposeActivity() { return IntentCompat.getParcelableExtra(intent, VIDEO_URI_KEY, Uri::class.java) ?: intent.data } + + private fun saveScreenshot() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + picture.savePictureToMediaStore(this) + } else { + requestPermissionLauncher.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/settings/playerconfig/PlayerConfigFragment.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/settings/playerconfig/PlayerConfigFragment.kt index feef7c51..4063da39 100644 --- a/app/src/main/java/com/skyd/anivu/ui/fragment/settings/playerconfig/PlayerConfigFragment.kt +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/settings/playerconfig/PlayerConfigFragment.kt @@ -16,6 +16,7 @@ import com.skyd.anivu.ext.findMainNavController import com.skyd.anivu.ext.getOrDefault import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference import com.skyd.anivu.model.preference.player.PlayerShow85sButtonPreference +import com.skyd.anivu.model.preference.player.PlayerShowScreenshotButtonPreference import dagger.hilt.android.AndroidEntryPoint @@ -75,6 +76,23 @@ class PlayerConfigFragment : BasePreferenceFragmentCompat() { playerAppearanceCategory.addPreference(this) } + SwitchPreferenceCompat(this).apply { + key = "playerShowScreenshotButton" + title = getString(R.string.player_config_fragment_show_screenshot_button) + setIcon(R.drawable.ic_photo_camera_24) + isChecked = + requireContext().dataStore.getOrDefault(PlayerShowScreenshotButtonPreference) + setOnPreferenceChangeListener { _, newValue -> + PlayerShowScreenshotButtonPreference.put( + context = requireContext(), + scope = lifecycleScope, + value = newValue as Boolean, + ) + true + } + playerAppearanceCategory.addPreference(this) + } + val playerAdvancedCategory = PreferenceCategory(this).apply { key = "playerAdvancedCategory" title = getString(R.string.player_config_fragment_advanced_category) 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 104afb12..1a9c2559 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 @@ -16,6 +16,7 @@ import com.skyd.anivu.model.preference.behavior.article.DeduplicateTitleInDescPr import com.skyd.anivu.model.preference.behavior.feed.HideEmptyDefaultPreference import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference import com.skyd.anivu.model.preference.player.PlayerShow85sButtonPreference +import com.skyd.anivu.model.preference.player.PlayerShowScreenshotButtonPreference val LocalNavController = compositionLocalOf { error("LocalNavController not initialized!") @@ -44,4 +45,5 @@ val LocalHideEmptyDefault = compositionLocalOf { HideEmptyDefaultPreference.defa // Player val LocalPlayerDoubleTap = compositionLocalOf { PlayerDoubleTapPreference.default } -val LocalPlayerShow85sButton = compositionLocalOf { PlayerShow85sButtonPreference.default } \ No newline at end of file +val LocalPlayerShow85sButton = compositionLocalOf { PlayerShow85sButtonPreference.default } +val LocalPlayerShowScreenshotButton = compositionLocalOf { PlayerShowScreenshotButtonPreference.default } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt index 00e8786b..508c2028 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/MPVView.kt @@ -2,12 +2,12 @@ package com.skyd.anivu.ui.mpv import android.content.Context import android.os.Build -import android.os.Environment import android.util.AttributeSet import android.util.Log import android.view.SurfaceHolder import android.view.SurfaceView import android.view.WindowManager +import com.skyd.anivu.config.Const import `is`.xyz.mpv.MPVLib import `is`.xyz.mpv.MPVLib.mpvFormat.MPV_FORMAT_DOUBLE import `is`.xyz.mpv.MPVLib.mpvFormat.MPV_FORMAT_FLAG @@ -15,13 +15,23 @@ import `is`.xyz.mpv.MPVLib.mpvFormat.MPV_FORMAT_INT64 import `is`.xyz.mpv.MPVLib.mpvFormat.MPV_FORMAT_NONE import `is`.xyz.mpv.MPVLib.mpvFormat.MPV_FORMAT_STRING import `is`.xyz.mpv.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.io.File 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) + fun initialize( configDir: String, cacheDir: String, @@ -82,11 +92,8 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32 MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - // - val screenshotDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) - screenshotDir.mkdirs() - MPVLib.setOptionString("screenshot-directory", screenshotDir.path) + + MPVLib.setOptionString("screenshot-directory", Const.PICTURES_DIR.path) } private var filePath: String? = null @@ -224,6 +231,8 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att } // Property getters/setters + val filename: String + get() = MPVLib.getPropertyString("filename") var paused: Boolean get() = MPVLib.getPropertyBoolean("pause") set(paused) = MPVLib.setPropertyBoolean("pause", paused) @@ -420,6 +429,27 @@ class MPVView(context: Context, attrs: AttributeSet?) : SurfaceView(context, att } } + fun screenshot(onSaveScreenshot: (File) -> Unit) { + val format = "jpg" + val filename = "$filename-(${timePos.toDurationString(splitter = "-")})-${Random.nextInt()}" + MPVLib.setOptionString("screenshot-format", format) + MPVLib.setOptionString("screenshot-template", filename) + MPVLib.command(arrayOf("screenshot")) + + scope.launch { + val picture = File(Const.PICTURES_DIR, "$filename.$format") + try { + withTimeout(10000) { + while (!picture.exists()) delay(100) + } + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "Failed to save screenshot") + return@launch + } + onSaveScreenshot(picture) + } + } + companion object { private const val TAG = "mpv" } 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 e679c941..bc6f2aac 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 @@ -38,6 +38,7 @@ import androidx.compose.material.icons.outlined.ArrowBackIosNew import androidx.compose.material.icons.rounded.BrightnessHigh import androidx.compose.material.icons.rounded.BrightnessLow import androidx.compose.material.icons.rounded.BrightnessMedium +import androidx.compose.material.icons.rounded.CameraAlt import androidx.compose.material.icons.rounded.ClosedCaption import androidx.compose.material.icons.rounded.FastForward import androidx.compose.material.icons.rounded.FastRewind @@ -89,9 +90,12 @@ import com.skyd.anivu.ext.alwaysLight import com.skyd.anivu.ext.startWith import com.skyd.anivu.ext.toPercentage import com.skyd.anivu.ui.local.LocalPlayerShow85sButton +import com.skyd.anivu.ui.local.LocalPlayerShowScreenshotButton import com.skyd.anivu.ui.mpv.state.PlayState +import com.skyd.anivu.ui.mpv.state.PlayStateCallback import com.skyd.anivu.ui.mpv.state.SubtitleTrackDialogState import com.skyd.anivu.ui.mpv.state.TransformState +import com.skyd.anivu.ui.mpv.state.TransformStateCallback import `is`.xyz.mpv.MPVLib import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -100,6 +104,7 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.io.File import kotlin.math.abs import kotlin.math.pow @@ -121,6 +126,7 @@ sealed interface PlayerCommand { data object GetSpeed : PlayerCommand data object LoadAllTracks : PlayerCommand data object GetSubtitleTrack : PlayerCommand + data object Screenshot : PlayerCommand } private fun MPVView.solveCommand( @@ -133,6 +139,7 @@ private fun MPVView.solveCommand( onVideoZoom: (Float) -> Unit, onVideoOffset: (Offset) -> Unit, onSpeedChanged: (Float) -> Unit, + onSaveScreenshot: (File) -> Unit, ) { when (command) { is PlayerCommand.SetUri -> uri().resolveUri(context)?.let { loadFile(it) } @@ -171,6 +178,8 @@ private fun MPVView.solveCommand( sid = command.trackId onSubtitleTrackChanged(command.trackId) } + + PlayerCommand.Screenshot -> screenshot(onSaveScreenshot = onSaveScreenshot) } } @@ -178,6 +187,7 @@ private fun MPVView.solveCommand( fun PlayerView( uri: Uri, 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, @@ -190,6 +200,25 @@ fun PlayerView( var playState by remember { mutableStateOf(PlayState.initial) } var transformState by remember { mutableStateOf(TransformState.initial) } + val playStateCallback = remember { + PlayStateCallback( + onPlayStateChanged = { commandQueue.trySend(PlayerCommand.Paused(playState.isPlaying)) }, + onPlayOrPause = { commandQueue.trySend(PlayerCommand.PlayOrPause) }, + onSeekTo = { + playState = playState.copy(isSeeking = true) + commandQueue.trySend(PlayerCommand.SeekTo(it)) + }, + onSpeedChanged = { commandQueue.trySend(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)) }, + ) + } + val mpvObserver = remember { object : MPVLib.EventObserver { override fun eventProperty(property: String) { @@ -287,6 +316,7 @@ fun PlayerView( transformState = transformState.copy(videoOffset = it) }, onSpeedChanged = { playState = playState.copy(speed = it) }, + onSaveScreenshot = onSaveScreenshot, ) } .collect() @@ -299,14 +329,8 @@ fun PlayerView( PlayerController( enabled = { mediaLoaded }, onBack = onBack, - onPlayStateChanged = { commandQueue.trySend(PlayerCommand.Paused(playState.isPlaying)) }, playState = { playState }, - onSeekTo = { - playState = playState.copy(isSeeking = true) - commandQueue.trySend(PlayerCommand.SeekTo(it)) - }, - onPlayOrPause = { commandQueue.trySend(PlayerCommand.PlayOrPause) }, - onSpeedChanged = { commandQueue.trySend(PlayerCommand.SetSpeed(it)) }, + playStateCallback = playStateCallback, subtitleTrackDialogState = { subtitleTrackDialogState }, onDismissSubtitleTrackDialog = { subtitleTrackDialogState = subtitleTrackDialogState.copy(show = false) @@ -314,9 +338,8 @@ fun PlayerView( onRequestSubtitleTrack = { commandQueue.trySend(PlayerCommand.GetSubtitleTrack) }, onSubtitleTrackChanged = { commandQueue.trySend(PlayerCommand.SetSubtitleTrack(it.trackId)) }, transformState = { transformState }, - onVideoRotate = { commandQueue.trySend(PlayerCommand.Rotate(it.toInt())) }, - onVideoZoom = { commandQueue.trySend(PlayerCommand.Zoom(it)) }, - onVideoOffset = { commandQueue.trySend(PlayerCommand.VideoOffset(it)) } + transformStateCallback = transformStateCallback, + onScreenshot = { commandQueue.trySend(PlayerCommand.Screenshot) }, ) var needPlayWhenResume by rememberSaveable { mutableStateOf(false) } @@ -357,19 +380,15 @@ internal val ControllerLabelGray = Color(0x70000000) private fun PlayerController( enabled: () -> Boolean, onBack: () -> Unit, - onPlayStateChanged: () -> Unit, playState: () -> PlayState, - onSeekTo: (position: Int) -> Unit, - onPlayOrPause: () -> Unit, - onSpeedChanged: (Float) -> Unit, + playStateCallback: PlayStateCallback, subtitleTrackDialogState: () -> SubtitleTrackDialogState, onDismissSubtitleTrackDialog: () -> Unit, onRequestSubtitleTrack: () -> Unit, onSubtitleTrackChanged: (MPVView.Track) -> Unit, transformState: () -> TransformState, - onVideoRotate: (Float) -> Unit, - onVideoZoom: (Float) -> Unit, - onVideoOffset: (Offset) -> Unit, + transformStateCallback: TransformStateCallback, + onScreenshot: () -> Unit, ) { var showController by rememberSaveable { mutableStateOf(true) } var controllerWidth by remember { mutableIntStateOf(0) } @@ -422,11 +441,9 @@ private fun PlayerController( .detectPressGestures( controllerWidth = { controllerWidth }, playState = playState, - onSeekTo = onSeekTo, - onPlayOrPause = onPlayOrPause, + playStateCallback = playStateCallback, showController = { showController }, onShowControllerChanged = { showController = it }, - onSpeedChanged = onSpeedChanged, isLongPressing = { isLongPressing }, isLongPressingChanged = { isLongPressing = it }, onShowForwardRipple = { @@ -451,13 +468,11 @@ private fun PlayerController( onVolumeRangeChanged = { volumeRange = it }, onVolumeChanged = { volumeValue = it }, playState = playState, - onSeekTo = onSeekTo, + playStateCallback = playStateCallback, onShowSeekTimePreview = { showSeekTimePreview = it }, onTimePreviewChanged = { seekTimePreview = it }, transformState = transformState, - onVideoRotate = onVideoRotate, - onVideoZoom = onVideoZoom, - onVideoOffset = onVideoOffset, + transformStateCallback = transformStateCallback, cancelAutoHideControllerRunnable = cancelAutoHideControllerRunnable, restartAutoHideControllerRunnable = restartAutoHideControllerRunnable, ) @@ -501,15 +516,13 @@ private fun PlayerController( enabled = enabled, show = { showController }, onBack = onBack, - onPlayStateChanged = onPlayStateChanged, playState = playState, - onSeekTo = onSeekTo, + playStateCallback = playStateCallback, onSubtitleTrackClick = onRequestSubtitleTrack, - onRestartAutoHideControllerRunnable = restartAutoHideControllerRunnable, transformState = transformState, - onVideoRotate = onVideoRotate, - onVideoZoom = onVideoZoom, - onVideoOffset = onVideoOffset, + transformStateCallback = transformStateCallback, + onScreenshot = onScreenshot, + onRestartAutoHideControllerRunnable = restartAutoHideControllerRunnable, ) // Seek time preview @@ -546,15 +559,13 @@ private fun AutoHiddenBox( enabled: () -> Boolean, show: () -> Boolean, onBack: () -> Unit, - onPlayStateChanged: () -> Unit, playState: () -> PlayState, - onSeekTo: (position: Int) -> Unit, + playStateCallback: PlayStateCallback, onSubtitleTrackClick: () -> Unit, - onRestartAutoHideControllerRunnable: () -> Unit, transformState: () -> TransformState, - onVideoRotate: (Float) -> Unit, - onVideoZoom: (Float) -> Unit, - onVideoOffset: (Offset) -> Unit, + transformStateCallback: TransformStateCallback, + onScreenshot: () -> Unit, + onRestartAutoHideControllerRunnable: () -> Unit, ) { Box { AnimatedVisibility( @@ -563,7 +574,7 @@ private fun AutoHiddenBox( exit = fadeOut(), ) { ConstraintLayout(modifier = Modifier.fillMaxSize()) { - val (topBar, bottomBar, forward85s, resetTransform) = createRefs() + val (topBar, bottomBar, screenshot, forward85s, resetTransform) = createRefs() TopBar( modifier = Modifier.constrainAs(topBar) { top.linkTo(parent.top) }, @@ -572,13 +583,25 @@ private fun AutoHiddenBox( ) BottomBar( modifier = Modifier.constrainAs(bottomBar) { bottom.linkTo(parent.bottom) }, - onPlayStateChanged = onPlayStateChanged, + playStateCallback = playStateCallback, playState = playState, - onSeekTo = onSeekTo, onSubtitleTrackClick = onSubtitleTrackClick, onRestartAutoHideControllerRunnable = onRestartAutoHideControllerRunnable, ) + if (LocalPlayerShowScreenshotButton.current) { + Screenshot( + modifier = Modifier + .constrainAs(screenshot) { + bottom.linkTo(parent.bottom) + top.linkTo(parent.top) + end.linkTo(parent.end) + } + .padding(end = 20.dp), + onClick = onScreenshot, + ) + } + // +85s button if (LocalPlayerShow85sButton.current) { Forward85s( @@ -587,9 +610,9 @@ private fun AutoHiddenBox( bottom.linkTo(bottomBar.top) end.linkTo(parent.end) } - .padding(end = 50.dp), + .padding(end = 20.dp), onClick = { - with(playState()) { onSeekTo(currentPosition + 85) } + with(playState()) { playStateCallback.onSeekTo(currentPosition + 85) } }, ) } @@ -609,9 +632,11 @@ private fun AutoHiddenBox( }, enabled = enabled, onClick = { - onVideoOffset(Offset.Zero) - onVideoZoom(1f) - onVideoRotate(0f) + with(transformStateCallback) { + onVideoOffset(Offset.Zero) + onVideoZoom(1f) + onVideoRotate(0f) + } } ) } @@ -781,6 +806,23 @@ private fun ResetTransform( ) } +@Composable +private fun Screenshot( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Icon( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(color = ControllerLabelGray) + .clickable(onClick = onClick) + .padding(10.dp), + imageVector = Icons.Rounded.CameraAlt, + contentDescription = stringResource(id = R.string.player_screenshot), + tint = Color.White, + ) +} + @Composable private fun Forward85s( modifier: Modifier = Modifier, @@ -848,9 +890,8 @@ private fun TopBar( @Composable private fun BottomBar( modifier: Modifier = Modifier, - onPlayStateChanged: () -> Unit, playState: () -> PlayState, - onSeekTo: (position: Int) -> Unit, + playStateCallback: PlayStateCallback, onSubtitleTrackClick: () -> Unit, onRestartAutoHideControllerRunnable: () -> Unit, ) { @@ -903,7 +944,7 @@ private fun BottomBar( sliderValue = it }, onValueChangeFinished = { - onSeekTo(sliderValue.toInt()) + playStateCallback.onSeekTo(sliderValue.toInt()) valueIsChanging = false }, valueRange = 0f..playPositionStateValue.duration.toFloat(), @@ -953,9 +994,9 @@ private fun BottomBar( Icon( modifier = Modifier .clip(CircleShape) - .size(52.dp) - .clickable(onClick = onPlayStateChanged) - .padding(9.dp), + .size(50.dp) + .clickable(onClick = playStateCallback.onPlayStateChanged) + .padding(7.dp), imageVector = if (playPositionStateValue.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, contentDescription = stringResource(if (playPositionStateValue.isPlaying) R.string.pause else R.string.play), ) @@ -975,12 +1016,12 @@ private fun BottomBar( } } -fun Int.toDurationString(sign: Boolean = false): String { +fun Int.toDurationString(sign: Boolean = false, splitter: String = ":"): String { if (sign) return (if (this >= 0) "+" else "-") + abs(this).toDurationString() val hours = this / 3600 val minutes = this % 3600 / 60 val seconds = this % 60 - return if (hours == 0) "%02d:%02d".format(minutes, seconds) - else "%d:%02d:%02d".format(hours, minutes, seconds) + return if (hours == 0) "%02d$splitter%02d".format(minutes, seconds) + else "%d$splitter%02d$splitter%02d".format(hours, minutes, seconds) } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/PointerInputDetector.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/PointerInputDetector.kt index 7ed83720..a4cc4648 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/PointerInputDetector.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/PointerInputDetector.kt @@ -22,7 +22,9 @@ import com.skyd.anivu.ext.getScreenBrightness import com.skyd.anivu.model.preference.player.PlayerDoubleTapPreference import com.skyd.anivu.ui.local.LocalPlayerDoubleTap import com.skyd.anivu.ui.mpv.state.PlayState +import com.skyd.anivu.ui.mpv.state.PlayStateCallback import com.skyd.anivu.ui.mpv.state.TransformState +import com.skyd.anivu.ui.mpv.state.TransformStateCallback import kotlin.math.abs private val inStatusBarArea: PointerInputScope.(y: Float) -> Boolean = { y -> @@ -33,9 +35,7 @@ private val inStatusBarArea: PointerInputScope.(y: Float) -> Boolean = { y -> internal fun Modifier.detectPressGestures( controllerWidth: () -> Int, playState: () -> PlayState, - onSeekTo: (position: Int) -> Unit, - onPlayOrPause: () -> Unit, - onSpeedChanged: (Float) -> Unit, + playStateCallback: PlayStateCallback, showController: () -> Boolean, onShowControllerChanged: (Boolean) -> Unit, isLongPressing: () -> Boolean, @@ -48,23 +48,23 @@ internal fun Modifier.detectPressGestures( var beforeLongPressingSpeed by remember { mutableFloatStateOf(playState().speed) } val playerDoubleTap = LocalPlayerDoubleTap.current - val onDoubleTapPausePlay: () -> Unit = remember { { onPlayOrPause() } } + val onDoubleTapPausePlay: () -> Unit = remember { { playStateCallback.onPlayOrPause() } } val onDoubleTapBackwardForward: PlayState.(Offset) -> Unit = { offset -> if (offset.x < controllerWidth() / 2f) { - onSeekTo(currentPosition - 10) // -10s. + playStateCallback.onSeekTo(currentPosition - 10) // -10s. onShowBackwardRipple(offset) } else { - onSeekTo(currentPosition + 10) // +10s. + playStateCallback.onSeekTo(currentPosition + 10) // +10s. onShowForwardRipple(offset) } } val onDoubleTapBackwardPausePlayForward: PlayState.(Offset) -> Unit = { offset -> if (offset.x <= controllerWidth() * 0.25f) { - onSeekTo(currentPosition - 10) // -10s. + playStateCallback.onSeekTo(currentPosition - 10) // -10s. onShowBackwardRipple(offset) } else if (offset.x >= controllerWidth() * 0.75f) { - onSeekTo(currentPosition + 10) // +10s. + playStateCallback.onSeekTo(currentPosition + 10) // +10s. onShowForwardRipple(offset) } else { onDoubleTapPausePlay() @@ -88,7 +88,7 @@ internal fun Modifier.detectPressGestures( onLongPress = { beforeLongPressingSpeed = playState().speed isLongPressingChanged(true) - onSpeedChanged(3f) + playStateCallback.onSpeedChanged(3f) }, onDoubleTap = { restartAutoHideControllerRunnable() @@ -98,7 +98,7 @@ internal fun Modifier.detectPressGestures( tryAwaitRelease() if (isLongPressing()) { isLongPressingChanged(false) - onSpeedChanged(beforeLongPressingSpeed) + playStateCallback.onSpeedChanged(beforeLongPressingSpeed) } }, onTap = { @@ -121,13 +121,11 @@ internal fun Modifier.detectControllerGestures( onVolumeRangeChanged: (IntRange) -> Unit, onVolumeChanged: (Int) -> Unit, playState: () -> PlayState, - onSeekTo: (position: Int) -> Unit, + playStateCallback: PlayStateCallback, onShowSeekTimePreview: (Boolean) -> Unit, onTimePreviewChanged: (Int) -> Unit, transformState: () -> TransformState, - onVideoRotate: (Float) -> Unit, - onVideoZoom: (Float) -> Unit, - onVideoOffset: (Offset) -> Unit, + transformStateCallback: TransformStateCallback, cancelAutoHideControllerRunnable: () -> Boolean, restartAutoHideControllerRunnable: () -> Unit, ): Modifier { @@ -233,7 +231,7 @@ internal fun Modifier.detectControllerGestures( onShowSeekTimePreview(false) restartAutoHideControllerRunnable() if (inStatusBarArea(pointerStartY)) return@onHorizontalDragEnd - onSeekTo(seekTimePreviewStartPosition + seekTimePreviewPositionDelta) + playStateCallback.onSeekTo(seekTimePreviewStartPosition + seekTimePreviewPositionDelta) }, onHorizontalDragCancel = { onShowSeekTimePreview(false) @@ -247,9 +245,9 @@ internal fun Modifier.detectControllerGestures( }, onGesture = onGesture@{ _: Offset, pan: Offset, zoom: Float, rotation: Float -> with(transformState()) { - onVideoOffset(videoOffset + pan) - onVideoRotate(videoRotate + rotation) - onVideoZoom(videoZoom * zoom) + transformStateCallback.onVideoOffset(videoOffset + pan) + transformStateCallback.onVideoRotate(videoRotate + rotation) + transformStateCallback.onVideoZoom(videoZoom * zoom) } } ) diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/state/PlayState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/state/PlayState.kt index fa70aef2..784d0f12 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/state/PlayState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/state/PlayState.kt @@ -1,5 +1,7 @@ package com.skyd.anivu.ui.mpv.state +import androidx.compose.runtime.Immutable + data class PlayState( val isPlaying: Boolean, val isSeeking: Boolean, @@ -18,4 +20,12 @@ data class PlayState( title = "", ) } -} \ No newline at end of file +} + +@Immutable +data class PlayStateCallback( + val onPlayStateChanged: () -> Unit, + val onPlayOrPause: () -> Unit, + val onSeekTo: (position: Int) -> Unit, + val onSpeedChanged: (Float) -> Unit, +) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/mpv/state/TransformState.kt b/app/src/main/java/com/skyd/anivu/ui/mpv/state/TransformState.kt index dffe8789..fbcd4c10 100644 --- a/app/src/main/java/com/skyd/anivu/ui/mpv/state/TransformState.kt +++ b/app/src/main/java/com/skyd/anivu/ui/mpv/state/TransformState.kt @@ -1,5 +1,6 @@ package com.skyd.anivu.ui.mpv.state +import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset data class TransformState( @@ -14,4 +15,11 @@ data class TransformState( videoOffset = Offset.Zero, ) } -} \ No newline at end of file +} + +@Immutable +data class TransformStateCallback( + val onVideoRotate: (Float) -> Unit, + val onVideoZoom: (Float) -> Unit, + val onVideoOffset: (Offset) -> Unit, +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_photo_camera_24.xml b/app/src/main/res/drawable/ic_photo_camera_24.xml new file mode 100644 index 00000000..8cc2ebc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_camera_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ac5b7819..eac831d4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -205,6 +205,9 @@ 编辑 mpv.conf 字幕轨道 已选 + 截图 + 无权限。无法保存截图。 + 截图按钮 每 %d 分钟 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4ab0611d..067a895d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -213,6 +213,9 @@ Edit mpv.conf Subtitle track Selected + Screenshot + No permission. Can\'t save screenshot. + Screenshot button Every %d minute Every %d minutes