Skip to content

Add still watching feature #4509

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import org.jellyfin.androidtv.ui.ScreensaverViewModel
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingViewModel
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
Expand Down Expand Up @@ -106,6 +107,7 @@ val appModule = module {
// Non API related
single { DataRefreshService() }
single { PlaybackControllerContainer() }
single { InteractionTrackerViewModel(get(), get()) }

single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
Expand All @@ -120,8 +122,8 @@ val appModule = module {
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get()) }
viewModel { StillWatchingViewModel(get(), get(), get()) }
viewModel { PictureViewerViewModel(get()) }
viewModel { ScreensaverViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
import org.jellyfin.androidtv.preference.constant.RatingType
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
import org.jellyfin.androidtv.preference.constant.StillWatchingBehavior
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
Expand Down Expand Up @@ -87,6 +88,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var cinemaModeEnabled = booleanPreference("pref_enable_cinema_mode", true)

/**
* Enable still watching
*/
var stillWatchingBehavior = enumPreference("enable_still_watching", StillWatchingBehavior.DISABLED)

/* Playback - Video */
/**
* Whether to use an external playback application or not.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.jellyfin.androidtv.preference.constant

import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum

enum class StillWatchingBehavior(
override val nameRes: Int
) : PreferenceEnum {
/**
* Shorter than default. Show screen at 2 episodes or 60 minutes of uninterrupted watch time.
*/
SHORT(R.string.lbl_still_watching_short),
/**
* Default behavior for still watching. Show screen at 3 episodes or 90 minutes of uninterrupted watch time.
*/
DEFAULT(R.string.lbl_still_watching_default),
/**
* Longer than default. Show screen at 5 episodes or 150 minutes of uninterrupted watch time.
*/
LONG(R.string.lbl_still_watching_long),
/**
* Much longer than default. Show screen at 8 episodes or 240 minutes of uninterrupted watch time.
*/
VERY_LONG(R.string.lbl_still_watching_very_long),
/**
* Disables still watching screen.
*/
DISABLED(R.string.state_disabled)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package org.jellyfin.androidtv.ui

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingPresetConfigs
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingStates
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import kotlin.time.Duration.Companion.milliseconds

class InteractionTrackerViewModel(
private val userPreferences: UserPreferences,
private val playbackControllerContainer: PlaybackControllerContainer
) : ViewModel() {
// Screensaver vars

private var timer: Job? = null
private var locks = 0

// Still Watching vars

private var isWatchingEpisodes = false
private var episodeCount = 0
private var watchTime = 0L
private var episodeInteractMs = 0L
private var episodeWasInterrupted: Boolean = false
private var showStillWatching: Boolean = false

// Preferences

private val inAppEnabled get() = userPreferences[UserPreferences.screensaverInAppEnabled]
private val timeout get() = userPreferences[UserPreferences.screensaverInAppTimeout].milliseconds

// State

private val _screensaverVisible = MutableStateFlow(false)
val visible get() = _screensaverVisible.asStateFlow()

private val _keepScreenOn = MutableStateFlow(inAppEnabled)
val keepScreenOn get() = _keepScreenOn.asStateFlow()

var activityPaused: Boolean = false
set(value) {
field = value
notifyInteraction(canCancel = true, userInitiated = false)
}

init {
notifyInteraction(canCancel = true, userInitiated = false)
}

fun getShowStillWatching(): Boolean {
return showStillWatching
}

fun notifyStartSession(item: BaseItemDto, items: List<BaseItemDto>) {
// No need to track when only watching 1 episode
if (itemIsEpisode(item) && items.size > 1) {
resetSession()
episodeWasInterrupted = false
showStillWatching = false
}
}

fun onEpisodeWatched() {
if (!episodeWasInterrupted) episodeCount++
calculateWatchTime()
episodeWasInterrupted = false
checkStillWatchingStatus()
}

fun notifyStart(item: BaseItemDto) {
if (itemIsEpisode(item)) {
isWatchingEpisodes = true
}
}

private fun checkStillWatchingStatus() {
val presetName = userPreferences[UserPreferences.stillWatchingBehavior].toString().uppercase()
val preset = runCatching { StillWatchingPresetConfigs.valueOf(presetName) }.getOrDefault(StillWatchingPresetConfigs.DISABLED)

val stillWatchingSetting = StillWatchingStates.getSetting(preset)
val episodeRequirementMet = episodeCount == stillWatchingSetting.episodeCount
val watchTimeRequirementMet = watchTime >= stillWatchingSetting.minDuration.inWholeMilliseconds

if (episodeRequirementMet || watchTimeRequirementMet) {
showStillWatching = true
}
}

fun notifyInteraction(canCancel: Boolean, userInitiated: Boolean) {
// Cancel pending screensaver timer (if any)
timer?.cancel()

// If watching episodes, reset episode count and watch time
if (isWatchingEpisodes && userInitiated) {
resetSession()

val playbackController: PlaybackController = playbackControllerContainer.playbackController!!

episodeInteractMs = playbackController.currentPosition
episodeWasInterrupted = true
}

// Hide screensaver when interacted with allowed cancellation or when disabled
if (_screensaverVisible.value && (canCancel || !inAppEnabled || activityPaused)) {

Check warning

Code scanning / detekt

Complex conditions should be simplified and extracted into well-named methods if necessary. Warning

This condition is too complex (4). Defined complexity threshold for conditions is set to '4'
_screensaverVisible.value = false
}

// Create new timer to show screensaver when enabled
if (inAppEnabled && !activityPaused && locks == 0) {
timer = viewModelScope.launch {
delay(timeout)
_screensaverVisible.value = true
}
}

// Update KEEP_SCREEN_ON flag value
_keepScreenOn.value = inAppEnabled || locks > 0
}

/**
* Create a lock that prevents the screensaver for running until the returned function is called
* or the lifecycle is destroyed.
*
* @return Function to cancel the lock
*/
fun addLifecycleLock(lifecycle: Lifecycle): () -> Unit {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) return {}

val lock = ScreensaverLock(lifecycle)
lock.activate()
return lock::cancel
}

private inner class ScreensaverLock(private val lifecycle: Lifecycle) : LifecycleEventObserver {
private var active: Boolean = false

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) cancel()
}

fun activate() {
if (active) return
lifecycle.addObserver(this)
locks++
notifyInteraction(canCancel = true, userInitiated = false)
active = true
}

fun cancel() {
if (!active) return
locks--
lifecycle.removeObserver(this)
notifyInteraction(canCancel = false, userInitiated = false)
active = false
}
}

private fun itemIsEpisode(item: BaseItemDto? = null): Boolean {
if (item != null) {
return item.type == BaseItemKind.EPISODE
}

val playbackController = playbackControllerContainer.playbackController

return playbackController?.currentlyPlayingItem?.type == BaseItemKind.EPISODE
}

private fun resetSession() {
watchTime = 0L
episodeCount = 0
episodeInteractMs = 0L
}

private fun calculateWatchTime() {
val duration = playbackControllerContainer.playbackController!!.duration
val durationWatchedUninterrupted = if (episodeWasInterrupted) duration - episodeInteractMs else duration
watchTime += durationWatchedUninterrupted
}
}
103 changes: 0 additions & 103 deletions app/src/main/java/org/jellyfin/androidtv/ui/ScreensaverViewModel.kt

This file was deleted.

Loading
Loading