diff --git a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt index 1b291098..5c81805d 100644 --- a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt +++ b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt @@ -15,15 +15,15 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import org.jellyfin.mobile.player.cast.Chromecast import org.jellyfin.mobile.player.cast.IChromecast -import org.jellyfin.mobile.setup.ConnectFragment -import org.jellyfin.mobile.webapp.WebViewFragment import org.jellyfin.mobile.player.ui.PlayerFragment +import org.jellyfin.mobile.setup.ConnectFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.SmartOrientationListener -import org.jellyfin.mobile.utils.isWebViewSupported import org.jellyfin.mobile.utils.extensions.replaceFragment +import org.jellyfin.mobile.utils.isWebViewSupported import org.jellyfin.mobile.webapp.RemotePlayerService +import org.jellyfin.mobile.webapp.WebViewFragment import org.koin.android.ext.android.inject import org.koin.androidx.fragment.android.setupKoinFragmentFactory import org.koin.androidx.viewmodel.ext.android.viewModel @@ -87,9 +87,11 @@ class MainActivity : AppCompatActivity() { is ServerState.Available -> { val currentFragment = findFragmentById(R.id.fragment_container) if (currentFragment !is WebViewFragment || currentFragment.server != state.server) { - replaceFragment(Bundle().apply { - putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server) - }) + replaceFragment( + Bundle().apply { + putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server) + } + ) } } } diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt index 35d865db..5afc17b8 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt @@ -16,18 +16,18 @@ import com.google.android.exoplayer2.util.Util import kotlinx.coroutines.channels.Channel import okhttp3.OkHttpClient import org.jellyfin.mobile.MainViewModel -import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder import org.jellyfin.mobile.bridge.ExternalPlayer -import org.jellyfin.mobile.setup.ConnectFragment -import org.jellyfin.mobile.webapp.WebViewFragment import org.jellyfin.mobile.player.audio.car.LibraryBrowser +import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder import org.jellyfin.mobile.player.interaction.PlayerEvent -import org.jellyfin.mobile.player.ui.PlayerFragment import org.jellyfin.mobile.player.source.MediaSourceResolver +import org.jellyfin.mobile.player.ui.PlayerFragment +import org.jellyfin.mobile.setup.ConnectFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.isLowRamDevice import org.jellyfin.mobile.webapp.RemoteVolumeProvider +import org.jellyfin.mobile.webapp.WebViewFragment import org.jellyfin.mobile.webapp.WebappFunctionChannel import org.koin.android.ext.koin.androidApplication import org.koin.androidx.fragment.dsl.fragment diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt index ea8fd77f..eb30e91b 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt @@ -11,7 +11,6 @@ import android.webkit.JavascriptInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.jellyfin.mobile.R -import org.jellyfin.mobile.webapp.WebViewFragment import org.jellyfin.mobile.settings.SettingsFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM @@ -28,11 +27,12 @@ import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE import org.jellyfin.mobile.utils.extensions.addFragment import org.jellyfin.mobile.utils.extensions.disableFullscreen import org.jellyfin.mobile.utils.extensions.enableFullscreen -import org.jellyfin.mobile.utils.requestDownload import org.jellyfin.mobile.utils.extensions.requireMainActivity +import org.jellyfin.mobile.utils.requestDownload import org.jellyfin.mobile.utils.runOnUiThread import org.jellyfin.mobile.webapp.RemotePlayerService import org.jellyfin.mobile.webapp.RemoteVolumeProvider +import org.jellyfin.mobile.webapp.WebViewFragment import org.jellyfin.mobile.webapp.WebappFunctionChannel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder @@ -191,10 +191,14 @@ class NativeInterface(private val fragment: WebViewFragment) : KoinComponent { @JavascriptInterface fun execCast(action: String, args: String) { - fragment.requireMainActivity().chromecast.execute(action, JSONArray(args), object : JavascriptCallback() { - override fun callback(keep: Boolean, err: String?, result: String?) { - webappFunctionChannel.call("""window.NativeShell.castCallback("$action", $keep, $err, $result);""") - } - }) + fragment.requireMainActivity().chromecast.execute( + action, + JSONArray(args), + object : JavascriptCallback() { + override fun callback(keep: Boolean, err: String?, result: String?) { + webappFunctionChannel.call("""window.NativeShell.castCallback("$action", $keep, $err, $result);""") + } + }, + ) } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 343c832d..4b23d3e0 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -29,25 +29,25 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jellyfin.mobile.BuildConfig import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL -import org.jellyfin.mobile.player.ui.DisplayPreferences import org.jellyfin.mobile.player.interaction.PlayerEvent import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper -import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.queue.QueueManager +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.ui.DisplayPreferences import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS import org.jellyfin.mobile.utils.applyDefaultAudioAttributes import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes +import org.jellyfin.mobile.utils.extensions.scaleInRange +import org.jellyfin.mobile.utils.extensions.width import org.jellyfin.mobile.utils.getVolumeLevelPercent import org.jellyfin.mobile.utils.getVolumeRange import org.jellyfin.mobile.utils.logTracks -import org.jellyfin.mobile.utils.extensions.scaleInRange import org.jellyfin.mobile.utils.seekToOffset import org.jellyfin.mobile.utils.setPlaybackState import org.jellyfin.mobile.utils.toMediaMetadata -import org.jellyfin.mobile.utils.extensions.width import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt index 2e031f2a..239a8667 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt @@ -36,13 +36,13 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.jellyfin.mobile.R -import org.jellyfin.mobile.player.cast.CastPlayerProvider -import org.jellyfin.mobile.player.cast.ICastPlayerProvider import org.jellyfin.mobile.app.ApiClientController import org.jellyfin.mobile.player.audio.car.LibraryBrowser import org.jellyfin.mobile.player.audio.car.LibraryPage -import org.jellyfin.mobile.utils.extensions.mediaUri +import org.jellyfin.mobile.player.cast.CastPlayerProvider +import org.jellyfin.mobile.player.cast.ICastPlayerProvider import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.extensions.mediaUri import org.jellyfin.mobile.utils.toast import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException @@ -142,7 +142,6 @@ class MediaService : MediaBrowserServiceCompat() { newPlayer = if (castPlayerProvider.isCastSessionAvailable) castPlayerProvider.get()!! else exoPlayer ) notificationManager.showNotificationForPlayer(currentPlayer) - } override fun onDestroy() { @@ -262,10 +261,11 @@ class MediaService : MediaBrowserServiceCompat() { } private fun setPlaybackError() { - mediaSession.setPlaybackState(PlaybackStateCompat.Builder().apply { - setState(PlaybackStateCompat.STATE_ERROR, 0, 1f) - setErrorMessage(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, getString(R.string.media_service_item_not_found)) - }.build()) + val errorState = PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_ERROR, 0, 1f) + .setErrorMessage(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, getString(R.string.media_service_item_not_found)) + .build() + mediaSession.setPlaybackState(errorState) } @Suppress("unused") diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt index 5fda5a9e..a64039e1 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt @@ -46,7 +46,7 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.* +import java.util.UUID @Suppress("TooManyFunctions") class LibraryBrowser( @@ -259,10 +259,12 @@ class LibraryBrowser( setTitle(context.getString(item.second)) if (item.first == LibraryPage.ALBUMS) { - setExtras(Bundle().apply { - putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - }) + setExtras( + Bundle().apply { + putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + ) } }.build() MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE) diff --git a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt index 39bcfe64..8c7def97 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt @@ -168,7 +168,6 @@ object CodecHelpers { else -> null } - private fun getAVCLevel(level: Int): String? = when (level) { CodecProfileLevel.AVCLevel1 -> "1" CodecProfileLevel.AVCLevel11 -> "11" diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt index 9e17dbd5..9fb6026e 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt @@ -20,10 +20,10 @@ import com.google.android.exoplayer2.Player import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.BuildConfig import org.jellyfin.mobile.MainActivity import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID @@ -114,15 +114,18 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom } if (receiverRegistered.compareAndSet(false, true)) { - context.registerReceiver(notificationActionReceiver, IntentFilter().apply { - addAction(Constants.ACTION_PLAY) - addAction(Constants.ACTION_PAUSE) - addAction(Constants.ACTION_REWIND) - addAction(Constants.ACTION_FAST_FORWARD) - addAction(Constants.ACTION_PREVIOUS) - addAction(Constants.ACTION_NEXT) - addAction(Constants.ACTION_STOP) - }) + context.registerReceiver( + notificationActionReceiver, + IntentFilter().apply { + addAction(Constants.ACTION_PLAY) + addAction(Constants.ACTION_PAUSE) + addAction(Constants.ACTION_REWIND) + addAction(Constants.ACTION_FAST_FORWARD) + addAction(Constants.ACTION_PREVIOUS) + addAction(Constants.ACTION_NEXT) + addAction(Constants.ACTION_STOP) + }, + ) } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index be057dcf..6863267a 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -28,22 +28,22 @@ import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ui.PlayerView import kotlinx.coroutines.launch import org.jellyfin.mobile.R -import org.jellyfin.mobile.utils.extensions.aspectRational -import org.jellyfin.mobile.utils.extensions.isLandscape -import org.jellyfin.mobile.player.interaction.PlayOptions import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.player.PlayerException import org.jellyfin.mobile.player.PlayerViewModel +import org.jellyfin.mobile.player.interaction.PlayOptions import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL import org.jellyfin.mobile.utils.SmartOrientationListener import org.jellyfin.mobile.utils.brightness +import org.jellyfin.mobile.utils.extensions.aspectRational import org.jellyfin.mobile.utils.extensions.disableFullscreen import org.jellyfin.mobile.utils.extensions.enableFullscreen import org.jellyfin.mobile.utils.extensions.isFullscreen +import org.jellyfin.mobile.utils.extensions.isLandscape import org.jellyfin.mobile.utils.toast import org.jellyfin.sdk.model.api.MediaStream diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt index 8ef9aa25..3d9a007d 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt @@ -73,136 +73,150 @@ class PlayerGestureHelper( /** * Handles taps when controls are locked */ - private val unlockDetector = GestureDetector(playerView.context, object : GestureDetector.SimpleOnGestureListener() { - override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - playerLockScreenHelper.peekUnlockButton() - return true - } - }) + private val unlockDetector = GestureDetector( + playerView.context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { + playerLockScreenHelper.peekUnlockButton() + return true + } + }, + ) /** * Handles double tap to seek and brightness/volume gestures */ - private val gestureDetector = GestureDetector(playerView.context, object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent): Boolean { - val viewWidth = playerView.measuredWidth - val viewHeight = playerView.measuredHeight - val viewCenterX = viewWidth / 2 - val viewCenterY = viewHeight / 2 - val isFastForward = e.x.toInt() > viewCenterX + private val gestureDetector = GestureDetector( + playerView.context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + val viewWidth = playerView.measuredWidth + val viewHeight = playerView.measuredHeight + val viewCenterX = viewWidth / 2 + val viewCenterY = viewHeight / 2 + val isFastForward = e.x.toInt() > viewCenterX - // Show ripple effect - playerView.foreground?.apply { - val left = if (isFastForward) viewCenterX else 0 - val right = if (isFastForward) viewWidth else viewCenterX - setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2) - setHotspot(e.x, e.y) - state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed) - playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) { - state = IntArray(0) - } - } - - // Fast-forward/rewind - with(fragment) { if (isFastForward) onFastForward() else onRewind() } - - // Cancel previous runnable to not hide controller while seeking - playerView.removeCallbacks(hidePlayerViewControllerAction) - - // Ensure controller gets hidden after seeking - playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) - return true - } - - override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - playerView.apply { - if (!isControllerVisible) showController() else hideController() - } - return true - } - - override fun onScroll(firstEvent: MotionEvent, currentEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - if (!appPreferences.exoPlayerAllowSwipeGestures) - return false - - // Check whether swipe was started in excluded region - if (firstEvent.y < playerView.resources.dip(Constants.GESTURE_EXCLUSION_AREA_TOP)) - return false - - // Check whether swipe was oriented vertically - if (abs(distanceY / distanceX) < 2) - return false - - val viewCenterX = playerView.measuredWidth / 2 - - // Distance to swipe to go from min to max - val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO - val ratioChange = distanceY / distanceFull - - if (firstEvent.x.toInt() > viewCenterX) { - // Swiping on the right, change volume - - val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat() - - val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val change = ratioChange * maxVolume - swipeGestureValueTracker += change - - val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0) - - gestureIndicatorOverlayImage.setImageResource(org.jellyfin.mobile.R.drawable.ic_volume_white_24dp) - gestureIndicatorOverlayProgress.max = maxVolume - gestureIndicatorOverlayProgress.progress = toSet - } else { - // Swiping on the left, change brightness - - val window = fragment.requireActivity().window - val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL - - // Initialize on first swipe - if (swipeGestureValueTracker == -1f) { - val brightness = window.brightness - swipeGestureValueTracker = when (brightness) { - in brightnessRange -> brightness - else -> Settings.System.getFloat(fragment.requireActivity().contentResolver, Settings.System.SCREEN_BRIGHTNESS) / Constants.SCREEN_BRIGHTNESS_MAX + // Show ripple effect + playerView.foreground?.apply { + val left = if (isFastForward) viewCenterX else 0 + val right = if (isFastForward) viewWidth else viewCenterX + setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2) + setHotspot(e.x, e.y) + state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed) + playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) { + state = IntArray(0) } } - swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange) - window.brightness = swipeGestureValueTracker - if (appPreferences.exoPlayerRememberBrightness) { - appPreferences.exoPlayerBrightness = swipeGestureValueTracker - } + // Fast-forward/rewind + with(fragment) { if (isFastForward) onFastForward() else onRewind() } - gestureIndicatorOverlayImage.setImageResource(org.jellyfin.mobile.R.drawable.ic_brightness_white_24dp) - gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX - gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt() + // Cancel previous runnable to not hide controller while seeking + playerView.removeCallbacks(hidePlayerViewControllerAction) + + // Ensure controller gets hidden after seeking + playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) + return true } - gestureIndicatorOverlayLayout.isVisible = true - return true - } - }) + override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { + playerView.apply { + if (!isControllerVisible) showController() else hideController() + } + return true + } + + override fun onScroll( + firstEvent: MotionEvent, + currentEvent: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + if (!appPreferences.exoPlayerAllowSwipeGestures) + return false + + // Check whether swipe was started in excluded region + if (firstEvent.y < playerView.resources.dip(Constants.GESTURE_EXCLUSION_AREA_TOP)) + return false + + // Check whether swipe was oriented vertically + if (abs(distanceY / distanceX) < 2) + return false + + val viewCenterX = playerView.measuredWidth / 2 + + // Distance to swipe to go from min to max + val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO + val ratioChange = distanceY / distanceFull + + if (firstEvent.x.toInt() > viewCenterX) { + // Swiping on the right, change volume + + val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat() + + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val change = ratioChange * maxVolume + swipeGestureValueTracker += change + + val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0) + + gestureIndicatorOverlayImage.setImageResource(org.jellyfin.mobile.R.drawable.ic_volume_white_24dp) + gestureIndicatorOverlayProgress.max = maxVolume + gestureIndicatorOverlayProgress.progress = toSet + } else { + // Swiping on the left, change brightness + + val window = fragment.requireActivity().window + val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL + + // Initialize on first swipe + if (swipeGestureValueTracker == -1f) { + val brightness = window.brightness + swipeGestureValueTracker = when (brightness) { + in brightnessRange -> brightness + else -> Settings.System.getFloat(fragment.requireActivity().contentResolver, Settings.System.SCREEN_BRIGHTNESS) / Constants.SCREEN_BRIGHTNESS_MAX + } + } + + swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange) + window.brightness = swipeGestureValueTracker + if (appPreferences.exoPlayerRememberBrightness) { + appPreferences.exoPlayerBrightness = swipeGestureValueTracker + } + + gestureIndicatorOverlayImage.setImageResource(org.jellyfin.mobile.R.drawable.ic_brightness_white_24dp) + gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX + gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt() + } + + gestureIndicatorOverlayLayout.isVisible = true + return true + } + }, + ) /** * Handles scale/zoom gesture */ - private val zoomGestureDetector = ScaleGestureDetector(playerView.context, object : ScaleGestureDetector.OnScaleGestureListener { - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape() + private val zoomGestureDetector = ScaleGestureDetector( + playerView.context, + object : ScaleGestureDetector.OnScaleGestureListener { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape() - override fun onScale(detector: ScaleGestureDetector): Boolean { - val scaleFactor = detector.scaleFactor - if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) { - isZoomEnabled = scaleFactor > 1 - updateZoomMode(isZoomEnabled) + override fun onScale(detector: ScaleGestureDetector): Boolean { + val scaleFactor = detector.scaleFactor + if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) { + isZoomEnabled = scaleFactor > 1 + updateZoomMode(isZoomEnabled) + } + return true } - return true - } - override fun onScaleEnd(detector: ScaleGestureDetector) = Unit - }).apply { isQuickScaleEnabled = false } + override fun onScaleEnd(detector: ScaleGestureDetector) = Unit + }, + ).apply { isQuickScaleEnabled = false } init { @Suppress("ClickableViewAccessibility") diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt index 85a79fae..eab46bde 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt @@ -8,8 +8,8 @@ import androidx.core.view.isVisible import com.google.android.exoplayer2.ui.PlayerView import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.isAutoRotateOn import org.jellyfin.mobile.utils.extensions.lockOrientation +import org.jellyfin.mobile.utils.isAutoRotateOn class PlayerLockScreenHelper( private val playerFragment: PlayerFragment, diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt index 36888e9b..7808ec90 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt @@ -189,7 +189,13 @@ class PlayerMenus( setOnDismissListener(this@PlayerMenus) } - private fun buildMenuItems(menu: Menu, groupId: Int, mediaStreams: List, selectedStream: MediaStream?, showNone: Boolean = false) { + private fun buildMenuItems( + menu: Menu, + groupId: Int, + mediaStreams: List, + selectedStream: MediaStream?, + showNone: Boolean = false, + ) { menu.clear() val itemNone = if (showNone) menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none)) else null val menuItems = mediaStreams.map { mediaStream -> diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt index 908aa4cd..1f4f5bce 100644 --- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt @@ -16,14 +16,14 @@ import de.Maxr1998.modernpreferences.helpers.screen import de.Maxr1998.modernpreferences.helpers.singleChoice import de.Maxr1998.modernpreferences.preferences.CheckBoxPreference import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem -import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.databinding.FragmentSettingsBinding import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins +import org.jellyfin.mobile.utils.extensions.requireMainActivity import org.jellyfin.mobile.utils.getDownloadsPaths import org.jellyfin.mobile.utils.isPackageInstalled -import org.jellyfin.mobile.utils.extensions.requireMainActivity import org.jellyfin.mobile.utils.withThemedContext import org.koin.android.ext.android.inject diff --git a/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt b/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt index d44a38a5..6c2ea6ca 100644 --- a/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt @@ -22,12 +22,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch +import org.jellyfin.mobile.MainViewModel import org.jellyfin.mobile.R import org.jellyfin.mobile.app.ApiClientController import org.jellyfin.mobile.databinding.FragmentConnectBinding import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins -import org.jellyfin.mobile.MainViewModel import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.discovery.LocalServerDiscovery import org.jellyfin.sdk.discovery.RecommendedServerInfo diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt index b65a59be..66dd69ce 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -24,11 +24,11 @@ import androidx.core.content.getSystemService import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout -import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.BuildConfig import org.jellyfin.mobile.R -import org.jellyfin.mobile.webapp.WebViewFragment +import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.settings.ExternalPlayerPackage +import org.jellyfin.mobile.webapp.WebViewFragment import org.koin.android.ext.android.get import timber.log.Timber import java.io.File diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt index b318ae78..b42594b5 100644 --- a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt @@ -36,29 +36,29 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import io.ktor.http.HttpStatusCode import kotlinx.coroutines.launch -import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.bridge.ExternalPlayer import org.jellyfin.mobile.bridge.NativeInterface import org.jellyfin.mobile.bridge.NativePlayer import org.jellyfin.mobile.bridge.NativePlayerHost -import org.jellyfin.mobile.player.interaction.PlayOptions -import org.jellyfin.mobile.app.ApiClientController -import org.jellyfin.mobile.databinding.FragmentWebviewBinding -import org.jellyfin.mobile.setup.ConnectFragment import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.databinding.FragmentWebviewBinding +import org.jellyfin.mobile.player.interaction.PlayOptions import org.jellyfin.mobile.player.ui.PlayerFragment +import org.jellyfin.mobile.setup.ConnectFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER -import org.jellyfin.mobile.utils.extensions.addFragment import org.jellyfin.mobile.utils.applyDefault import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins import org.jellyfin.mobile.utils.dip +import org.jellyfin.mobile.utils.extensions.addFragment +import org.jellyfin.mobile.utils.extensions.replaceFragment import org.jellyfin.mobile.utils.fadeIn import org.jellyfin.mobile.utils.initLocale import org.jellyfin.mobile.utils.inject import org.jellyfin.mobile.utils.isOutdated -import org.jellyfin.mobile.utils.extensions.replaceFragment import org.jellyfin.mobile.utils.requestNoBatteryOptimizations import org.jellyfin.mobile.utils.runOnUiThread import org.json.JSONException @@ -203,14 +203,22 @@ class WebViewFragment : Fragment(), NativePlayerHost { } } - override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { val errorMessage = errorResponse.data?.run { bufferedReader().use(Reader::readText) } Timber.e("Received WebView HTTP %d error: %s", errorResponse.statusCode, errorMessage) if (request.url == Uri.parse(view.url)) onErrorReceived() } - override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat) { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceErrorCompat, + ) { val description = if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_DESCRIPTION)) error.description else null val errorCode = if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_CODE)) error.errorCode else ERROR_UNKNOWN Timber.e("Received WebView error %d at %s: %s", errorCode, request.url.toString(), description)