mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-10-26 16:45:26 +00:00
Extract some PlayerViewModel code
This commit is contained in:
9 files changed
+227
-176
No files matched your search
@@ -23,7 +23,9 @@ import org.jellyfin.mobile.bridge.NativePlayer
|
||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
||||
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.interaction.ApiHelper
|
||||
import org.jellyfin.mobile.player.interaction.WebAppCommand
|
||||
import org.jellyfin.mobile.player.interaction.WebAppCommandHandler
|
||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
||||
import org.jellyfin.mobile.player.source.MediaSourceResolver
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
@@ -52,7 +54,7 @@ val applicationModule = module {
|
||||
single { ImageLoader(androidApplication()) }
|
||||
single { PermissionRequestHelper() }
|
||||
single { RemoteVolumeProvider(get()) }
|
||||
single(named(PLAYER_EVENT_CHANNEL)) { Channel<PlayerEvent>() }
|
||||
single(named(PLAYER_EVENT_CHANNEL)) { Channel<WebAppCommand>() }
|
||||
|
||||
// Controllers
|
||||
single { ApiClientController(get(), get(), get(), get(), get()) }
|
||||
@@ -60,6 +62,7 @@ val applicationModule = module {
|
||||
// Event handlers and channels
|
||||
single { ActivityEventHandler(get()) }
|
||||
single { WebappFunctionChannel() }
|
||||
factory { WebAppCommandHandler(get(named(PLAYER_EVENT_CHANNEL))) }
|
||||
|
||||
// Bridge interfaces
|
||||
single { NativePlayer(get(), get(), get(named(PLAYER_EVENT_CHANNEL))) }
|
||||
@@ -75,6 +78,7 @@ val applicationModule = module {
|
||||
single { ConnectionHelper(get(), get()) }
|
||||
|
||||
// Media player helpers
|
||||
factory { ApiHelper(get(), get()) }
|
||||
single { MediaSourceResolver(get()) }
|
||||
single { DeviceProfileBuilder(get()) }
|
||||
single { QualityOptionsProvider() }
|
||||
|
||||
@@ -6,7 +6,7 @@ import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.events.ActivityEvent
|
||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
||||
import org.jellyfin.mobile.player.interaction.WebAppCommand
|
||||
import org.jellyfin.mobile.settings.VideoPlayerType
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.json.JSONObject
|
||||
@@ -15,7 +15,7 @@ import org.json.JSONObject
|
||||
class NativePlayer(
|
||||
private val appPreferences: AppPreferences,
|
||||
private val activityEventHandler: ActivityEventHandler,
|
||||
private val playerEventChannel: Channel<PlayerEvent>,
|
||||
private val webAppCommandChannel: Channel<WebAppCommand>,
|
||||
) {
|
||||
|
||||
@JavascriptInterface
|
||||
@@ -30,36 +30,36 @@ class NativePlayer(
|
||||
|
||||
@JavascriptInterface
|
||||
fun pausePlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Pause)
|
||||
webAppCommandChannel.trySend(WebAppCommand.Pause)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun resumePlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Resume)
|
||||
webAppCommandChannel.trySend(WebAppCommand.Resume)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopPlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Stop)
|
||||
webAppCommandChannel.trySend(WebAppCommand.Stop)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun destroyPlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Destroy)
|
||||
webAppCommandChannel.trySend(WebAppCommand.Destroy)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seek(ticks: Long) {
|
||||
playerEventChannel.trySend(PlayerEvent.Seek(ticks / Constants.TICKS_PER_MILLISECOND))
|
||||
webAppCommandChannel.trySend(WebAppCommand.Seek(ticks / Constants.TICKS_PER_MILLISECOND))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekMs(ms: Long) {
|
||||
playerEventChannel.trySend(PlayerEvent.Seek(ms))
|
||||
webAppCommandChannel.trySend(WebAppCommand.Seek(ms))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setVolume(volume: Int) {
|
||||
playerEventChannel.trySend(PlayerEvent.SetVolume(volume))
|
||||
webAppCommandChannel.trySend(WebAppCommand.SetVolume(volume))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package org.jellyfin.mobile.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
@@ -26,20 +28,17 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
import com.google.android.exoplayer2.util.Clock
|
||||
import com.google.android.exoplayer2.util.EventLogger
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.BuildConfig
|
||||
import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL
|
||||
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
||||
import org.jellyfin.mobile.player.interaction.ApiHelper
|
||||
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.interaction.WebAppCommandHandler
|
||||
import org.jellyfin.mobile.player.queue.QueueManager
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.config.DecoderType
|
||||
@@ -49,37 +48,25 @@ 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.getVolumeLevelPercent
|
||||
import org.jellyfin.mobile.utils.extensions.setVolumeLevelPercent
|
||||
import org.jellyfin.mobile.utils.logTracks
|
||||
import org.jellyfin.mobile.utils.seekToOffset
|
||||
import org.jellyfin.mobile.utils.setPlaybackState
|
||||
import org.jellyfin.mobile.utils.toMediaMetadata
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
||||
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.operations.DisplayPreferencesApi
|
||||
import org.jellyfin.sdk.api.operations.HlsSegmentApi
|
||||
import org.jellyfin.sdk.api.operations.PlayStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.Listener {
|
||||
private val apiClient: ApiClient = get()
|
||||
private val displayPreferencesApi: DisplayPreferencesApi = apiClient.displayPreferencesApi
|
||||
private val playStateApi: PlayStateApi = apiClient.playStateApi
|
||||
private val apiHelper: ApiHelper = get()
|
||||
private val hlsSegmentApi: HlsSegmentApi = apiClient.hlsSegmentApi
|
||||
|
||||
private val lifecycleObserver = PlayerLifecycleObserver(this)
|
||||
@@ -116,7 +103,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
*/
|
||||
val playerOrNull: ExoPlayer? get() = _player.value
|
||||
|
||||
private val playerEventChannel: Channel<PlayerEvent> by inject(named(PLAYER_EVENT_CHANNEL))
|
||||
private val webAppCommandHandler: WebAppCommandHandler by inject()
|
||||
|
||||
val mediaSession: MediaSession by lazy {
|
||||
MediaSession(
|
||||
@@ -138,39 +125,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
|
||||
// Load display preferences
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val displayPreferencesDto by displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = Constants.DISPLAY_PREFERENCES_ID_USER_SETTINGS,
|
||||
client = Constants.DISPLAY_PREFERENCES_CLIENT_EMBY,
|
||||
)
|
||||
|
||||
val customPrefs = displayPreferencesDto.customPrefs
|
||||
|
||||
displayPreferences = DisplayPreferences(
|
||||
skipBackLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH]?.toLongOrNull()
|
||||
?: Constants.DEFAULT_SEEK_TIME_MS,
|
||||
skipForwardLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH]?.toLongOrNull()
|
||||
?: Constants.DEFAULT_SEEK_TIME_MS,
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to load display preferences")
|
||||
}
|
||||
displayPreferences = apiHelper.loadDisplayPreferences()
|
||||
}
|
||||
|
||||
// Subscribe to player events from webapp
|
||||
viewModelScope.launch {
|
||||
for (event in playerEventChannel) {
|
||||
when (event) {
|
||||
PlayerEvent.Pause -> mediaSessionCallback.onPause()
|
||||
PlayerEvent.Resume -> mediaSessionCallback.onPlay()
|
||||
PlayerEvent.Stop, PlayerEvent.Destroy -> mediaSessionCallback.onStop()
|
||||
is PlayerEvent.Seek -> playerOrNull?.seekTo(event.ms)
|
||||
is PlayerEvent.SetVolume -> {
|
||||
setVolume(event.volume)
|
||||
playerOrNull?.reportPlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
with(webAppCommandHandler) {
|
||||
subscribeWebAppCommands()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +215,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
|
||||
|
||||
viewModelScope.launch {
|
||||
player.reportPlaybackStart(jellyfinMediaSource)
|
||||
apiHelper.reportPlaybackStart(player, jellyfinMediaSource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +223,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
progressUpdateJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(Constants.PLAYER_TIME_UPDATE_RATE)
|
||||
playerOrNull?.reportPlaybackState()
|
||||
reportPlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,87 +251,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
queueManager.tryRestartPlayback()
|
||||
}
|
||||
|
||||
private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) {
|
||||
try {
|
||||
playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
playMethod = mediaSource.playMethod,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
audioStreamIndex = mediaSource.selectedAudioStream?.index,
|
||||
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
|
||||
isPaused = !isPlaying,
|
||||
isMuted = false,
|
||||
canSeek = true,
|
||||
positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND,
|
||||
volumeLevel = audioManager.getVolumeLevelPercent(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
),
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback start")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun Player.reportPlaybackState() {
|
||||
suspend fun reportPlaybackState() {
|
||||
val player = playerOrNull ?: return
|
||||
val mediaSource = mediaSourceOrNull ?: return
|
||||
val playbackPositionMillis = currentPosition
|
||||
if (playbackState != Player.STATE_ENDED) {
|
||||
try {
|
||||
playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
playMethod = mediaSource.playMethod,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
audioStreamIndex = mediaSource.selectedAudioStream?.index,
|
||||
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
|
||||
isPaused = !isPlaying,
|
||||
isMuted = false,
|
||||
canSeek = true,
|
||||
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
|
||||
volumeLevel = audioManager.getVolumeLevelPercent(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
),
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback progress")
|
||||
}
|
||||
}
|
||||
apiHelper.reportPlaybackState(player, mediaSource)
|
||||
}
|
||||
|
||||
private fun reportPlaybackStop() {
|
||||
val mediaSource = mediaSourceOrNull ?: return
|
||||
val player = playerOrNull ?: return
|
||||
val hasFinished = player.playbackState == Player.STATE_ENDED
|
||||
val lastPositionTicks = when {
|
||||
hasFinished -> mediaSource.runTimeTicks
|
||||
else -> player.currentPosition * Constants.TICKS_PER_MILLISECOND
|
||||
}
|
||||
|
||||
// viewModelScope may already be cancelled at this point, so we need to fallback
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
// Report stopped playback
|
||||
playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
positionTicks = lastPositionTicks,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
failed = false,
|
||||
),
|
||||
)
|
||||
|
||||
// Mark video as watched if playback finished
|
||||
if (hasFinished) {
|
||||
playStateApi.markPlayedItem(itemId = mediaSource.itemId)
|
||||
}
|
||||
|
||||
// Stop active encoding if transcoding
|
||||
stopTranscoding(mediaSource)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback stop")
|
||||
}
|
||||
}
|
||||
apiHelper.reportPlaybackStop(playerOrNull ?: return, mediaSourceOrNull ?: return)
|
||||
}
|
||||
|
||||
suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) {
|
||||
@@ -466,7 +353,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
releasePlayer()
|
||||
}
|
||||
|
||||
private fun setVolume(percent: Int) {
|
||||
fun setVolume(percent: Int) {
|
||||
if (!audioManager.isVolumeFixed) audioManager.setVolumeLevelPercent(percent)
|
||||
}
|
||||
|
||||
@@ -507,7 +394,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
viewModelScope.launch {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY, Player.STATE_BUFFERING -> {
|
||||
player.reportPlaybackState()
|
||||
reportPlaybackState()
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
reportPlaybackStop()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import com.google.android.exoplayer2.Player
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.config.DisplayPreferences
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.extensions.getVolumeLevelPercent
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
|
||||
import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import timber.log.Timber
|
||||
|
||||
class ApiHelper(
|
||||
context: Context,
|
||||
private val apiClient: ApiClient,
|
||||
) {
|
||||
private val displayPreferencesApi = apiClient.displayPreferencesApi
|
||||
private val playStateApi = apiClient.playStateApi
|
||||
private val hlsSegmentApi = apiClient.hlsSegmentApi
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
suspend fun loadDisplayPreferences(): DisplayPreferences {
|
||||
try {
|
||||
val displayPreferencesDto by displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = Constants.DISPLAY_PREFERENCES_ID_USER_SETTINGS,
|
||||
client = Constants.DISPLAY_PREFERENCES_CLIENT_EMBY,
|
||||
)
|
||||
|
||||
val customPrefs = displayPreferencesDto.customPrefs
|
||||
|
||||
return DisplayPreferences(
|
||||
skipBackLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH]?.toLongOrNull()
|
||||
?: Constants.DEFAULT_SEEK_TIME_MS,
|
||||
skipForwardLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH]?.toLongOrNull()
|
||||
?: Constants.DEFAULT_SEEK_TIME_MS,
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to load display preferences, returning defaults")
|
||||
return DisplayPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStart(player: Player, mediaSource: JellyfinMediaSource) {
|
||||
try {
|
||||
playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
playMethod = mediaSource.playMethod,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
audioStreamIndex = mediaSource.selectedAudioStream?.index,
|
||||
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
|
||||
isPaused = !player.isPlaying,
|
||||
isMuted = false,
|
||||
canSeek = true,
|
||||
positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND,
|
||||
volumeLevel = audioManager.getVolumeLevelPercent(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
),
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback start")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackState(player: Player, mediaSource: JellyfinMediaSource) {
|
||||
val playbackPositionMillis = player.currentPosition
|
||||
if (player.playbackState != Player.STATE_ENDED) {
|
||||
try {
|
||||
playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
playMethod = mediaSource.playMethod,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
audioStreamIndex = mediaSource.selectedAudioStream?.index,
|
||||
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
|
||||
isPaused = !player.isPlaying,
|
||||
isMuted = false,
|
||||
canSeek = true,
|
||||
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
|
||||
volumeLevel = audioManager.getVolumeLevelPercent(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
),
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportPlaybackStop(player: Player, mediaSource: JellyfinMediaSource) {
|
||||
val hasFinished = player.playbackState == Player.STATE_ENDED
|
||||
val lastPositionTicks = when {
|
||||
hasFinished -> mediaSource.runTimeTicks
|
||||
else -> player.currentPosition * Constants.TICKS_PER_MILLISECOND
|
||||
}
|
||||
|
||||
// viewModelScope may already be cancelled at this point, so we need to fallback
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
// Report stopped playback
|
||||
playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
positionTicks = lastPositionTicks,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
failed = false,
|
||||
),
|
||||
)
|
||||
|
||||
// Mark video as watched if playback finished
|
||||
if (hasFinished) {
|
||||
playStateApi.markPlayedItem(itemId = mediaSource.itemId)
|
||||
}
|
||||
|
||||
// Stop active encoding if transcoding
|
||||
stopTranscoding(mediaSource)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback stop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) {
|
||||
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
|
||||
hlsSegmentApi.stopEncodingProcess(
|
||||
deviceId = apiClient.deviceInfo.id,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
sealed class PlayerEvent {
|
||||
data object Pause : PlayerEvent()
|
||||
data object Resume : PlayerEvent()
|
||||
data object Stop : PlayerEvent()
|
||||
data object Destroy : PlayerEvent()
|
||||
data class Seek(val ms: Long) : PlayerEvent()
|
||||
data class SetVolume(val volume: Int) : PlayerEvent()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
/**
|
||||
* Represents a command sent to the player from the web app.
|
||||
*/
|
||||
sealed class WebAppCommand {
|
||||
data object Pause : WebAppCommand()
|
||||
data object Resume : WebAppCommand()
|
||||
data object Stop : WebAppCommand()
|
||||
data object Destroy : WebAppCommand()
|
||||
data class Seek(val ms: Long) : WebAppCommand()
|
||||
data class SetVolume(val volume: Int) : WebAppCommand()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
|
||||
class WebAppCommandHandler(
|
||||
private val commandChannel: Channel<WebAppCommand>,
|
||||
) {
|
||||
fun PlayerViewModel.subscribeWebAppCommands() {
|
||||
viewModelScope.launch {
|
||||
for (event in commandChannel) {
|
||||
when (event) {
|
||||
WebAppCommand.Pause -> pause()
|
||||
WebAppCommand.Resume -> play()
|
||||
WebAppCommand.Stop, WebAppCommand.Destroy -> stop()
|
||||
is WebAppCommand.Seek -> playerOrNull?.seekTo(event.ms)
|
||||
is WebAppCommand.SetVolume -> {
|
||||
setVolume(event.volume)
|
||||
reportPlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,8 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
requireActivity().window.keepScreenOn = uiViewModel.shouldShowPauseButton
|
||||
}
|
||||
viewModel.error.observe(this) { message ->
|
||||
val safeMessage = message.ifEmpty { requireContext().getString(R.string.player_error_unspecific_exception) }
|
||||
requireContext().toast(safeMessage)
|
||||
val safeMessage = message.ifEmpty { getString(R.string.player_error_unspecific_exception) }
|
||||
requireActivity().toast(safeMessage)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
@@ -119,17 +119,20 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
|
||||
// Handle fragment arguments, extract playback options and start playback
|
||||
lifecycleScope.launch {
|
||||
val context = requireContext()
|
||||
val playOptions = requireArguments().getParcelableCompat<PlayOptions>(Constants.EXTRA_MEDIA_PLAY_OPTIONS)
|
||||
if (playOptions == null) {
|
||||
context.toast(R.string.player_error_invalid_play_options)
|
||||
requireActivity().toast(R.string.player_error_invalid_play_options)
|
||||
return@launch
|
||||
}
|
||||
when (viewModel.queueManager.initializePlaybackQueue(playOptions)) {
|
||||
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
|
||||
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
|
||||
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
|
||||
null -> Unit // success
|
||||
|
||||
val playbackException = viewModel.queueManager.initializePlaybackQueue(playOptions)
|
||||
if (playbackException != null) {
|
||||
val context = requireActivity()
|
||||
when (playbackException) {
|
||||
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
|
||||
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
|
||||
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,15 +162,7 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
}
|
||||
ViewCompat.requestApplyInsets(view)
|
||||
|
||||
/*// Create playback menus
|
||||
playerMenus = PlayerMenus(this, playerBinding, playerControlsBinding)
|
||||
|
||||
// Set controller timeout
|
||||
suppressControllerAutoHide(false)*/
|
||||
|
||||
fullscreenHelper = FullscreenHelper(window)
|
||||
/*playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
|
||||
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)*/
|
||||
val swipeGestureHelper = SwipeGestureHelper(requireContext(), window, appPreferences)
|
||||
|
||||
composeView.setContent {
|
||||
|
||||
@@ -100,19 +100,11 @@ object Constants {
|
||||
const val LANGUAGE_UNDEFINED = "und"
|
||||
const val TICKS_PER_MILLISECOND = 10000
|
||||
const val PLAYER_TIME_UPDATE_RATE = 10000L
|
||||
const val DEFAULT_CONTROLS_TIMEOUT_MS = 2500
|
||||
const val SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL = 64
|
||||
const val DEFAULT_CENTER_OVERLAY_TIMEOUT_MS = 250
|
||||
const val DISPLAY_PREFERENCES_ID_USER_SETTINGS = "usersettings"
|
||||
const val DISPLAY_PREFERENCES_CLIENT_EMBY = "emby"
|
||||
const val DISPLAY_PREFERENCES_SKIP_BACK_LENGTH = "skipBackLength"
|
||||
const val DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH = "skipForwardLength"
|
||||
const val DEFAULT_SEEK_TIME_MS = 10000L
|
||||
const val DOUBLE_TAP_RIPPLE_DURATION_MS = 100L
|
||||
const val FULL_SWIPE_RANGE_SCREEN_RATIO = 0.66f
|
||||
const val SCREEN_BRIGHTNESS_MAX = 255
|
||||
const val ZOOM_SCALE_BASE = 1f
|
||||
const val ZOOM_SCALE_THRESHOLD = 0.01f
|
||||
val ASPECT_RATIO_16_9 = Rational(16, 9)
|
||||
val PIP_MIN_RATIONAL = Rational(100, 239)
|
||||
val PIP_MAX_RATIONAL = Rational(239, 100)
|
||||
|
||||
Reference in new issue
Block a user