mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Integrate with libass-android to support SSA/ASS subtitles in ExoPlayer (#569)
## Description Add better ASS/SSA support to ExoPlayer by using [`libass-android`](https://github.com/peerless2012/libass-android). This is enabled with the "Use libass for ASS subtitles" advanced settings for ExoPlayer. It is enabled by default. ### Related issues Related to #22
This commit is contained in:
parent
014bed1bf3
commit
83ebd922d9
8 changed files with 573 additions and 512 deletions
|
|
@ -231,6 +231,7 @@ dependencies {
|
|||
implementation(libs.androidx.media3.exoplayer.dash)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.androidx.media3.ui.compose)
|
||||
implementation(libs.ass.media)
|
||||
|
||||
implementation(libs.coil.core)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
|
|||
|
|
@ -10,22 +10,28 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
|
||||
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.Constructor
|
||||
|
|
@ -46,71 +52,17 @@ class PlayerFactory
|
|||
var currentPlayer: Player? = null
|
||||
private set
|
||||
|
||||
fun createVideoPlayer(): Player {
|
||||
if (currentPlayer?.isReleased == false) {
|
||||
Timber.w("Player was not released before trying to create a new one!")
|
||||
currentPlayer?.release()
|
||||
}
|
||||
|
||||
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
|
||||
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
|
||||
val newPlayer =
|
||||
when (backend) {
|
||||
PlayerBackend.PREFER_MPV,
|
||||
PlayerBackend.MPV,
|
||||
-> {
|
||||
val enableHardwareDecoding =
|
||||
prefs?.mpvOptions?.enableHardwareDecoding
|
||||
?: AppPreference.MpvHardwareDecoding.defaultValue
|
||||
val useGpuNext =
|
||||
prefs?.mpvOptions?.useGpuNext
|
||||
?: AppPreference.MpvGpuNext.defaultValue
|
||||
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
PlayerBackend.EXO_PLAYER,
|
||||
PlayerBackend.UNRECOGNIZED,
|
||||
-> {
|
||||
val extensions = prefs?.overrides?.mediaExtensionsEnabled
|
||||
val decodeAv1 = prefs?.overrides?.decodeAv1 == true
|
||||
Timber.v("extensions=$extensions")
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
}
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setRenderersFactory(
|
||||
WholphinRenderersFactory(context, decodeAv1)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode),
|
||||
).build()
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPlayer = newPlayer
|
||||
return newPlayer
|
||||
}
|
||||
|
||||
suspend fun createVideoPlayer(
|
||||
backend: PlayerBackend,
|
||||
prefs: PlaybackPreferences,
|
||||
): Player {
|
||||
): PlayerCreation {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (currentPlayer?.isReleased == false) {
|
||||
Timber.w("Player was not released before trying to create a new one!")
|
||||
currentPlayer?.release()
|
||||
}
|
||||
}
|
||||
|
||||
var assHandler: AssHandler? = null
|
||||
val newPlayer =
|
||||
when (backend) {
|
||||
PlayerBackend.PREFER_MPV,
|
||||
|
|
@ -125,8 +77,9 @@ class PlayerFactory
|
|||
PlayerBackend.UNRECOGNIZED,
|
||||
-> {
|
||||
val extensions = prefs.overrides.mediaExtensionsEnabled
|
||||
val directPlayAss = prefs.overrides.directPlayAss
|
||||
val decodeAv1 = prefs.overrides.decodeAv1
|
||||
Timber.v("extensions=$extensions")
|
||||
Timber.v("extensions=$extensions, directPlayAss=$directPlayAss")
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
|
|
@ -134,17 +87,42 @@ class PlayerFactory
|
|||
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
}
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
var renderersFactory: RenderersFactory =
|
||||
WholphinRenderersFactory(context, decodeAv1)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode)
|
||||
val mediaSourceFactory =
|
||||
if (directPlayAss) {
|
||||
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
|
||||
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
|
||||
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||
DefaultMediaSourceFactory(
|
||||
dataSourceFactory,
|
||||
extractorsFactory.withAssMkvSupport(
|
||||
assSubtitleParserFactory,
|
||||
assHandler,
|
||||
),
|
||||
).setSubtitleParserFactory(assSubtitleParserFactory)
|
||||
} else {
|
||||
DefaultMediaSourceFactory(
|
||||
dataSourceFactory,
|
||||
extractorsFactory,
|
||||
)
|
||||
}
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.setRenderersFactory(
|
||||
WholphinRenderersFactory(context, decodeAv1)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode),
|
||||
).build()
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setRenderersFactory(renderersFactory)
|
||||
.build()
|
||||
.apply {
|
||||
assHandler?.init(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPlayer = newPlayer
|
||||
return newPlayer
|
||||
return PlayerCreation(newPlayer, assHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +135,11 @@ val Player.isReleased: Boolean
|
|||
}
|
||||
}
|
||||
|
||||
data class PlayerCreation(
|
||||
val player: Player,
|
||||
val assHandler: AssHandler? = null,
|
||||
)
|
||||
|
||||
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
|
||||
class WholphinRenderersFactory(
|
||||
context: Context,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.annotation.OptIn
|
||||
|
|
@ -42,16 +45,18 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.view.children
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import androidx.media3.ui.compose.PlayerSurface
|
||||
|
|
@ -83,6 +88,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -127,8 +133,7 @@ fun PlaybackPage(
|
|||
LoadingState.Success -> {
|
||||
val playerState by viewModel.currentPlayer.collectAsState()
|
||||
PlaybackPageContent(
|
||||
player = playerState!!.player,
|
||||
playerBackend = playerState!!.backend,
|
||||
playerState = playerState!!,
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
viewModel = viewModel,
|
||||
|
|
@ -141,13 +146,15 @@ fun PlaybackPage(
|
|||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackPageContent(
|
||||
player: Player,
|
||||
playerBackend: PlayerBackend,
|
||||
playerState: PlayerState,
|
||||
preferences: UserPreferences,
|
||||
destination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlaybackViewModel,
|
||||
) {
|
||||
val player = playerState.player
|
||||
val playerBackend = playerState.backend
|
||||
|
||||
val prefs = preferences.appPreferences.playbackPreferences
|
||||
val scope = rememberCoroutineScope()
|
||||
val configuration = LocalConfiguration.current
|
||||
|
|
@ -320,10 +327,14 @@ fun PlaybackPageContent(
|
|||
.focusRequester(focusRequester)
|
||||
.focusable(),
|
||||
) {
|
||||
var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier = scaledModifier,
|
||||
modifier =
|
||||
scaledModifier.onSizeChanged {
|
||||
playerSurfaceSize = it
|
||||
},
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
|
|
@ -416,7 +427,7 @@ fun PlaybackPageContent(
|
|||
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
||||
AndroidView(
|
||||
|
|
@ -427,12 +438,42 @@ fun PlaybackPageContent(
|
|||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
}
|
||||
playerState.assHandler?.let { assHandler ->
|
||||
if (prefs.overrides.directPlayAss) {
|
||||
Timber.v("Adding AssSubtitleView")
|
||||
addView(
|
||||
AssSubtitleView(context, assHandler).apply {
|
||||
layoutParams =
|
||||
FrameLayout
|
||||
.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
).apply { gravity = Gravity.CENTER }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
||||
.apply(it)
|
||||
it.children.firstOrNull { it is AssSubtitleView }?.let {
|
||||
(it as? AssSubtitleView)?.apply {
|
||||
val resized =
|
||||
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
|
||||
if (resized) {
|
||||
Timber.v("Resizing AssSubtitleView: $playerSurfaceSize")
|
||||
layoutParams =
|
||||
FrameLayout
|
||||
.LayoutParams(
|
||||
playerSurfaceSize.width,
|
||||
playerSurfaceSize.height,
|
||||
).apply { gravity = Gravity.CENTER }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onReset = {
|
||||
it.setCues(null)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -218,7 +219,8 @@ class PlaybackViewModel
|
|||
isHdr: Boolean,
|
||||
is4k: Boolean,
|
||||
) {
|
||||
val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
|
||||
val softwareDecoding =
|
||||
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
|
||||
val playerBackend =
|
||||
when (preferences.appPreferences.playbackPreferences.playerBackend) {
|
||||
PlayerBackend.UNRECOGNIZED,
|
||||
|
|
@ -237,13 +239,14 @@ class PlaybackViewModel
|
|||
disconnectPlayer()
|
||||
}
|
||||
|
||||
player =
|
||||
val playerCreation =
|
||||
playerFactory.createVideoPlayer(
|
||||
playerBackend,
|
||||
preferences.appPreferences.playbackPreferences,
|
||||
)
|
||||
this.player = playerCreation.player
|
||||
currentPlayer.update {
|
||||
PlayerState(player, playerBackend)
|
||||
PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
|
||||
}
|
||||
configurePlayer()
|
||||
}
|
||||
|
|
@ -1438,6 +1441,7 @@ class PlaybackViewModel
|
|||
data class PlayerState(
|
||||
val player: Player,
|
||||
val backend: PlayerBackend,
|
||||
val assHandler: AssHandler?,
|
||||
)
|
||||
|
||||
data class MediaSegmentState(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
|
|||
import com.github.damontecres.wholphin.ui.playback.isDpad
|
||||
import com.github.damontecres.wholphin.ui.playback.isEnterKey
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import timber.log.Timber
|
||||
import kotlin.math.abs
|
||||
|
|
@ -88,413 +89,429 @@ fun SlideshowPage(
|
|||
),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
|
||||
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
||||
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
||||
val position by viewModel.position.observeAsState(0)
|
||||
val pager by viewModel.pager.observeAsState()
|
||||
val imageState by viewModel.image.observeAsState()
|
||||
|
||||
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||
val isZoomed = zoomFactor * 100 > 102
|
||||
var rotation by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var showOverlay by rememberSaveable { mutableStateOf(false) }
|
||||
var showFilterDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var panX by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var panY by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
|
||||
val slideshowControls =
|
||||
object : SlideshowControls {
|
||||
override fun startSlideshow() {
|
||||
showOverlay = false
|
||||
viewModel.startSlideshow()
|
||||
}
|
||||
|
||||
override fun stopSlideshow() {
|
||||
viewModel.stopSlideshow()
|
||||
}
|
||||
when (val st = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
val rotateAnimation: Float by animateFloatAsState(
|
||||
targetValue = rotation,
|
||||
label = "image_rotation",
|
||||
)
|
||||
val zoomAnimation: Float by animateFloatAsState(
|
||||
targetValue = zoomFactor,
|
||||
label = "image_zoom",
|
||||
)
|
||||
val panXAnimation: Float by animateFloatAsState(
|
||||
targetValue = panX,
|
||||
label = "image_panX",
|
||||
)
|
||||
val panYAnimation: Float by animateFloatAsState(
|
||||
targetValue = panY,
|
||||
label = "image_panY",
|
||||
)
|
||||
|
||||
val slideshowState by viewModel.slideshow.collectAsState()
|
||||
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val screenHeight = LocalWindowInfo.current.containerSize.height
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
|
||||
val maxPanX = screenWidth * .75f
|
||||
val maxPanY = screenHeight * .75f
|
||||
|
||||
fun reset(resetRotate: Boolean) {
|
||||
zoomFactor = 1f
|
||||
panX = 0f
|
||||
panY = 0f
|
||||
if (resetRotate) rotation = 0f
|
||||
}
|
||||
|
||||
fun pan(
|
||||
xFactor: Int,
|
||||
yFactor: Int,
|
||||
) {
|
||||
if (xFactor != 0) {
|
||||
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
if (yFactor != 0) {
|
||||
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
|
||||
}
|
||||
}
|
||||
|
||||
fun zoom(factor: Float) {
|
||||
if (factor < 0) {
|
||||
val diffFactor = factor / (zoomFactor - 1f)
|
||||
// zooming out
|
||||
val panXDiff = abs(panX * diffFactor)
|
||||
val panYDiff = abs(panY * diffFactor)
|
||||
if (DEBUG) {
|
||||
Timber.d(
|
||||
"zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff",
|
||||
)
|
||||
}
|
||||
if (panX > 0f) {
|
||||
panX -= panXDiff
|
||||
} else if (panX < 0f) {
|
||||
panX += panXDiff
|
||||
}
|
||||
if (panY > 0f) {
|
||||
panY -= panYDiff
|
||||
} else if (panY < 0f) {
|
||||
panY += panYDiff
|
||||
}
|
||||
}
|
||||
zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f)
|
||||
if (!isZoomed) {
|
||||
// Always reset if not zoomed
|
||||
panX = 0f
|
||||
panY = 0f
|
||||
}
|
||||
}
|
||||
LoadingState.Success -> {
|
||||
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
||||
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
||||
val position by viewModel.position.observeAsState(0)
|
||||
val pager by viewModel.pager.observeAsState()
|
||||
val imageState by viewModel.image.observeAsState()
|
||||
|
||||
LaunchedEffect(imageState) {
|
||||
reset(true)
|
||||
}
|
||||
val player = viewModel.player
|
||||
val presentationState = rememberPresentationState(player)
|
||||
LaunchedEffect(slideshowActive) {
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
||||
}
|
||||
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||
val isZoomed = zoomFactor * 100 > 102
|
||||
var rotation by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var showOverlay by rememberSaveable { mutableStateOf(false) }
|
||||
var showFilterDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var panX by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var panY by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
|
||||
var longPressing by remember { mutableStateOf(false) }
|
||||
val slideshowControls =
|
||||
object : SlideshowControls {
|
||||
override fun startSlideshow() {
|
||||
showOverlay = false
|
||||
viewModel.startSlideshow()
|
||||
}
|
||||
|
||||
val contentModifier =
|
||||
Modifier
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = {
|
||||
showOverlay = !showOverlay
|
||||
},
|
||||
override fun stopSlideshow() {
|
||||
viewModel.stopSlideshow()
|
||||
}
|
||||
}
|
||||
|
||||
val rotateAnimation: Float by animateFloatAsState(
|
||||
targetValue = rotation,
|
||||
label = "image_rotation",
|
||||
)
|
||||
val zoomAnimation: Float by animateFloatAsState(
|
||||
targetValue = zoomFactor,
|
||||
label = "image_zoom",
|
||||
)
|
||||
val panXAnimation: Float by animateFloatAsState(
|
||||
targetValue = panX,
|
||||
label = "image_panX",
|
||||
)
|
||||
val panYAnimation: Float by animateFloatAsState(
|
||||
targetValue = panY,
|
||||
label = "image_panY",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent {
|
||||
val isOverlayShowing = showOverlay || showFilterDialog
|
||||
var result = false
|
||||
if (!isOverlayShowing) {
|
||||
if (longPressing && it.type == KeyEventType.KeyUp) {
|
||||
// User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image
|
||||
longPressing = false
|
||||
return@onKeyEvent true
|
||||
}
|
||||
longPressing =
|
||||
it.nativeKeyEvent.isLongPress ||
|
||||
it.nativeKeyEvent.repeatCount > 0
|
||||
if (longPressing) {
|
||||
when (it.key) {
|
||||
Key.DirectionUp -> zoom(.05f)
|
||||
Key.DirectionDown -> zoom(-.05f)
|
||||
val slideshowState by viewModel.slideshow.collectAsState()
|
||||
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||
|
||||
// These work, but feel awkward because Up/Down zoom, so you can't long press them to pan
|
||||
// Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() }
|
||||
// Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() }
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
}
|
||||
if (it.type != KeyEventType.KeyUp) {
|
||||
result = false
|
||||
} else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) {
|
||||
// Image is zoomed in
|
||||
when (it.key) {
|
||||
Key.DirectionLeft -> pan(30, 0)
|
||||
Key.DirectionRight -> pan(-30, 0)
|
||||
Key.DirectionUp -> pan(0, 30)
|
||||
Key.DirectionDown -> pan(0, -30)
|
||||
}
|
||||
result = true
|
||||
} else if (!isOverlayShowing && isZoomed && it.key == Key.Back) {
|
||||
reset(false)
|
||||
result = true
|
||||
} else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) {
|
||||
when (it.key) {
|
||||
Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> {
|
||||
if (!viewModel.previousImage()) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
R.string.slideshow_at_beginning,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> {
|
||||
if (!viewModel.nextImage()) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
R.string.no_more_images,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isOverlayShowing && it.key == Key.Back) {
|
||||
showOverlay = false
|
||||
viewModel.unpauseSlideshow()
|
||||
result = true
|
||||
} else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) {
|
||||
showOverlay = true
|
||||
viewModel.pauseSlideshow()
|
||||
result = true
|
||||
}
|
||||
if (result) {
|
||||
// Handled the key, so reset the slideshow timer
|
||||
viewModel.pulseSlideshow()
|
||||
}
|
||||
result
|
||||
},
|
||||
) {
|
||||
when (loadingState) {
|
||||
ImageLoadingState.Error -> {
|
||||
ErrorMessage("Error loading image", null, modifier)
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
|
||||
ImageLoadingState.Loading -> {
|
||||
LoadingPage(modifier)
|
||||
val density = LocalDensity.current
|
||||
val screenHeight = LocalWindowInfo.current.containerSize.height
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
|
||||
val maxPanX = screenWidth * .75f
|
||||
val maxPanY = screenHeight * .75f
|
||||
|
||||
fun reset(resetRotate: Boolean) {
|
||||
zoomFactor = 1f
|
||||
panX = 0f
|
||||
panY = 0f
|
||||
if (resetRotate) rotation = 0f
|
||||
}
|
||||
|
||||
is ImageLoadingState.Success -> {
|
||||
imageState?.let { imageState ->
|
||||
if (imageState.image.data.mediaType == MediaType.VIDEO) {
|
||||
LaunchedEffect(imageState.id) {
|
||||
val mediaItem =
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setUri(imageState.url)
|
||||
.build()
|
||||
player.setMediaItem(mediaItem)
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) {
|
||||
Player.REPEAT_MODE_OFF
|
||||
} else {
|
||||
Player.REPEAT_MODE_ONE
|
||||
}
|
||||
player.prepare()
|
||||
player.play()
|
||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||
}
|
||||
LifecycleStartEffect(Unit) {
|
||||
onStopOrDispose {
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
val contentScale = ContentScale.Fit
|
||||
val scaledModifier =
|
||||
contentModifier.resizeWithContentScale(
|
||||
contentScale,
|
||||
presentationState.videoSizeDp,
|
||||
)
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier =
|
||||
scaledModifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomAnimation
|
||||
scaleY = zoomAnimation
|
||||
translationX = panXAnimation
|
||||
translationY = panYAnimation
|
||||
}.rotate(rotateAnimation),
|
||||
fun pan(
|
||||
xFactor: Int,
|
||||
yFactor: Int,
|
||||
) {
|
||||
if (xFactor != 0) {
|
||||
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
|
||||
}
|
||||
if (yFactor != 0) {
|
||||
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
|
||||
}
|
||||
}
|
||||
|
||||
fun zoom(factor: Float) {
|
||||
if (factor < 0) {
|
||||
val diffFactor = factor / (zoomFactor - 1f)
|
||||
// zooming out
|
||||
val panXDiff = abs(panX * diffFactor)
|
||||
val panYDiff = abs(panY * diffFactor)
|
||||
if (DEBUG) {
|
||||
Timber.d(
|
||||
"zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff",
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.Black),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val colorFilter =
|
||||
remember(imageState.id, imageFilter) {
|
||||
if (imageFilter.hasImageFilter()) {
|
||||
ColorMatrixColorFilter(imageFilter.colorMatrix)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (panX > 0f) {
|
||||
panX -= panXDiff
|
||||
} else if (panX < 0f) {
|
||||
panX += panXDiff
|
||||
}
|
||||
if (panY > 0f) {
|
||||
panY -= panYDiff
|
||||
} else if (panY < 0f) {
|
||||
panY += panYDiff
|
||||
}
|
||||
}
|
||||
zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f)
|
||||
if (!isZoomed) {
|
||||
// Always reset if not zoomed
|
||||
panX = 0f
|
||||
panY = 0f
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(imageState) {
|
||||
reset(true)
|
||||
}
|
||||
val player = viewModel.player
|
||||
val presentationState = rememberPresentationState(player)
|
||||
LaunchedEffect(slideshowActive) {
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
||||
}
|
||||
|
||||
var longPressing by remember { mutableStateOf(false) }
|
||||
|
||||
val contentModifier =
|
||||
Modifier
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = {
|
||||
showOverlay = !showOverlay
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent {
|
||||
val isOverlayShowing = showOverlay || showFilterDialog
|
||||
var result = false
|
||||
if (!isOverlayShowing) {
|
||||
if (longPressing && it.type == KeyEventType.KeyUp) {
|
||||
// User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image
|
||||
longPressing = false
|
||||
return@onKeyEvent true
|
||||
}
|
||||
longPressing =
|
||||
it.nativeKeyEvent.isLongPress ||
|
||||
it.nativeKeyEvent.repeatCount > 0
|
||||
if (longPressing) {
|
||||
when (it.key) {
|
||||
Key.DirectionUp -> zoom(.05f)
|
||||
Key.DirectionDown -> zoom(-.05f)
|
||||
|
||||
// These work, but feel awkward because Up/Down zoom, so you can't long press them to pan
|
||||
// Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() }
|
||||
// Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() }
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
}
|
||||
// If the image loading is large, show the thumbnail while waiting
|
||||
// TODO
|
||||
val showLoadingThumbnail = true
|
||||
SubcomposeAsyncImage(
|
||||
if (it.type != KeyEventType.KeyUp) {
|
||||
result = false
|
||||
} else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) {
|
||||
// Image is zoomed in
|
||||
when (it.key) {
|
||||
Key.DirectionLeft -> pan(30, 0)
|
||||
Key.DirectionRight -> pan(-30, 0)
|
||||
Key.DirectionUp -> pan(0, 30)
|
||||
Key.DirectionDown -> pan(0, -30)
|
||||
}
|
||||
result = true
|
||||
} else if (!isOverlayShowing && isZoomed && it.key == Key.Back) {
|
||||
reset(false)
|
||||
result = true
|
||||
} else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) {
|
||||
when (it.key) {
|
||||
Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> {
|
||||
if (!viewModel.previousImage()) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
R.string.slideshow_at_beginning,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> {
|
||||
if (!viewModel.nextImage()) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
R.string.no_more_images,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isOverlayShowing && it.key == Key.Back) {
|
||||
showOverlay = false
|
||||
viewModel.unpauseSlideshow()
|
||||
result = true
|
||||
} else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) {
|
||||
showOverlay = true
|
||||
viewModel.pauseSlideshow()
|
||||
result = true
|
||||
}
|
||||
if (result) {
|
||||
// Handled the key, so reset the slideshow timer
|
||||
viewModel.pulseSlideshow()
|
||||
}
|
||||
result
|
||||
},
|
||||
) {
|
||||
when (loadingState) {
|
||||
ImageLoadingState.Error -> {
|
||||
ErrorMessage("Error loading image", null, modifier)
|
||||
}
|
||||
|
||||
ImageLoadingState.Loading -> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
is ImageLoadingState.Success -> {
|
||||
imageState?.let { imageState ->
|
||||
if (imageState.image.data.mediaType == MediaType.VIDEO) {
|
||||
LaunchedEffect(imageState.id) {
|
||||
val mediaItem =
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setUri(imageState.url)
|
||||
.build()
|
||||
player.setMediaItem(mediaItem)
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) {
|
||||
Player.REPEAT_MODE_OFF
|
||||
} else {
|
||||
Player.REPEAT_MODE_ONE
|
||||
}
|
||||
player.prepare()
|
||||
player.play()
|
||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||
}
|
||||
LifecycleStartEffect(Unit) {
|
||||
onStopOrDispose {
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
val contentScale = ContentScale.Fit
|
||||
val scaledModifier =
|
||||
contentModifier.resizeWithContentScale(
|
||||
contentScale,
|
||||
presentationState.videoSizeDp,
|
||||
)
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier =
|
||||
scaledModifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomAnimation
|
||||
scaleY = zoomAnimation
|
||||
translationX = panXAnimation
|
||||
translationY = panYAnimation
|
||||
}.rotate(rotateAnimation),
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.Black),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val colorFilter =
|
||||
remember(imageState.id, imageFilter) {
|
||||
if (imageFilter.hasImageFilter()) {
|
||||
ColorMatrixColorFilter(imageFilter.colorMatrix)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
// If the image loading is large, show the thumbnail while waiting
|
||||
// TODO
|
||||
val showLoadingThumbnail = true
|
||||
SubcomposeAsyncImage(
|
||||
modifier =
|
||||
contentModifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomAnimation
|
||||
scaleY = zoomAnimation
|
||||
translationX = panXAnimation
|
||||
translationY = panYAnimation
|
||||
|
||||
val xTransform =
|
||||
(screenWidth - panXAnimation) / (screenWidth * 2)
|
||||
val yTransform =
|
||||
(screenHeight - panYAnimation) / (screenHeight * 2)
|
||||
if (DEBUG) {
|
||||
Timber.d(
|
||||
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
|
||||
)
|
||||
}
|
||||
|
||||
transformOrigin =
|
||||
TransformOrigin(xTransform, yTransform)
|
||||
}.rotate(rotateAnimation),
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(imageState.url)
|
||||
.size(Size.ORIGINAL)
|
||||
.crossfade(!showLoadingThumbnail)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
error = {
|
||||
Text(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center),
|
||||
text = "Error loading image",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
},
|
||||
loading = {
|
||||
ImageLoadingPlaceholder(
|
||||
thumbnailUrl = imageState.thumbnailUrl,
|
||||
showThumbnail = showLoadingThumbnail,
|
||||
colorFilter = colorFilter,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
// Ensure that if an image takes a long time to load, it won't be skipped
|
||||
onLoading = {
|
||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||
},
|
||||
onSuccess = {
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
onError = {
|
||||
Timber.e(
|
||||
it.result.throwable,
|
||||
"Error loading image ${imageState.id}",
|
||||
)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"Error loading image: ${it.result.throwable.localizedMessage}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
showOverlay,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
) {
|
||||
imageState?.let { imageState ->
|
||||
ImageOverlay(
|
||||
modifier =
|
||||
contentModifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomAnimation
|
||||
scaleY = zoomAnimation
|
||||
translationX = panXAnimation
|
||||
translationY = panYAnimation
|
||||
|
||||
val xTransform =
|
||||
(screenWidth - panXAnimation) / (screenWidth * 2)
|
||||
val yTransform =
|
||||
(screenHeight - panYAnimation) / (screenHeight * 2)
|
||||
if (DEBUG) {
|
||||
Timber.d(
|
||||
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
|
||||
)
|
||||
}
|
||||
|
||||
transformOrigin = TransformOrigin(xTransform, yTransform)
|
||||
}.rotate(rotateAnimation),
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(imageState.url)
|
||||
.size(Size.ORIGINAL)
|
||||
.crossfade(!showLoadingThumbnail)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
error = {
|
||||
Text(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center),
|
||||
text = "Error loading image",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
},
|
||||
loading = {
|
||||
ImageLoadingPlaceholder(
|
||||
thumbnailUrl = imageState.thumbnailUrl,
|
||||
showThumbnail = showLoadingThumbnail,
|
||||
colorFilter = colorFilter,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
// Ensure that if an image takes a long time to load, it won't be skipped
|
||||
onLoading = {
|
||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||
},
|
||||
onSuccess = {
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
onError = {
|
||||
Timber.e(
|
||||
it.result.throwable,
|
||||
"Error loading image ${imageState.id}",
|
||||
)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"Error loading image: ${it.result.throwable.localizedMessage}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
viewModel.pulseSlideshow()
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.TransparentBlack50),
|
||||
onDismiss = { showOverlay = false },
|
||||
player = player,
|
||||
slideshowControls = slideshowControls,
|
||||
slideshowEnabled = slideshowState.enabled,
|
||||
image = imageState,
|
||||
position = position,
|
||||
count = pager?.size ?: -1,
|
||||
onClickItem = {},
|
||||
onLongClickItem = {},
|
||||
onZoom = ::zoom,
|
||||
onRotate = { rotation += it },
|
||||
onReset = { reset(true) },
|
||||
onShowFilterDialogClick = {
|
||||
showFilterDialog = true
|
||||
showOverlay = false
|
||||
viewModel.pauseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(showFilterDialog) {
|
||||
ImageFilterDialog(
|
||||
filter = imageFilter,
|
||||
showVideoOptions = false,
|
||||
showSaveGalleryButton = true,
|
||||
onChange = viewModel::updateImageFilter,
|
||||
onClickSave = viewModel::saveImageFilter,
|
||||
onClickSaveGallery = viewModel::saveGalleryFilter,
|
||||
onDismissRequest = {
|
||||
showFilterDialog = false
|
||||
viewModel.unpauseSlideshow()
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
showOverlay,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
) {
|
||||
imageState?.let { imageState ->
|
||||
ImageOverlay(
|
||||
modifier =
|
||||
contentModifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.TransparentBlack50),
|
||||
onDismiss = { showOverlay = false },
|
||||
player = player,
|
||||
slideshowControls = slideshowControls,
|
||||
slideshowEnabled = slideshowState.enabled,
|
||||
image = imageState,
|
||||
position = position,
|
||||
count = pager?.size ?: -1,
|
||||
onClickItem = {},
|
||||
onLongClickItem = {},
|
||||
onZoom = ::zoom,
|
||||
onRotate = { rotation += it },
|
||||
onReset = { reset(true) },
|
||||
onShowFilterDialogClick = {
|
||||
showFilterDialog = true
|
||||
showOverlay = false
|
||||
viewModel.pauseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(showFilterDialog) {
|
||||
ImageFilterDialog(
|
||||
filter = imageFilter,
|
||||
showVideoOptions = false,
|
||||
showSaveGalleryButton = true,
|
||||
onChange = viewModel::updateImageFilter,
|
||||
onClickSave = viewModel::saveImageFilter,
|
||||
onClickSaveGallery = viewModel::saveGalleryFilter,
|
||||
onDismissRequest = {
|
||||
showFilterDialog = false
|
||||
viewModel.unpauseSlideshow()
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
|
|
@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
|
|||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -77,9 +79,8 @@ class SlideshowViewModel
|
|||
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
|
||||
}
|
||||
|
||||
val player by lazy {
|
||||
playerFactory.createVideoPlayer()
|
||||
}
|
||||
lateinit var player: Player
|
||||
private set
|
||||
|
||||
private var saveFilters = true
|
||||
|
||||
|
|
@ -104,6 +105,8 @@ class SlideshowViewModel
|
|||
private val _image = MutableLiveData<ImageState>()
|
||||
val image: LiveData<ImageState> = _image
|
||||
|
||||
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
|
||||
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
||||
private val _imageFilter = MutableLiveData(VideoFilter())
|
||||
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
||||
|
|
@ -113,58 +116,67 @@ class SlideshowViewModel
|
|||
init {
|
||||
addCloseable {
|
||||
screensaverService.keepScreenOn(false)
|
||||
player.removeListener(this@SlideshowViewModel)
|
||||
player.release()
|
||||
}
|
||||
player.addListener(this@SlideshowViewModel)
|
||||
viewModelScope.launchIO {
|
||||
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences
|
||||
slideshowDelay =
|
||||
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
|
||||
?: AppPreference.SlideshowDuration.defaultValue
|
||||
// val album =
|
||||
// api.userLibraryApi
|
||||
// .getItem(
|
||||
// itemId = slideshowSettings.parentId,
|
||||
// ).content
|
||||
// .let { BaseItem(it, false) }
|
||||
// this@SlideshowViewModel.album.setValueOnMain(album)
|
||||
val includeItemTypes =
|
||||
if (photoPrefs.slideshowPlayVideos) {
|
||||
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
|
||||
} else {
|
||||
listOf(BaseItemKind.PHOTO)
|
||||
}
|
||||
val request =
|
||||
slideshowSettings.filter.filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = slideshowSettings.parentId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
fields = PhotoItemFields,
|
||||
recursive = true,
|
||||
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
|
||||
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||
),
|
||||
)
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val filter =
|
||||
playbackEffectDao
|
||||
.getPlaybackEffect(
|
||||
user.rowId,
|
||||
slideshowSettings.parentId,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
)?.videoFilter
|
||||
if (filter != null) {
|
||||
Timber.v("Got filter for album %s", slideshowSettings.parentId)
|
||||
albumImageFilter = filter
|
||||
}
|
||||
if (this@SlideshowViewModel::player.isInitialized) {
|
||||
player.removeListener(this@SlideshowViewModel)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val appPreferences = userPreferencesService.getCurrent().appPreferences
|
||||
val playerCreation =
|
||||
playerFactory.createVideoPlayer(
|
||||
backend = PlayerBackend.EXO_PLAYER,
|
||||
appPreferences.playbackPreferences,
|
||||
)
|
||||
player = playerCreation.player
|
||||
player.addListener(this@SlideshowViewModel)
|
||||
|
||||
val photoPrefs = appPreferences.photoPreferences
|
||||
slideshowDelay =
|
||||
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
|
||||
?: AppPreference.SlideshowDuration.defaultValue
|
||||
val includeItemTypes =
|
||||
if (photoPrefs.slideshowPlayVideos) {
|
||||
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
|
||||
} else {
|
||||
listOf(BaseItemKind.PHOTO)
|
||||
}
|
||||
val request =
|
||||
slideshowSettings.filter.filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = slideshowSettings.parentId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
fields = PhotoItemFields,
|
||||
recursive = true,
|
||||
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
|
||||
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||
),
|
||||
)
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val filter =
|
||||
playbackEffectDao
|
||||
.getPlaybackEffect(
|
||||
user.rowId,
|
||||
slideshowSettings.parentId,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
)?.videoFilter
|
||||
if (filter != null) {
|
||||
Timber.v("Got filter for album %s", slideshowSettings.parentId)
|
||||
albumImageFilter = filter
|
||||
}
|
||||
}
|
||||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||
.init(slideshowSettings.index)
|
||||
this@SlideshowViewModel._pager.setValueOnMain(pager)
|
||||
loading.update { LoadingState.Success }
|
||||
updatePosition(slideshowSettings.index)?.join()
|
||||
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error")
|
||||
loading.update { LoadingState.Error(ex) }
|
||||
}
|
||||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||
.init(slideshowSettings.index)
|
||||
this@SlideshowViewModel._pager.setValueOnMain(pager)
|
||||
updatePosition(slideshowSettings.index)?.join()
|
||||
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@
|
|||
<string name="check_for_updates">Check for updates</string>
|
||||
<string name="combine_continue_next_summary">Applies to TV Series only</string>
|
||||
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
|
||||
<string name="direct_play_ass">Direct play ASS subtitles</string>
|
||||
<string name="direct_play_ass">Use libass for ASS subtitles</string>
|
||||
<string name="direct_play_pgs">Direct play PGS subtitles</string>
|
||||
<string name="downmix_stereo">Always downmix to stereo</string>
|
||||
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ preferenceKtx = "1.2.1"
|
|||
tvprovider = "1.1.0"
|
||||
workRuntimeKtx = "2.11.1"
|
||||
paletteKtx = "1.0.0"
|
||||
assMedia = "0.4.0"
|
||||
kotlinxCoroutinesTest = "1.10.2"
|
||||
coreTesting = "2.2.0"
|
||||
openapi-generator = "7.20.0"
|
||||
|
|
@ -134,6 +135,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
|
|||
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
|
||||
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
|
||||
|
||||
ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue