diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5f86522..22bd0deb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,7 +231,6 @@ 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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index f665da1c..55b2819e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -251,7 +251,7 @@ suspend fun upgradeApp( } } - if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) { + if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { appPreferences.updateData { it.updateScreensaverPreferences { startDelay = ScreensaverPreference.DEFAULT_START_DELAY diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index a5d6fc78..425ab94d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -10,28 +10,22 @@ 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 androidx.media3.extractor.DefaultExtractorsFactory +import com.github.damontecres.wholphin.preferences.AppPreference 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 @@ -52,17 +46,71 @@ 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, - ): PlayerCreation { + ): Player { 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, @@ -77,9 +125,8 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled - val directPlayAss = prefs.overrides.directPlayAss val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") + Timber.v("extensions=$extensions") val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -87,42 +134,17 @@ 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) - .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(renderersFactory) - .build() - .apply { - assHandler?.init(this) - } + .setRenderersFactory( + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() } } currentPlayer = newPlayer - return PlayerCreation(newPlayer, assHandler) + return newPlayer } } @@ -135,11 +157,6 @@ 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index 5d73ec21..c81893b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -161,27 +161,32 @@ class ScreensaverService if (pager.isEmpty()) { emit(null) } else { + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds while (true) { - val item = pager.getBlocking(index) - Timber.v("Next index=%s, item=%s", index, item?.id) - if (item != null) { - val backdropUrl = - if (item.type == BaseItemKind.PHOTO) { - api.libraryApi.getDownloadUrl(item.id) - } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - } - val title = - if (item.type == BaseItemKind.PHOTO) { - item.data.premiereDate?.let { - formatDate(it.toLocalDate()) + try { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } - } else { - item.title - } - val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) - if (backdropUrl != null) { - try { + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { context.imageLoader .enqueue( ImageRequest @@ -191,18 +196,17 @@ class ScreensaverService ).job .await() emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) - } catch (_: CancellationException) { - break + delay(duration) } - val duration = - userPreferencesService - .getCurrent() - .appPreferences - .interfacePreferences.screensaverPreference.duration.milliseconds - delay(duration) } + } catch (_: CancellationException) { + break + } catch (ex: Exception) { + Timber.e(ex, "Error fetching next item") + delay(duration) } index++ + if (index > pager.lastIndex) index = 0 } } }.flowOn(Dispatchers.Default).cancellable() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index c7a4bf10..3b9958b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -1,8 +1,5 @@ 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 @@ -45,18 +42,16 @@ 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 @@ -88,7 +83,6 @@ 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 @@ -133,7 +127,8 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - playerState = playerState!!, + player = playerState!!.player, + playerBackend = playerState!!.backend, preferences = preferences, destination = destination, viewModel = viewModel, @@ -146,15 +141,13 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - playerState: PlayerState, + player: Player, + playerBackend: PlayerBackend, 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 @@ -327,14 +320,10 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { - var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier.onSizeChanged { - playerSurfaceSize = it - }, + modifier = scaledModifier, ) if (presentationState.coverSurface) { Box( @@ -427,7 +416,7 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( @@ -438,42 +427,12 @@ 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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 91bdf485..505f44ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -74,7 +74,6 @@ 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 @@ -221,8 +220,7 @@ 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, @@ -241,14 +239,13 @@ class PlaybackViewModel disconnectPlayer() } - val playerCreation = + player = playerFactory.createVideoPlayer( playerBackend, preferences.appPreferences.playbackPreferences, ) - this.player = playerCreation.player currentPlayer.update { - PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) + PlayerState(player, playerBackend) } configurePlayer() } @@ -1444,7 +1441,6 @@ class PlaybackViewModel data class PlayerState( val player: Player, val backend: PlayerBackend, - val assHandler: AssHandler?, ) data class MediaSegmentState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 3b57017e..f35bc102 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -67,7 +67,6 @@ 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 @@ -89,429 +88,413 @@ fun SlideshowPage( ), ) { val context = LocalContext.current - val loading by viewModel.loading.collectAsState() - when (val st = loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) + 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() + } } - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(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) } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } - 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() + 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 + } + } - 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) } + 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 + } - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } + var longPressing by remember { mutableStateOf(false) } - 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", + val contentModifier = + Modifier + .clickable( + interactionSource = null, + indication = null, + onClick = { + showOverlay = !showOverlay + }, ) - val slideshowState by viewModel.slideshow.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) + 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 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) { - 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 - } - } - - 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 (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() - }, - ) + // 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 } } - } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - imageState?.let { imageState -> - ImageOverlay( + 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 - .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() + .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(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() + }, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index b86814ac..992659ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -16,7 +16,6 @@ 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 @@ -31,7 +30,6 @@ 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 @@ -79,8 +77,9 @@ class SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel } - lateinit var player: Player - private set + val player by lazy { + playerFactory.createVideoPlayer() + } private var saveFilters = true @@ -105,8 +104,6 @@ class SlideshowViewModel private val _image = MutableLiveData() val image: LiveData = _image - val loading = MutableStateFlow(LoadingState.Pending) - val loadingState = MutableLiveData(ImageLoadingState.Loading) private val _imageFilter = MutableLiveData(VideoFilter()) val imageFilter = ThrottledLiveData(_imageFilter, 500L) @@ -116,67 +113,58 @@ class SlideshowViewModel init { addCloseable { screensaverService.keepScreenOn(false) - if (this@SlideshowViewModel::player.isInitialized) { - player.removeListener(this@SlideshowViewModel) - player.release() - } + player.removeListener(this@SlideshowViewModel) + player.release() } + player.addListener(this@SlideshowViewModel) 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 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 } - 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() } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000..327f969b --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,131 @@ + + + حول + التسجيلات النشطة + إضافة للمفضلة + إضافة خادم + إضافة مستخدم + الصوت + مكان الميلاد + معدل البت + وُلد + إلغاء التسجيل + إلغاء تسجيل المسلسل + إلغاء + الفصول + اختيار %1$s + مسح ذاكرة التخزين المؤقت للصور + مجموعة + المجموعات + تجاري + تقييم المجتمع + حدث خطأ! اضغط على الزر لإرسال السجلات إلى خادمك. + تأكيد + تابع المشاهدة + تقييم النقاد + %.2f ثانية + الافتراضي + حذف + تُوفي + إخراج %1$s + المخرج + مُعطّل + الخوادم المكتشفة + + تنزيل… + مُفعّل + ينتهي عند %1$s + أدخل عنوان IP للخادم أو رابط URL + أدخل عنوان الخادم + الحلقات + خطأ في تحميل المجموعة %1$s + خارجي + المفضلة + الحجم + إجبارية + الأنواع + انتقل إلى المسلسل + انتقل إلى + إخفاء عناصر التحكم في التشغيل + إخفاء معلومات تصحيح الأخطاء + إخفاء + الرئيسية + فوري + المقدمة + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + المكتبة + معلومات الترخيص + تلفزيون مباشر + تحميل… + هل تريد تعيين المسلسل بأكمله كمُشاهَد؟ + هل تريد تعيين المسلسل بأكمله كغير مشاهد؟ + تعيين كغير مشاهد + تعيين كمشاهد + الحد الأقصى لمعدل البت + المزيد من هذا القبيل + المزيد + + الأفلام + + + + + + + الاسم + التالي + لا توجد بيانات + لا توجد نتائج + لم يتم العثور على خوادم + لا توجد تسجيلات مجدولة + لا يوجد تحديث متاح + لا شيء + الترجمات الإجبارية فقط + خاتمة + المسار + + الأشخاص + + + + + + + عدد مرات التشغيل + تشغيل من هنا + تشغيل + إظهار معلومات التشغيل التفصيلية + سرعة التشغيل + التشغيل + قائمة التشغيل + قوائم التشغيل + معاينة + إعدادات ملف تعريف المستخدم + قائمة الانتظار + ملخص ما سبق + أُضيف مؤخرًا في %1$s + أُضيف مؤخرًا + التسجيلات الأخيرة + أحدث الإصدارات + مقترح + تسجيل البرنامج + تسجيل المسلسل + إزالة من المفضلة + إعادة تشغيل + استئناف + حفظ + + بحث + جارٍ البحث… + البحث الصوتي + جارٍ البدء + تحدث للبحث + جارٍ المعالجة + اضغط رجوع للإلغاء + خطأ في تسجيل الصوت + خطأ في التعرف على الصوت + مطلوب إذن الوصول للميكروفون + خطأ في الشبكة + انتهت مهلة الشبكة + لم يتم التعرف على الكلام + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 013f5ccd..2a8884ba 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -199,12 +199,12 @@ Kursivschrift Laufzeit - Theme Songs - + Titelsong + Titelsongs - Theme Videos - + Titelvideo + Titelvideos Clip @@ -224,11 +224,11 @@ Shorts - + Kurzvideos Trifft nur auf Serien zu - Direct play ASS subtitles - Direct play PGS subtitles + Libass für ASS Untertitel verwenden + Direktwiedergabe PGS Untertitel Immer zu Stereo heruntermischen FFmpeg decoder Modul verwenden Standard Skalierung @@ -251,8 +251,8 @@ Schriftgröße Schriftfarbe Schriftart Deckkraft - Rand Stil - Rand Farbe + Stil der Umrandung + Farbe der Umrandung Hintergrund Deckktraft Hintergrund Stil Untertitel Stil @@ -329,7 +329,7 @@ Login via Server Drücke die mittlere Taste zur Bestätigung Größe des Festplatten-Caches für Bilder (MB) - Besetzung & Mitwirkende + Container Codec Level @@ -432,4 +432,122 @@ Videos während der Diashow abspielen Maximale Tage in \"Als nächstes\" Quick Connect + + %s Minute + %s Minuten + + Interlaced + NAL + Gewähre anderen Geräten Zugriff auf deinen Account + Eingabe Quick Connect Code + Keine Beschränkung + Reihe hinzufügen + Genres in %1$s + kürzlich veröffentlicht in %1$s + Höhe + Auf alle Reihen anwenden + Reihe hinzufügen für %1$s + Überschreibe Einstellungen auf dem Server? + Überschreibe lokale Einstellungen? + Vorschläge für %1$s + Sende Media Info Log zum Server + Anpassung Homepage + Für Episoden + Vorlagen um schnell alle Reihen zu gestalten + Wähle Reihen und Bilder auf der Homepage + Starte anschließend + Serien Vorschaubilder + Episoden Vorschaubilder + Einloggen zum Seerr Server + Jellyfin User + Zeige Media Management Optionen + Größe für alle Karten reduzieren + Einstellungen gespeichert + Maximale Altersfreigabe + Keine Begrenzung + Ohne Altersbeschränkung + Ab %s Jahre + Bildschirmschoner + Bildschirmschoner-Einstellungen + Bildschirmschoner starten + Länge + Fotos + Arten einschließen + Für welche Arten von Einträgen sollen Bilder angezeigt werden + Angepasst + Zugeschnitten + Ausgefüllt + Ignorieren + Automatisch überspringen + Vor dem Überspringen nachfragen + FFmpeg nur verwenden, sofern keine anderen Decoder vorhanden sind + FFmpeg statt der vorhandenen Decoder verwenden + FFmpeg Decoder nie verwenden + Am Ende einer Playlist + Während der Credits/Outro + Weiß + Hellgrau + Dunkelgrau + Gelb + Cyan + Magenta + Umrandung + Schatten + MPV bevorzugen + ExoPlayer für die HDR Wiedergabe verwenden + Poster (2:3) + 16:9 + 4:3 + Quadrat (1:1) + Primär + Bild mit dynamischen Farben + API Schlüssel + Lokaler Benutzer + Suche %s + Design-Vorlagen + Animieren + Breite ausfüllen + Höhe ausfüllen + Wholphin Standard + Wholphin Kompakt + Niedrigste + Niedrig + mittel + Hoch + Voll + Lila + Orange + Schwarz + Nur Bild + Aktuell + Eingebauten Bildschirmschoner nutzen + Bist du sicher, dass du diesen Eintrag löschen möchtest? + Schauspieler + Komponist + Autor + Nebenrolle + Produzent + Dirigent + Liedtext-Schreiber + Künstler + %s als Favorit speichern + Startseite - Reihen + Vom Server Benutzerprofil laden + Im Server Benutzerprofil speichern + Vom Webclient laden + Größe für alle Karten erhöhen + Vorschaubilder benutzen + Es wurden keine Untertitel gefunden + Arrangeur + Ingenieur + Dunkelblau + Untertitel-Transparenz + Vorschaubild + Serien Vorschaubilder verwenden + + + Ersteller + Gestreckt + Zentriert + Mischer diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b4ef66da..93bf5c7c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -35,7 +35,7 @@ Nombre Sin resultados Ninguno - Outro + Créditos finales Personas Reproducir Reproducción @@ -80,7 +80,7 @@ Crear nueva lista de reproducción Agregar a la lista de reproducción Pausar con un clic - Presiona el centro del D-Pad para pausar/reproducir + Pulsa el botón central de la cruceta para reproducir/pausar Éxito Duración Extras @@ -264,8 +264,8 @@ Retroceder Comportamiento al saltar anuncios Avanzar - Comportamiento al saltar la intro - Comportamiento al saltar el outro + Comportamiento al saltar la cabecera + Comportamiento al saltar créditos finales Comportamiento al saltar avances Comportamiento al saltar resumen Actualización disponible @@ -291,8 +291,8 @@ Saltar anuncios Saltar avance Saltar resumen - Saltar outro - Saltar intro + Saltar créditos finales + Saltar cabecera Reproducido FIltrar Año @@ -485,8 +485,8 @@ Rellenar Ajustar al ancho Ajustar al alto - Miniaturas de las series - Miniaturas de los episodios + Miniatura de la serie + Miniatura del episodio Mínimo Bajo Medio @@ -534,4 +534,38 @@ No se encontraron subtítulos externos Actualidad + + %s minuto + %s minutos + %s minutos + + Comenzar después de + Animación + Clasificación por edad máxima + Para todas las edades + Menores de %s + Salvapantallas + Configuración de salvapantallas + Iniciar salvapantallas + Duración + Fotos + Incluir tipos + Qué tipos de elementos mostrar + Usar salvapantallas de la aplicación + Sin límite + Intérprete + Compositor + Guionista + Estrella invitada + Productor + Director musical + Letrista + Arreglista + Ingeniero de sonido + Ingeniero de mezcla + Creador + Artista + ¿Estás seguro de que quieres eliminar este elemento? + Mostrar opciones de gestión de medios + Buscar %s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 538fd523..74e702c8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -221,8 +221,8 @@ Opacité de la police Style des contours Couleur des contours - Opacité de l’arrière-plan - Style de l’arrière-plan + Opacité de l\'arrière-plan + Style de l\'arrière-plan Style des sous-titres Couleur de fond Réinitialiser @@ -234,10 +234,10 @@ Options ExoPlayer Passer le segment Passer les publicités - Passer l’aperçu + Passer l\'aperçu Passer le récapitulatif Passer le générique de fin - Passer l’intro + Passer l\'intro Lu Filtre Année @@ -275,7 +275,7 @@ Lire la musique de thème Revenir en arrière à la reprise de la lecture Comportement de saut des publicités - Comportement de saut de l’intro + Comportement de saut de l\'intro Comportement de saut du générique de fin Comportement de saut des aperçus Comportement de saut du récapitulatif @@ -289,7 +289,7 @@ - + Calendrier du DVR @@ -480,4 +480,89 @@ Préréglages intégrés pour styliser rapidement toutes les lignes Choisissez des lignes et des images sur la page d\'accueil Favori %s + + %s minute + %s minutes + %s minute + + Démarrer après + Animer + Classification par âge maximale + Pas de maximum + Pour tous les âges + Jusqu\'à l\'âge de %s + Écran de veille + Paramètres de l\'économiseur d\'écran + Lancer l\'économiseur d\'écran + Durée + Photos + Inclure les types + Quels types d\'éléments afficher des images pour + Ajustement + Recadrer + Remplir + Largeur de remplissage + Hauteur de remplissage + Wholphin Par défaut + Wholphin Compact + Images miniatures de la série + Images miniatures de l\'épisode + Le plus bas + Faible + Moyen + Haute + Complet + Violet + Orange + Bleu gras + Noir + Ignorer + Passer automatiquement + Demander à passer + N\'utilisez FFmpeg que si aucun décodeur intégré n\'existe + Préférez utiliser FFmpeg plutôt que les décodeurs intégrés + N\'utilisez jamais les décodeurs FFmpeg + À la fin de la lecture + Pendant le générique de fin/outro + Blanc + Gris clair + Gris foncé + Jaune + Cyan + Magenta + Contour + Ombre + Envelopper + Encadré + Préférer MPV + Utiliser ExoPlayer pour la lecture HDR + Affiche (2:3) + 16:9 + 4:3 + Carré (1:1) + Primaire + Miniature + Image avec couleur dynamique + Image uniquement + Entrez l\'URL et la clé API + Clé API + Se connecter au serveur Seerr + Utilisateur Jellyfin + Utilisateur local + Rechercher et télécharger des sous-titres + Aucun sous-titre distant n\'a été trouvé + Présent + Utiliser l\'économiseur d\'écran intégré + Acteur + Compositeur + Écrivain + Invité spécial + Producteur + Conducteur + Parolier + Arrangeur + Ingénieur + Mélangeur + Créateur + Artiste diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 356732cd..23e81de6 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -242,8 +242,8 @@ Ukuran font Warna font Transparansi font - Jenis garis tepi - Warna garis tepi + Jenis pinggiran + Warna pinggiran Transparansi latar belakang Gaya latar belakang Gaya subtitel @@ -318,7 +318,7 @@ Membutuhkan PIN untuk profil PIN akan dihapus Opsi tampilan - Ukuran garis tepi + Ukuran pinggiran Otomatis Gunakan nama pengguna/kata sandi Batas tepi @@ -446,4 +446,79 @@ Preset bawaan untuk kustomisasi cepat semua baris Pilih baris dan gambar di halaman beranda Favoritkan %s + Artis + + %s menit + + Mulai setelah + Animasi + Rating usia maks + Tidak ada maks + Untuk semua umur + Maks. usia %s + Screensaver + Pengaturan screensaver + Mulai screensaver + Durasi + Foto + Sertakan jenis + Tampilkan gambar untuk jenis item + Wholphin Bawaan + Wholphin Ringkas + Gambar Thumbnail Serial + Gambar Thumbnail Episode + Terendah + Rendah + Sedang + Tinggi + Penuh + Ungu + Oranye + Biru Tegas + Hitam + Abaikan + Lewati otomatis + Tanya sebelum melewati + Gunakan FFmpeg hanya jika dekoder bawaan tidak ada + Utamakan FFmpeg daripada dekoder bawaan + Jangan pernah gunakan dekoder FFmpeg + Di akhir pemutaran + Selama kredit akhir/outro + Putih + Abu-abu muda + Abu-abu tua + Kuning + Sian + Magenta + Garis luar + Utamakan MPV + Gunakan ExoPlayer untuk pemutaran HDR + Poster (2:3) + 16:9 + 4:3 + Persegi (1:1) + Utama + Thumbnail + Gambar dengan warna dinamis + Hanya gambar + + API Key + Masuk ke server Seerr + Pengguna Jellyfin + Pengguna lokal + + Subtitel remote tidak ditemukan + Tayang + Gunakan screensaver dalam aplikasi + Aktor + Komposer + Penulis + Bintang tamu + Produser + Konduktor + Penulis lirik + Aransemen + Teknisi + Mixer + Kreator diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a3764b96..3142001e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -480,4 +480,92 @@ Usa immagine delle serie Scegli righe e immagini nella home page Preferito %s + Adatta + Ritaglia + Riempi + Riempi larghezza + Riempi altezza + Wholphin predefinito + Wholphin Compatto + Immagini miniatura serie + Immagini miniatura episodio + Minimo + Basso + Medio + Alto + Massimo + Viola + Arancione + Blu intenso + Nero + Ignora + Salta automaticamente + Chiedi se saltare + Usa FFmpeg solo se non esiste un decodificatore integrato + Preferisci FFmpeg ai decoder integrati + Non usare mai i decoder FFmpeg + Alla fine della riproduzione + Durante i titoli di coda + Bianco + Grigio chiaro + Grigio scuro + Giallo + Ciano + Magenta + Contorno + Ombra + Avvolgi + Con riquadro + Preferisci MVP + Usa ExoPlayer per la riproduzione HDR + Copertina (2:3) + 16:9 + 4:3 + Quadrato (1:1) + Principale + Miniatura + Immagine con colore dinamico + Solo immagine + + Chiave API + Accedi al server Seerr + Utente Jellyfin + Utente locale + + Nessun sottotitolo remoto trovato + In corso + + %s minuto + %s minuti + %s minuti + + Inizia dopo + Anima + Età massima + Per tutte le età + Fino a %s anni + Salvaschermo + Impostazioni salvaschermo + Avvia salvaschermo + Durata + Foto + Includi tipi + Quali tipi di elementi mostrare nelle immagini + Nessun massimo + Usa salvaschermo dell\'app + Compositore + Sceneggiatore + Attore + Produttore + Ospite speciale + Direttore d’orchestra + Paroliere + Arrangiatore + Ingegnere del suono + Tecnico di mixaggio + Artista + Creatore + Cerca %s + Sei sicuro di voler cancellare questo elemento? + Mostra opzioni di gestione dei media diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 76bf308f..ed794d85 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -65,8 +65,8 @@ Mais Filmes - - + + Nome A Seguir @@ -81,8 +81,8 @@ Caminho Pessoas - - + + Contagem de Reproduções Reproduzir a partir daqui @@ -152,8 +152,8 @@ Trailer Trailers - - + + Agenda do DVR Guia @@ -161,8 +161,8 @@ Temporadas Programas de TV - - + + Interface Desconhecido @@ -190,53 +190,53 @@ Extras Outro - - + + Por Trás das Cenas - - + + Músicas Tema - - + + Vídeos Tema - - + + Clipes - - + + Cenas Deletadas - - + + Entrevistas - - + + Cenas - - + + Amostras - - + + Curtas - - + + Favoritar %s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1fb5fbb5..6d8ac4e0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -121,7 +121,7 @@ Mais Votados Não Assistidos Trailer - Trailers + Trailer Trailers Trailers @@ -463,8 +463,8 @@ Aplicar a todas as linhas Personalizar página inicial Linhas da página inicial - Carregar do servidor - Gravar para o servidor + Carregar do perfil de utilizador do servidor + Guardar no perfil de utilizador do servidor Carregar a partir do cliente web Usar imagem da série Adicionar linha para %1$s @@ -479,4 +479,90 @@ Predefinições embarcadas para definir todas as linhas rapidamente Escolha as linhas e imagens na página inicial Diminuir o tamanho de todos os cartões + Favoritos %s + + %s minuto + %s minutos + %s minutos + + Começar depois + Animado + Idade máxima + Sem máximo + Para todas as idades + Até a idade %s + Proteção de ecrã + Definições de protecção de ecrã + Iniciar protecção de ecrã + Duração + Fotos + Incluir tipos + Que tipos de itens devem mostrar imagens + Ajustar + Recortar + Preencher + Preencher largura + Preencher altura + Padrão do Wholphin + Wholphin Compacto + Imagens em miniatura da série + Imagens em miniatura do episódio + Mínimo + Baixo + Médio + Alto + Máximo + Roxo + Laranja + Azul intenso + Preto + Ignorar + Saltar automaticamente + Pedir para saltar + Usa FFmpeg apenas se não houver um descodificador integrado + Prefere o FFmpeg aos descodificadores integrados + Nunca usar os descodificadores FFmpeg + No final da reprodução + Durante os créditos finais + Branco + Cinzento claro + Cinzento escuro + Amarelo + Ciano + Magenta + Contorno + Sombra + Envolver + Com caixa + Preferir MPV + Usar ExoPlayer para reprodução HDR + Poster (2:3) + 16:9 + 4:3 + Quadrado (1:1) + Primária + Miniatura + Imagem com cor dinâmica + Apenas imagem + + Chave API + Iniciar sessão no servidor Seerr + Utilizador Jellyfin + Utilizador local + + Não foram encontradas legendas remotas + Em curso + Usar protetor de ecrã da aplicação + Ator + Compositor + Argumentista + Convidado especial + Produtor + Director musicar + Letrista + Arranjador + Engenheiro de som + Técnico de mixagem + Criador + Artista diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 195565df..dfb498ba 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -146,9 +146,9 @@ Trailer Trailery - - - + + + DVR rozvrh Sprievodca @@ -156,9 +156,9 @@ Série Seriály - - - + + + Rozhranie Neznámy @@ -186,69 +186,69 @@ Bonusový materiál Ostatné - - - + + + Zo zákulisia - - - + + + Tématické piesne - - - + + + Tématické videá - - - + + + Klipy - - - + + + Vymazané scény - - - + + + Rozhovory - - - + + + Scény - - - + + + Vzorky - - - + + + Krátkometrážne filmy - - - + + + Krátke videá - - - + + + Obľúbené %s diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b7d13882..cef8bb90 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -277,4 +277,49 @@ Skriv in PIN Logga in automatiskt Logga in via server + Ange server-adress + Nätverksfel + Okänt fel + + %s minut + %s minuter + + Fet stil + Dolby Atmos + Avbryt ändringar? + Kräv PIN för profilen + Upplösning + Språk + Ja + Nej + Upprepa + Lösenord + Användarnamn + Skapare + Ingenjör + Skådespelare + Kompositör + Författare + Producent + Endast bild + Vit + Ljusgrå + Mörkgrå + Gul + Lila + Svart + För alla åldrar + Upp till %s år + Skärmsläckare + Inställningar Skärmsläckare + Starta skärmsläckare + Bilder + Inställningar sparade + Rotera vänster + Rotera höger + Zooma in + Zooma ut + Ingen gräns + Lägg till rad + Tryck bakåt för att avbryta diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index aec35e54..213ef136 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -256,7 +256,7 @@ Güncellemeleri kontrol et Yalnızca diziler için geçerlidir - ASS altyazıları doğrudan oynat + ASS altyazıları için libass kullan PGS altyazıları doğrudan oynat Her zaman Stereo\'ya dönüştür FFmpeg kod çözücü kullan @@ -472,4 +472,91 @@ Tüm satırları hızlıca stilize etmek için hazır ayarlar Favori %s Ana sayfadaki satırları ve görselleri seçin + Sığdır + Kırp + Doldur + Genişliği Doldur + Yüksekliği Doldur + Wholphin Varsayılan + Wholphin Kompakt + Dizi Küçük Resimleri + Bölüm Küçük Resimleri + En Düşük + Düşük + Orta + Yüksek + Tam + Mor + Turuncu + Koyu Mavi + Siyah + Yoksay + Otomatik Atla + Atlamak için sor + Dahili çözücü yoksa FFmpeg kullan + Dahili çözücüler yerine FFmpeg\'i tercih et + FFmpeg çözücülerini asla kullanma + Oynatma sonunda + Kapanış bölümünde / Jenerik sırasında + Beyaz + Açık Gri + Koyu Gri + Sarı + Camgöbeği + Macenta + Kenarlık + Gölge + Kapla + Kutu içinde + MPV\'yi tercih et + HDR oynatma için ExoPlayer kullan + Poster (2:3) + 16:9 + 4:3 + Kare (1:1) + Birincil + Küçük Resim + Dinamik renkli görsel + Sadece görsel + + API Anahtarı + Seerr sunucusuna giriş yap + Jellyfin kullanıcısı + Yerel kullanıcı + + Uzak altyazı bulunamadı + Mevcut + + %s dakika + %s dakika + + Başlama süresi + Oynatma Süresi + Maksimum yaş sınırı + Sınır yok + Genel İzleyici + %s yaşa kadar + Ekran koruyucu + Ekran koruyucu ayarları + Ekran koruyucuyu başlat + Süre + Fotoğraflar + İçerik türleri + Görsel gösterilecek öğe türleri + Uygulama içi ekran koruyucuyu kullan + Aktör + Besteci + Yazar + Konuk sanatçı + Yapımcı + Orkestra şefi + Söz yazarı + Aranjör + Mühendis + Karıştırıcı + Yaratıcı + Sanatçı + Bu öğeyi silmek istediğinizden emin misiniz? + Medya yönetim seçeneklerini göster + Ara %s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 28875ea9..3134076d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -150,7 +150,7 @@ 字体 背景 成功 - 年龄分级 + 内容分级 播放时长 附加内容 @@ -208,7 +208,7 @@ 检查更新 仅适用于电视剧 - 直接播放 ASS 字幕 + 使用 libass 处理 ASS 字幕 直接播放 PGS 字幕 始终降混为立体声 使用 FFmpeg 解码模块 @@ -446,4 +446,90 @@ 可快速设置所有行样式的内置预设 选择首页上的行和图片 收藏的 %s + 适应 + 裁剪 + 填充 + 填充宽度 + 填充高度 + Wholphin 默认 + Wholphin 紧凑 + 剧集缩略图 + 分集缩略图 + 紫色 + 橙色 + 深蓝色 + 黑色 + 忽略 + 自动跳过 + 询问后跳过 + 仅在无内置解码器时使用 FFmpeg + 优先使用 FFmpeg 解码器而非内置解码器 + 从不使用 FFmpeg 解码器 + 播放结束时 + 播到片尾时 + 白色 + 浅灰色 + 深灰色 + 黄色 + 青色 + 紫红色 + 轮廓 + 阴影 + 优先使用 mpv + HDR 播放使用 ExoPlayer + 海报 (2:3) + 16:9 + 4:3 + 正方形 (1:1) + 主图 + 缩略图 + 动态配色图片 + 仅图片 + + API 密钥 + 登录 Seerr 服务器 + Jellyfin 用户 + 本地用户 + + 未找到远程字幕 + 最低音量 + 低音量 + 中等音量 + 高音量 + 最大音量 + 环绕式 + 框式 + 当前 + + %s 分钟 + + 动画 + 启动时间 + 最高年龄分级 + 无上限 + 所有年龄 + 最高至 %s 岁 + 屏幕保护程序 + 屏幕保护程序设置 + 启动屏幕保护程序 + 时长 + 照片 + 包含类型 + 要显示图片的项目类型 + 使用应用内屏幕保护程序 + 演员 + 作曲 + 编剧 + 客串 + 制片 + 指挥 + 作词 + 编曲 + 录音 + 混音 + 主创 + 艺人 + 显示媒体管理选项 + 是否确定要删除此项目? + 搜索 %s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6670d10e..5bf09d0f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -59,7 +59,7 @@ 指南 更新 版本 - 畫面比例 + 畫面縮放比例 影片 影片 觀看直播 @@ -104,7 +104,7 @@ 直接播放 ASS 字幕 直接播放 PGS 字幕 使用 FFmpeg 解碼模組 - 預設畫面比例 + 預設縮放比例 安裝更新 安裝版本 首頁每列項目上限 @@ -362,7 +362,7 @@ 移除 Seerr 伺服器 已新增 Seerr 伺服器 密碼 - 使用者名稱 + 帳號 URL 當前趨勢 即將上映的電影 @@ -427,18 +427,18 @@ 推薦的 %1$s 放大所有海報尺寸 縮小所有海報尺寸 - 使用橫向縮圖 + 使用橫式縮圖 新增一列 套用到所有列 首頁各列 為 %1$s 新增列項目 快速設定所有項目為內建預設樣式 - 選擇首頁要顯示的列項目及圖片樣式 + 選擇首頁要顯示的項目及圖片樣式 高度 自訂首頁 從伺服器載入設定 保存設定到伺服器 - 從網頁版載入 + 從網頁版載入設定 使用劇集圖片 覆蓋伺服器上的設定? 覆蓋本地設定? @@ -450,11 +450,11 @@ 拉伸填滿 填滿寬度 填滿高度 - 最低 - - 中等 - - 最大 + 最低音量 + 低音量 + 中等音量 + 高音量 + 最大音量 紫色 橘色 深藍色 @@ -472,10 +472,64 @@ 深灰色 黃色 青色 - 洋紅色 + 桃紅色 外框 陰影 貼合文字 長方底框 優先使用 MPV + 僅 HDR 使用 ExoPlayer 播放 + 我的最愛的 %s + Wholphin 預設樣式 + Wholphin 緊湊樣式 + 劇集縮圖樣式 + 單集縮圖樣式 + 海報 (2:3) + 16:9 + 4:3 + 正方形 (1:1) + 主封面 + 橫式縮圖 + 動態色彩背景 + 僅背景圖 + + API 金鑰 + 登入 Seerr 伺服器 + Jellyfin 帳號 + Seerr 帳號 + + 未找到遠端字幕 + 至今 + + %s 分鐘 + + 啟動時間 + 動態效果 + 年齡分級上限 + 不限制 + 適合所有年齡 + 最高至 %s 歲 + 螢幕保護程式 + 螢幕保護程式設定 + 立即開始 + 切換間隔 + 照片 + 包含類型 + 要顯示圖片的類型 + 使用內建螢幕保護程式 + 客串演員 + 演員 + 作曲 + 編劇 + 製作人 + 指揮 + 作詞 + 編曲 + 錄音師 + 混音師 + 創作者 + 演出者 + 確定要刪除此項目嗎? + 顯示媒體管理選項 + 搜尋 %s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3872fdef..6b508599 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,7 +312,7 @@ Check for updates Applies to TV Series only - Use libass for ASS subtitles + Direct play ASS subtitles Direct play PGS subtitles Always downmix to stereo Use FFmpeg decoder module diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4de2268..b60ca106 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,6 @@ 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" @@ -135,8 +134,6 @@ 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" }