From 65d0738a367874173425f24abdccf8ab412dc093 Mon Sep 17 00:00:00 2001 From: ChiveHao Date: Sun, 28 Jul 2024 01:19:34 +0800 Subject: [PATCH] feat: screenshot by vlc video player in desktop (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add button for screenshot. * feat: add screenshot methods for PlayerState * style: adjust rhs bar in VideoScaffold * feat: vlc player screenshot * optimize: save screenshot file logic * optimize: 抽取截图逻辑到vm层 * feat: add exo player screenshot methods. * style: 调整截屏按钮只在Desktop显示 * optimize: 去掉截图文件名的当前时间戳 * optimize: 文件名的当前时间转换成时分秒毫秒字符串 * fix: build issue in VideoScaffold.android.kt * optimize: adjust code * optimize: adjust code * optimize: adjust code * optimize: adjust code * optimize: adjust code --- .../kotlin/ui/subject/episode/EpisodePage.kt | 10 +++++++ .../kotlin/ui/subject/episode/EpisodeVideo.kt | 11 ++++++- .../android/PlayerState.android.kt | 3 ++ .../android/ui/VideoScaffold.android.kt | 1 + .../video-player/common/ui/VideoScaffold.kt | 20 ++++++++++--- .../ui/guesture/PlayerFloatingButtonBox.kt | 28 +++++++++++++++++ .../common/ui/guesture/ScreenshotButton.kt | 30 +++++++++++++++++++ .../common/ui/state/PlayerState.kt | 5 ++++ .../desktop/ui/VideoPlayer.desktop.kt | 17 +++++++++++ 9 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 app/shared/video-player/common/ui/guesture/PlayerFloatingButtonBox.kt create mode 100644 app/shared/video-player/common/ui/guesture/ScreenshotButton.kt diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodePage.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodePage.kt index 2b251926d6..b1cc7d81a2 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodePage.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodePage.kt @@ -536,6 +536,16 @@ private fun EpisodeVideo( }, onShowMediaSelector = { isMediaSelectorVisible = true }, onShowSelectEpisode = { isEpisodeSelectorVisible = true }, + onClickScreenshot = { + val currentPositionMillis = vm.playerState.currentPositionMillis.value + val min = currentPositionMillis / 60000 + val sec = (currentPositionMillis - (min * 60000)) / 1000 + val ms = currentPositionMillis - (min * 60000) - (sec * 1000) + val currentPosition = "${min}m${sec}s${ms}ms" + // 条目ID-剧集序号-视频时间点.png + val filename = "${vm.subjectId}-${vm.episodePresentation.ep}-${currentPosition}.png" + vm.playerState.saveScreenshotFile(filename) + }, ) } diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt index 9cfb4d0f21..0c03cb8e27 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt @@ -54,6 +54,7 @@ import me.him188.ani.app.videoplayer.ui.VideoPlayer import me.him188.ani.app.videoplayer.ui.VideoScaffold import me.him188.ani.app.videoplayer.ui.guesture.GestureLock import me.him188.ani.app.videoplayer.ui.guesture.LockableVideoGestureHost +import me.him188.ani.app.videoplayer.ui.guesture.ScreenshotButton import me.him188.ani.app.videoplayer.ui.guesture.rememberGestureIndicatorState import me.him188.ani.app.videoplayer.ui.guesture.rememberPlayerFastSkipState import me.him188.ani.app.videoplayer.ui.guesture.rememberSwipeSeekerState @@ -95,6 +96,7 @@ internal fun EpisodeVideoImpl( sideSheets: @Composable () -> Unit, onShowMediaSelector: () -> Unit, onShowSelectEpisode: () -> Unit, + onClickScreenshot: () -> Unit, modifier: Modifier = Modifier, maintainAspectRatio: Boolean = !expanded, ) { @@ -204,7 +206,14 @@ internal fun EpisodeVideoImpl( ) } }, - rhsBar = { + rhsButtons = { + if (expanded && currentPlatform.isDesktop()) { + ScreenshotButton( + onClick = onClickScreenshot, + ) + } + }, + gestureLock = { if (expanded) { GestureLock(isLocked = isLocked, onClick = { isLocked = !isLocked }) } diff --git a/app/shared/video-player/android/PlayerState.android.kt b/app/shared/video-player/android/PlayerState.android.kt index 1aebe3329d..67c92b30da 100644 --- a/app/shared/video-player/android/PlayerState.android.kt +++ b/app/shared/video-player/android/PlayerState.android.kt @@ -354,6 +354,9 @@ internal class ExoPlayerState @UiThread constructor( override val subtitleTracks: MutableTrackGroup = MutableTrackGroup() override val audioTracks: MutableTrackGroup = MutableTrackGroup() + override fun saveScreenshotFile(filename: String) { + TODO("Not yet implemented") + } override val currentPositionMillis: MutableStateFlow = MutableStateFlow(0) override fun getExactCurrentPositionMillis(): Long = player.currentPosition diff --git a/app/shared/video-player/android/ui/VideoScaffold.android.kt b/app/shared/video-player/android/ui/VideoScaffold.android.kt index 2c2f84a908..30fa0e4238 100644 --- a/app/shared/video-player/android/ui/VideoScaffold.android.kt +++ b/app/shared/video-player/android/ui/VideoScaffold.android.kt @@ -98,6 +98,7 @@ private fun PreviewVideoScaffoldImpl( }, onShowMediaSelector = { isMediaSelectorVisible = true }, onShowSelectEpisode = { isEpisodeSelectorVisible = true }, + onClickScreenshot = {}, ) // VideoScaffold( diff --git a/app/shared/video-player/common/ui/VideoScaffold.kt b/app/shared/video-player/common/ui/VideoScaffold.kt index 42e155c4e9..b15258ad45 100644 --- a/app/shared/video-player/common/ui/VideoScaffold.kt +++ b/app/shared/video-player/common/ui/VideoScaffold.kt @@ -75,7 +75,8 @@ fun VideoScaffold( danmakuHost: @Composable BoxScope.() -> Unit = {}, gestureHost: @Composable BoxWithConstraintsScope.() -> Unit = {}, floatingMessage: @Composable BoxScope.() -> Unit = {}, - rhsBar: @Composable ColumnScope.() -> Unit = {}, + rhsButtons: @Composable ColumnScope.() -> Unit = {}, + gestureLock: @Composable ColumnScope.() -> Unit = {}, bottomBar: @Composable RowScope.() -> Unit = {}, floatingBottomEnd: @Composable RowScope.() -> Unit = {}, rhsSheet: @Composable () -> Unit = {}, @@ -205,15 +206,26 @@ fun VideoScaffold( } } Column(Modifier.fillMaxSize().background(Color.Transparent)) { - // Separate from controllers, to fix position when controllers are/aren't hidden Box(Modifier.weight(1f, fill = true).fillMaxWidth()) { - Column(Modifier.padding(end = 16.dp).align(Alignment.CenterEnd)) { + Column( + Modifier.padding(end = 16.dp).align(Alignment.CenterEnd), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = controllersVisibleState && !gestureLockedState, + enter = fadeIn(), + exit = fadeOut(), + ) { + rhsButtons() + } + + // Separate from controllers, to fix position when controllers are/aren't hidden AnimatedVisibility( visible = controllersVisibleState, enter = fadeIn(), exit = fadeOut(), ) { - rhsBar() + gestureLock() } } } diff --git a/app/shared/video-player/common/ui/guesture/PlayerFloatingButtonBox.kt b/app/shared/video-player/common/ui/guesture/PlayerFloatingButtonBox.kt new file mode 100644 index 0000000000..921f8e1593 --- /dev/null +++ b/app/shared/video-player/common/ui/guesture/PlayerFloatingButtonBox.kt @@ -0,0 +1,28 @@ +package me.him188.ani.app.videoplayer.ui.guesture + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme +import me.him188.ani.app.ui.foundation.theme.aniLightColorTheme +import me.him188.ani.app.ui.foundation.theme.slightlyWeaken + +@Composable +fun PlayerFloatingButtonBox( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Surface( + modifier, + shape = RoundedCornerShape(16.dp), + color = aniDarkColorTheme().background.copy(0.05f), + contentColor = Color.White, + border = BorderStroke(0.5.dp, aniLightColorTheme().outline.slightlyWeaken()), + ) { + content() + } +} \ No newline at end of file diff --git a/app/shared/video-player/common/ui/guesture/ScreenshotButton.kt b/app/shared/video-player/common/ui/guesture/ScreenshotButton.kt new file mode 100644 index 0000000000..96b1c1d744 --- /dev/null +++ b/app/shared/video-player/common/ui/guesture/ScreenshotButton.kt @@ -0,0 +1,30 @@ +package me.him188.ani.app.videoplayer.ui.guesture + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PhotoCamera +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun ScreenshotButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + PlayerFloatingButtonBox( + modifier = modifier, + content = { + IconButton(onClick) { + val color = Color.White + CompositionLocalProvider(LocalContentColor provides color) { + Icon(Icons.Rounded.PhotoCamera, contentDescription = "Lock screen") + } + } + }, + ) +} + diff --git a/app/shared/video-player/common/ui/state/PlayerState.kt b/app/shared/video-player/common/ui/state/PlayerState.kt index 7f687f7da8..bdeacf6de3 100644 --- a/app/shared/video-player/common/ui/state/PlayerState.kt +++ b/app/shared/video-player/common/ui/state/PlayerState.kt @@ -150,6 +150,8 @@ interface PlayerState { val subtitleTracks: TrackGroup val audioTracks: TrackGroup + + fun saveScreenshotFile(filename: String) } fun PlayerState.togglePause() { @@ -438,4 +440,7 @@ class DummyPlayerState : AbstractPlayerState(EmptyCoro override val subtitleTracks: TrackGroup = emptyTrackGroup() override val audioTracks: TrackGroup = emptyTrackGroup() + + override fun saveScreenshotFile(filename: String) { + } } \ No newline at end of file diff --git a/app/shared/video-player/desktop/ui/VideoPlayer.desktop.kt b/app/shared/video-player/desktop/ui/VideoPlayer.desktop.kt index 041d8a9343..1bc68a0eb9 100644 --- a/app/shared/video-player/desktop/ui/VideoPlayer.desktop.kt +++ b/app/shared/video-player/desktop/ui/VideoPlayer.desktop.kt @@ -46,8 +46,11 @@ import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import java.io.IOException +import java.nio.file.Path import java.util.Locale import kotlin.coroutines.CoroutineContext +import kotlin.io.path.createDirectories import kotlin.time.Duration.Companion.seconds @@ -139,6 +142,20 @@ class VlcjVideoPlayerState(parentCoroutineContext: CoroutineContext) : PlayerSta } } + override fun saveScreenshotFile(filename: String) { + player.submit { + val screenshotPath: Path = + Path.of(System.getProperty("user.home")).resolve("Pictures").resolve("Ani") + try { + screenshotPath.createDirectories() + } catch (ex: IOException) { + logger.warn("Create ani pictures dir fail", ex); + } + val filePath = screenshotPath.resolve(filename) + player.snapshots().save(filePath.toFile()) + } + } + class VlcjData( override val videoSource: VideoSource<*>, override val videoData: VideoData,