Skip to content
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

Add still watching feature #4509

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingViewModel
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
Expand Down Expand Up @@ -97,6 +98,7 @@ val appModule = module {
single { DataRefreshService() }
single { PlaybackControllerContainer() }


single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
Expand All @@ -110,6 +112,7 @@ val appModule = module {
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get(), get()) }
viewModel { StillWatchingViewModel() }
viewModel { PictureViewerViewModel(get()) }
viewModel { ScreensaverViewModel(get()) }
viewModel { SearchViewModel(get()) }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.jellyfin.androidtv.ui.navigation

import androidx.core.os.bundleOf
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jellyfin.androidtv.constant.Extras
import org.jellyfin.androidtv.ui.browsing.BrowseGridFragment
Expand All @@ -27,6 +26,7 @@ import org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment
import org.jellyfin.androidtv.ui.playback.ExternalPlayerActivity
import org.jellyfin.androidtv.ui.playback.nextup.NextUpFragment
import org.jellyfin.androidtv.ui.playback.rewrite.PlaybackRewriteFragment
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingFragment
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.screen.UserPreferencesScreen
Expand Down Expand Up @@ -147,6 +147,7 @@ object Destinations {

// Playback
val nowPlaying = fragmentDestination<AudioNowPlayingFragment>()
val stillWatching = fragmentDestination<StillWatchingFragment>()

fun pictureViewer(item: UUID, autoPlay: Boolean, albumSortBy: ItemSortBy?, albumSortOrder: SortOrder?) =
fragmentDestination<PictureViewerFragment>(
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.jellyfin.androidtv.ui.playback;

import static org.jellyfin.androidtv.util.TimeUtils.MILLIS_PER_MIN;
import static org.jellyfin.androidtv.util.TimeUtils.MILLIS_PER_SEC;
import static org.koin.java.KoinJavaComponent.inject;

import android.annotation.TargetApi;
Expand All @@ -8,6 +10,7 @@
import android.os.Handler;
import android.view.Display;
import android.view.WindowManager;
import android.os.CountDownTimer;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
Expand All @@ -23,6 +26,8 @@
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior;
import org.jellyfin.androidtv.preference.constant.ZoomMode;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.ui.navigation.Destinations;
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.ReportingHelper;
Expand Down Expand Up @@ -56,13 +61,17 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private final static long PROGRESS_REPORTING_INTERVAL = TimeUtils.secondsToMillis(3);
// Frequency to report paused state
private static final long PROGRESS_REPORTING_PAUSE_INTERVAL = TimeUtils.secondsToMillis(15);
// Minutes without activity to trigger still watching fragment
private static final double MINUTES_WITHOUT_ACTIVITY = 0.1;

private Lazy<PlaybackManager> playbackManager = inject(PlaybackManager.class);
private Lazy<UserPreferences> userPreferences = inject(UserPreferences.class);
private Lazy<VideoQueueManager> videoQueueManager = inject(VideoQueueManager.class);
private Lazy<org.jellyfin.sdk.api.client.ApiClient> api = inject(org.jellyfin.sdk.api.client.ApiClient.class);
private Lazy<DataRefreshService> dataRefreshService = inject(DataRefreshService.class);
private Lazy<ReportingHelper> reportingHelper = inject(ReportingHelper.class);
private Lazy<PlaybackControllerContainer> playbackControllerContainer = inject(PlaybackControllerContainer.class);
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);

List<BaseItemDto> mItems;
VideoManager mVideoManager;
Expand All @@ -84,6 +93,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private Runnable mReportLoop;
private Handler mHandler;

private CountDownTimer countdownTimer;

private long mStartPosition = 0;

// tmp position used when seeking
Expand Down Expand Up @@ -380,6 +391,10 @@ private void refreshCurrentPosition() {
mCurrentPosition = newPos != -1 ? newPos : mCurrentPosition;
}

public void cancelTimer() {
countdownTimer.cancel();
}

public void play(long position) {
play(position, null);
}
Expand Down Expand Up @@ -476,6 +491,17 @@ public void onClick(DialogInterface dialog, int which) {

VideoOptions internalOptions = buildExoPlayerOptions(forcedSubtitleIndex, item);

if (playbackControllerContainer.getValue().getEpisodesPlayedWithoutInterruption() >= 2) {
countdownTimer = new CountDownTimer((long) (MINUTES_WITHOUT_ACTIVITY * MILLIS_PER_MIN), MILLIS_PER_SEC) {
public void onTick(long millisUntilFinished) {}

public void onFinish() {
pause();
navigationRepository.getValue().navigate(Destinations.INSTANCE.getStillWatching(), false);
}
}.start();
}

playInternal(getCurrentlyPlayingItem(), position, internalOptions);
mPlaybackState = PlaybackState.BUFFERING;
mFragment.setPlayPauseActionState(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,31 @@ package org.jellyfin.androidtv.ui.playback

class PlaybackControllerContainer {
var playbackController: PlaybackController? = null

private var episodesPlayedWithoutInterruption = 0
private var episodeWasInterrupted = false

fun getEpisodesPlayedWithoutInterruption(): Int {
return this.episodesPlayedWithoutInterruption
}

fun getEpisodeWasInterrupted(): Boolean {
return this.episodeWasInterrupted
}

fun incrementEpisodesPlayedWithoutInterruption() {
episodesPlayedWithoutInterruption++
}

fun resetEpisodesPlayedWithoutInterruption() {
this.episodesPlayedWithoutInterruption = 0
}

fun setEpisodeWasInterrupted(status: Boolean?) {
this.episodeWasInterrupted = status!!
}

fun cancelTimer() {
playbackController?.cancelTimer()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.NEXTUP_TIMER_DISABLED
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
Expand All @@ -35,6 +36,7 @@ class NextUpFragment : Fragment() {
private val backgroundService: BackgroundService by inject()
private val userPreferences: UserPreferences by inject()
private val navigationRepository: NavigationRepository by inject()
private val playbackControllerContainer: PlaybackControllerContainer by inject()
private var timerStarted = false

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -48,7 +50,13 @@ class NextUpFragment : Fragment() {
.onEach { state ->
when (state) {
// Open next item
NextUpState.PLAY_NEXT -> navigationRepository.navigate(Destinations.videoPlayer(0), true)
NextUpState.PLAY_NEXT -> {
if (!playbackControllerContainer.getEpisodeWasInterrupted()) playbackControllerContainer.incrementEpisodesPlayedWithoutInterruption()

playbackControllerContainer.setEpisodeWasInterrupted(false)

navigationRepository.navigate(Destinations.videoPlayer(0), true)
}
// Close activity
NextUpState.CLOSE -> navigationRepository.goBack()
// Unknown state
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.jellyfin.androidtv.ui.playback.stillwatching

import android.content.Context
import android.os.CountDownTimer
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import org.jellyfin.androidtv.databinding.FragmentStillWatchingButtonsBinding

class StillWatchingButtonsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private var countdownTimer: CountDownTimer? = null
private val view = FragmentStillWatchingButtonsBinding.inflate(LayoutInflater.from(context), this, true)

var countdownTimerEnabled: Boolean = false
var duration: Int = 0

init {
view.fragmentStillWatchingButtonsYes.apply {
// Stop timer when unfocused
setOnFocusChangeListener { _, focused -> if (!focused) stopTimer() }
}
}

fun focusNoButton() = view.fragmentStillWatchingButtonsNo.requestFocus()

fun startTimer() {
// Cancel current timer if one is already set
countdownTimer?.cancel()

if (!countdownTimerEnabled) return

// Create timer
countdownTimer = object : CountDownTimer(duration.toLong(), 1) {
override fun onTick(millisUntilFinished: Long) {
view.fragmentStillWatchingButtonsNoProgress.apply {
max = duration
progress = (duration - millisUntilFinished).toInt()
}
}

override fun onFinish() {
// Perform a click so the event handler will activate
view.fragmentStillWatchingButtonsNoProgress.performClick()
}
}.start()
}

fun stopTimer() {
countdownTimer?.cancel()

// Hide progress bar
view.fragmentStillWatchingButtonsNoProgress.apply {
max = 0
progress = 0
}
}

fun setYesListener(listener: () -> Unit) {
view.fragmentStillWatchingButtonsYes.setOnClickListener { listener() }
}

fun setNoListener(listener: () -> Unit) {
view.fragmentStillWatchingButtonsNo.setOnClickListener { listener() }
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.jellyfin.androidtv.ui.playback.stillwatching

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.databinding.FragmentStillWatchingBinding
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel

class StillWatchingFragment : Fragment() {
companion object {
const val STILL_WATCHING_TIMER_DISABLED = 0
}

private var _binding: FragmentStillWatchingBinding? = null
private val binding get() = _binding!!

private val viewModel: StillWatchingViewModel by viewModel()
private val navigationRepository: NavigationRepository by inject()
private var timerStarted = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

viewModel.state
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { state ->
when (state) {
// Open next item
StillWatchingState.STILL_WATCHING -> navigationRepository.navigate(Destinations.videoPlayer(0), true)
// Close activity
StillWatchingState.CLOSE -> navigationRepository.goBack()
// Unknown state
else -> Unit
}
}.launchIn(lifecycleScope)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentStillWatchingBinding.inflate(inflater, container, false)

binding.fragmentStillWatchingButtons.apply {
// duration = userPreferences[UserPreferences.nextUpTimeout]
duration = 30000
countdownTimerEnabled = duration != STILL_WATCHING_TIMER_DISABLED
setYesListener(viewModel::stillWatching)
setNoListener(viewModel::close)
}

return binding.root
}

override fun onStart() {
super.onStart()

binding.fragmentStillWatchingButtons.focusNoButton()

if (!timerStarted) {
// We need to workaround an issue where compose claims focus for a single draw
// causing the NextUpButtonsView to auto-stop the timer
lifecycleScope.launch {
delay(1)
binding.fragmentStillWatchingButtons.startTimer()
}

timerStarted = true
}
}

override fun onPause() {
super.onPause()

binding.fragmentStillWatchingButtons.stopTimer()
}

override fun onDestroyView() {
super.onDestroyView()

_binding = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.jellyfin.androidtv.ui.playback.stillwatching

enum class StillWatchingState {
INITIALIZED,
STILL_WATCHING,
CLOSE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.jellyfin.androidtv.ui.playback.stillwatching

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class StillWatchingViewModel : ViewModel() {
private val _state = MutableStateFlow(StillWatchingState.INITIALIZED)
val state: StateFlow<StillWatchingState> = _state

fun stillWatching() {
_state.value = StillWatchingState.STILL_WATCHING
}

fun close() {
_state.value = StillWatchingState.CLOSE
}
}

Loading