mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Refactor slideshow to use new player creation
This commit is contained in:
parent
271cb0ca20
commit
fc5babac45
2 changed files with 464 additions and 435 deletions
|
|
@ -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.isDpad
|
||||||
import com.github.damontecres.wholphin.ui.playback.isEnterKey
|
import com.github.damontecres.wholphin.ui.playback.isEnterKey
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
@ -88,413 +89,429 @@ fun SlideshowPage(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val loading by viewModel.loading.collectAsState()
|
||||||
|
|
||||||
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
when (val st = loading) {
|
||||||
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
is LoadingState.Error -> {
|
||||||
val position by viewModel.position.observeAsState(0)
|
ErrorMessage(st, modifier)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val rotateAnimation: Float by animateFloatAsState(
|
LoadingState.Loading,
|
||||||
targetValue = rotation,
|
LoadingState.Pending,
|
||||||
label = "image_rotation",
|
-> {
|
||||||
)
|
LoadingPage(modifier)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if (yFactor != 0) {
|
|
||||||
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun zoom(factor: Float) {
|
LoadingState.Success -> {
|
||||||
if (factor < 0) {
|
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
||||||
val diffFactor = factor / (zoomFactor - 1f)
|
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
||||||
// zooming out
|
val position by viewModel.position.observeAsState(0)
|
||||||
val panXDiff = abs(panX * diffFactor)
|
val pager by viewModel.pager.observeAsState()
|
||||||
val panYDiff = abs(panY * diffFactor)
|
val imageState by viewModel.image.observeAsState()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(imageState) {
|
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||||
reset(true)
|
val isZoomed = zoomFactor * 100 > 102
|
||||||
}
|
var rotation by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val player = viewModel.player
|
var showOverlay by rememberSaveable { mutableStateOf(false) }
|
||||||
val presentationState = rememberPresentationState(player)
|
var showFilterDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
LaunchedEffect(slideshowActive) {
|
var panX by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
player.repeatMode =
|
var panY by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
|
||||||
}
|
|
||||||
|
|
||||||
var longPressing by remember { mutableStateOf(false) }
|
val slideshowControls =
|
||||||
|
object : SlideshowControls {
|
||||||
|
override fun startSlideshow() {
|
||||||
|
showOverlay = false
|
||||||
|
viewModel.startSlideshow()
|
||||||
|
}
|
||||||
|
|
||||||
val contentModifier =
|
override fun stopSlideshow() {
|
||||||
Modifier
|
viewModel.stopSlideshow()
|
||||||
.clickable(
|
}
|
||||||
interactionSource = null,
|
}
|
||||||
indication = null,
|
|
||||||
onClick = {
|
val rotateAnimation: Float by animateFloatAsState(
|
||||||
showOverlay = !showOverlay
|
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(
|
val slideshowState by viewModel.slideshow.collectAsState()
|
||||||
modifier =
|
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||||
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
|
val focusRequester = remember { FocusRequester() }
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> {
|
LaunchedEffect(Unit) {
|
||||||
if (!viewModel.nextImage()) {
|
focusRequester.tryRequestFocus()
|
||||||
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 -> {
|
val density = LocalDensity.current
|
||||||
LoadingPage(modifier)
|
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 -> {
|
fun pan(
|
||||||
imageState?.let { imageState ->
|
xFactor: Int,
|
||||||
if (imageState.image.data.mediaType == MediaType.VIDEO) {
|
yFactor: Int,
|
||||||
LaunchedEffect(imageState.id) {
|
) {
|
||||||
val mediaItem =
|
if (xFactor != 0) {
|
||||||
MediaItem
|
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
|
||||||
.Builder()
|
}
|
||||||
.setUri(imageState.url)
|
if (yFactor != 0) {
|
||||||
.build()
|
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
|
||||||
player.setMediaItem(mediaItem)
|
}
|
||||||
player.repeatMode =
|
}
|
||||||
if (slideshowState.enabled) {
|
|
||||||
Player.REPEAT_MODE_OFF
|
fun zoom(factor: Float) {
|
||||||
} else {
|
if (factor < 0) {
|
||||||
Player.REPEAT_MODE_ONE
|
val diffFactor = factor / (zoomFactor - 1f)
|
||||||
}
|
// zooming out
|
||||||
player.prepare()
|
val panXDiff = abs(panX * diffFactor)
|
||||||
player.play()
|
val panYDiff = abs(panY * diffFactor)
|
||||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
if (DEBUG) {
|
||||||
}
|
Timber.d(
|
||||||
LifecycleStartEffect(Unit) {
|
"zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff",
|
||||||
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(
|
if (panX > 0f) {
|
||||||
Modifier
|
panX -= panXDiff
|
||||||
.matchParentSize()
|
} else if (panX < 0f) {
|
||||||
.background(Color.Black),
|
panX += panXDiff
|
||||||
)
|
}
|
||||||
}
|
if (panY > 0f) {
|
||||||
} else {
|
panY -= panYDiff
|
||||||
val colorFilter =
|
} else if (panY < 0f) {
|
||||||
remember(imageState.id, imageFilter) {
|
panY += panYDiff
|
||||||
if (imageFilter.hasImageFilter()) {
|
}
|
||||||
ColorMatrixColorFilter(imageFilter.colorMatrix)
|
}
|
||||||
} else {
|
zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f)
|
||||||
null
|
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
|
if (it.type != KeyEventType.KeyUp) {
|
||||||
// TODO
|
result = false
|
||||||
val showLoadingThumbnail = true
|
} else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) {
|
||||||
SubcomposeAsyncImage(
|
// 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 =
|
modifier =
|
||||||
contentModifier
|
contentModifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.graphicsLayer {
|
.background(AppColors.TransparentBlack50),
|
||||||
scaleX = zoomAnimation
|
onDismiss = { showOverlay = false },
|
||||||
scaleY = zoomAnimation
|
player = player,
|
||||||
translationX = panXAnimation
|
slideshowControls = slideshowControls,
|
||||||
translationY = panYAnimation
|
slideshowEnabled = slideshowState.enabled,
|
||||||
|
image = imageState,
|
||||||
val xTransform =
|
position = position,
|
||||||
(screenWidth - panXAnimation) / (screenWidth * 2)
|
count = pager?.size ?: -1,
|
||||||
val yTransform =
|
onClickItem = {},
|
||||||
(screenHeight - panYAnimation) / (screenHeight * 2)
|
onLongClickItem = {},
|
||||||
if (DEBUG) {
|
onZoom = ::zoom,
|
||||||
Timber.d(
|
onRotate = { rotation += it },
|
||||||
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
|
onReset = { reset(true) },
|
||||||
)
|
onShowFilterDialogClick = {
|
||||||
}
|
showFilterDialog = true
|
||||||
|
showOverlay = false
|
||||||
transformOrigin = TransformOrigin(xTransform, yTransform)
|
viewModel.pauseSlideshow()
|
||||||
}.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(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.PlaybackEffect
|
||||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
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.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
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.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -77,9 +79,8 @@ class SlideshowViewModel
|
||||||
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
|
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val player by lazy {
|
lateinit var player: Player
|
||||||
playerFactory.createVideoPlayer()
|
private set
|
||||||
}
|
|
||||||
|
|
||||||
private var saveFilters = true
|
private var saveFilters = true
|
||||||
|
|
||||||
|
|
@ -104,6 +105,8 @@ class SlideshowViewModel
|
||||||
private val _image = MutableLiveData<ImageState>()
|
private val _image = MutableLiveData<ImageState>()
|
||||||
val image: LiveData<ImageState> = _image
|
val image: LiveData<ImageState> = _image
|
||||||
|
|
||||||
|
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||||
|
|
||||||
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
||||||
private val _imageFilter = MutableLiveData(VideoFilter())
|
private val _imageFilter = MutableLiveData(VideoFilter())
|
||||||
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
||||||
|
|
@ -113,58 +116,67 @@ class SlideshowViewModel
|
||||||
init {
|
init {
|
||||||
addCloseable {
|
addCloseable {
|
||||||
screensaverService.keepScreenOn(false)
|
screensaverService.keepScreenOn(false)
|
||||||
player.removeListener(this@SlideshowViewModel)
|
if (this@SlideshowViewModel::player.isInitialized) {
|
||||||
player.release()
|
player.removeListener(this@SlideshowViewModel)
|
||||||
}
|
player.release()
|
||||||
player.addListener(this@SlideshowViewModel)
|
}
|
||||||
viewModelScope.launchIO {
|
}
|
||||||
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences
|
viewModelScope.launchIO {
|
||||||
slideshowDelay =
|
try {
|
||||||
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
|
val appPreferences = userPreferencesService.getCurrent().appPreferences
|
||||||
?: AppPreference.SlideshowDuration.defaultValue
|
val playerCreation =
|
||||||
// val album =
|
playerFactory.createVideoPlayer(
|
||||||
// api.userLibraryApi
|
backend = PlayerBackend.EXO_PLAYER,
|
||||||
// .getItem(
|
appPreferences.playbackPreferences,
|
||||||
// itemId = slideshowSettings.parentId,
|
)
|
||||||
// ).content
|
player = playerCreation.player
|
||||||
// .let { BaseItem(it, false) }
|
player.addListener(this@SlideshowViewModel)
|
||||||
// this@SlideshowViewModel.album.setValueOnMain(album)
|
|
||||||
val includeItemTypes =
|
val photoPrefs = appPreferences.photoPreferences
|
||||||
if (photoPrefs.slideshowPlayVideos) {
|
slideshowDelay =
|
||||||
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
|
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
|
||||||
} else {
|
?: AppPreference.SlideshowDuration.defaultValue
|
||||||
listOf(BaseItemKind.PHOTO)
|
val includeItemTypes =
|
||||||
}
|
if (photoPrefs.slideshowPlayVideos) {
|
||||||
val request =
|
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
|
||||||
slideshowSettings.filter.filter.applyTo(
|
} else {
|
||||||
GetItemsRequest(
|
listOf(BaseItemKind.PHOTO)
|
||||||
parentId = slideshowSettings.parentId,
|
}
|
||||||
includeItemTypes = includeItemTypes,
|
val request =
|
||||||
fields = PhotoItemFields,
|
slideshowSettings.filter.filter.applyTo(
|
||||||
recursive = true,
|
GetItemsRequest(
|
||||||
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
|
parentId = slideshowSettings.parentId,
|
||||||
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
includeItemTypes = includeItemTypes,
|
||||||
),
|
fields = PhotoItemFields,
|
||||||
)
|
recursive = true,
|
||||||
serverRepository.currentUser.value?.let { user ->
|
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
|
||||||
val filter =
|
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||||
playbackEffectDao
|
),
|
||||||
.getPlaybackEffect(
|
)
|
||||||
user.rowId,
|
serverRepository.currentUser.value?.let { user ->
|
||||||
slideshowSettings.parentId,
|
val filter =
|
||||||
BaseItemKind.PHOTO_ALBUM,
|
playbackEffectDao
|
||||||
)?.videoFilter
|
.getPlaybackEffect(
|
||||||
if (filter != null) {
|
user.rowId,
|
||||||
Timber.v("Got filter for album %s", slideshowSettings.parentId)
|
slideshowSettings.parentId,
|
||||||
albumImageFilter = filter
|
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() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue