Skip to content

feat: sleep mode #518

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

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
92 changes: 92 additions & 0 deletions app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import android.widget.ImageButton
import android.widget.ImageView
import android.widget.Space
import android.widget.TextView
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
Expand All @@ -33,6 +34,7 @@ import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.TrackSelectionDialogBuilder
import androidx.navigation.navArgs
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
Expand All @@ -41,10 +43,16 @@ import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.utils.PlayerGestureHelper
import dev.jdtech.jellyfin.utils.PreviewScrubListener
import dev.jdtech.jellyfin.utils.formatDuration
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import dev.jdtech.jellyfin.core.R as CoreR
import dev.jdtech.jellyfin.player.video.R as PlayerVideoR

var isControlsLocked: Boolean = false
Expand All @@ -59,6 +67,9 @@ class PlayerActivity : BasePlayerActivity() {
private var playerGestureHelper: PlayerGestureHelper? = null
override val viewModel: PlayerActivityViewModel by viewModels()
private var previewScrubListener: PreviewScrubListener? = null
private var sleepJob: Job? = null
private var wasDialogShown: Boolean = false
private var isSleepModeEnabled: Boolean = false

private val isPipSupported by lazy {
// Check if device has PiP feature
Expand Down Expand Up @@ -127,7 +138,9 @@ class PlayerActivity : BasePlayerActivity() {
val skipIntroButton = binding.playerView.findViewById<Button>(R.id.btn_skip_intro)
val pipButton = binding.playerView.findViewById<ImageButton>(R.id.btn_pip)
val lockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_lockview)
val sleepModeButton = binding.playerView.findViewById<ImageButton>(R.id.btn_sleep_mode)
val unlockButton = binding.playerView.findViewById<ImageButton>(R.id.btn_unlock)
val sleepModeUnlockButton = binding.playerView.findViewById<ImageButton>(R.id.sleep_mode_unlock)

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Expand Down Expand Up @@ -163,6 +176,8 @@ class PlayerActivity : BasePlayerActivity() {
speedButton.imageAlpha = 255
pipButton.isEnabled = true
pipButton.imageAlpha = 255
sleepModeButton.isVisible = appPreferences.sleepMode
sleepModeButton.imageAlpha = 255
}
}
}
Expand Down Expand Up @@ -293,6 +308,26 @@ class PlayerActivity : BasePlayerActivity() {
pictureInPicture()
}

sleepModeButton.setOnClickListener {
if (isSleepModeEnabled) {
// Check if the sleep timer is currently running
if (sleepJob?.isActive == true) {
sleepJob?.cancel()
Toast.makeText(applicationContext, CoreR.string.sleep_mode_deactivated, Toast.LENGTH_SHORT).show()
}
isSleepModeEnabled = false
} else {
isSleepModeEnabled = false
wasDialogShown = true
showSleepTimerDurationDialog()
}
}

sleepModeUnlockButton.setOnClickListener {
disableSleepMode()
isSleepModeEnabled = false
}

if (appPreferences.playerTrickPlay) {
val imagePreview = binding.playerView.findViewById<ImageView>(R.id.image_preview)
val timeBar = binding.playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
Expand Down Expand Up @@ -384,4 +419,61 @@ class PlayerActivity : BasePlayerActivity() {
binding.playerView.useController = true
}
}

private fun startSleepTimer(timerDuration: Long) {
sleepJob?.cancel() // Cancel any existing timer job
sleepJob = CoroutineScope(Dispatchers.Main).launch {
delay(timerDuration * 1000)
binding.playerView.player?.pause()
binding.playerView.findViewById<FrameLayout>(R.id.player_controls).visibility = View.GONE
binding.playerView.findViewById<FrameLayout>(R.id.sleep_mode_view).visibility = View.VISIBLE
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
isControlsLocked = true
}
Toast.makeText(applicationContext, formatTimerMessage(timerDuration.formatDuration(resources)), Toast.LENGTH_SHORT).show()
}

private fun showSleepTimerDurationDialog() {
val durationValues = longArrayOf(300L, 600L, 1800L, 3600L, 7200L)
val timerTexts = durationValues.map { it.formatDuration(resources) }.toTypedArray()
val defaultIndex = 1
var selectedDuration = durationValues[defaultIndex]

val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(CoreR.string.select_sleep_timer_duration)
.setSingleChoiceItems(
timerTexts,
defaultIndex,
) { _, which ->
selectedDuration = durationValues[which]
}
.setNegativeButton(CoreR.string.cancel) { dialog, _ ->
dialog.dismiss()
wasDialogShown = false
}
.setPositiveButton(CoreR.string.set) { dialog, _ ->
// Start the sleep timer with the selected duration
startSleepTimer(selectedDuration)
isSleepModeEnabled = true
dialog.dismiss()
}
.setCancelable(true)
wasDialogShown = true
builder.create().show()
}

private fun disableSleepMode() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.playerView.findViewById<FrameLayout>(R.id.sleep_mode_view).visibility = View.GONE
binding.playerView.findViewById<FrameLayout>(R.id.player_controls).visibility = View.VISIBLE
isControlsLocked = false
isSleepModeEnabled = false
}

private fun formatTimerMessage(durationString: String): String {
return getString(
CoreR.string.sleep_mode_activated,
durationString,
)
}
}
43 changes: 31 additions & 12 deletions app/phone/src/main/res/layout/exo_main_controls.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">

<ImageButton
android:id="@+id/btn_sleep_mode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_weight="1"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/enable_sleep_mode"
android:padding="16dp"
android:src="@drawable/ic_sleep_mode"
android:visibility="gone"
app:tint="@android:color/white"
tools:visibility="visible" />

<Space
android:layout_width="16dp"
android:layout_height="0dp"
android:layout_weight="1" />

<ImageButton
android:id="@+id/btn_lockview"
android:layout_width="wrap_content"
Expand Down Expand Up @@ -147,50 +166,50 @@
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_skip_back"
android:padding="16dp"
android:src="@drawable/ic_skip_back"
android:contentDescription="@string/player_controls_skip_back" />
android:src="@drawable/ic_skip_back" />

<ImageButton
android:id="@+id/exo_rew"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_rewind"
android:padding="16dp"
android:src="@drawable/ic_rewind"
android:contentDescription="@string/player_controls_rewind" />
android:src="@drawable/ic_rewind" />

<ImageButton
android:id="@+id/exo_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/circle_background"
android:backgroundTint="@android:color/white"
android:contentDescription="@string/player_controls_play_pause"
android:padding="16dp"
android:src="@drawable/ic_play"
app:tint="@android:color/black"
android:contentDescription="@string/player_controls_play_pause" />
app:tint="@android:color/black" />

<ImageButton
android:id="@+id/exo_ffwd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_fast_forward"
android:padding="16dp"
android:src="@drawable/ic_fast_forward"
android:contentDescription="@string/player_controls_fast_forward" />
android:src="@drawable/ic_fast_forward" />

<ImageButton
android:id="@+id/exo_next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:background="@drawable/transparent_circle_background"
android:contentDescription="@string/player_controls_skip_forward"
android:padding="16dp"
android:src="@drawable/ic_skip_forward"
android:contentDescription="@string/player_controls_skip_forward" />
android:src="@drawable/ic_skip_forward" />
</LinearLayout>

<LinearLayout
Expand All @@ -206,9 +225,9 @@
android:layout_height="90dp"
android:layout_gravity="start"
android:background="@android:color/transparent"
android:contentDescription="@string/player_trickplay"
android:visibility="gone"
tools:visibility="visible"
android:contentDescription="@string/player_trickplay" />
tools:visibility="visible" />

<LinearLayout
android:layout_width="wrap_content"
Expand Down
1 change: 1 addition & 0 deletions app/phone/src/main/res/layout/exo_player_control_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
android:layout_height="match_parent">

<include layout="@layout/exo_main_controls"/>
<include layout="@layout/sleep_mode_layout"/>

<include
layout="@layout/exo_locked_controls"
Expand Down
57 changes: 57 additions & 0 deletions app/phone/src/main/res/layout/sleep_mode_layout.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/sleep_mode_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/player_background">

<ImageButton
android:id="@+id/sleep_mode_unlock"
android:layout_width="92dp"
android:layout_height="92dp"
android:layout_gravity="end"
android:background="@drawable/rounded_corner"
android:contentDescription="@string/select_playback_speed"
android:padding="16dp"
android:src="@drawable/ic_unlock_big"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sleep_mode_description"
app:tint="@android:color/white" />

<TextView
android:id="@+id/sleep_mode_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sleep_mode_enabled_title"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.DisplayMedium"
android:textColor="@color/neutral_200"
app:layout_constraintBottom_toTopOf="@id/sleep_mode_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/sleep_mode_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sleep_mode_enabled_description"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="@color/neutral_200"
app:layout_constraintBottom_toTopOf="@id/sleep_mode_unlock"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sleep_mode_title" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
35 changes: 35 additions & 0 deletions core/src/main/java/dev/jdtech/jellyfin/utils/CoreExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.View
import org.jellyfin.sdk.model.api.BaseItemDto
import java.io.Serializable
import dev.jdtech.jellyfin.core.R as CoreR

fun BaseItemDto.toView(): View {
return View(
Expand Down Expand Up @@ -48,3 +49,37 @@ fun Activity.restart() {
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}

fun Long.formatDuration(resources: Resources): String {
val hours = (this / 3600L).toInt()
val minutes = ((this % 3600L) / 60L).toInt()
val seconds = ((this % 3600L) % 60L).toInt()

val stringParts = mutableListOf<String>()

if (hours > 0) {
val hourText = resources.getQuantityString(
CoreR.plurals.hour,
hours,
)
stringParts.add("$hours $hourText")
}

if (minutes > 0) {
val minuteText = resources.getQuantityString(
CoreR.plurals.minute,
minutes,
)
stringParts.add("$minutes $minuteText")
}

if (seconds > 0) {
val secondText = resources.getQuantityString(
CoreR.plurals.second,
seconds,
)
stringParts.add("$seconds $secondText")
}

return stringParts.joinToString(" ")
}
8 changes: 8 additions & 0 deletions core/src/main/res/drawable/ic_moon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000"
android:pathData="M12,3a6,6 0,0 0,9 9,9 9,0 1,1 -9,-9Z"
android:strokeColor="@android:color/white" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>
17 changes: 17 additions & 0 deletions core/src/main/res/drawable/ic_sleep_mode.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:pathData="M2,4v16"
android:strokeColor="@android:color/white" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M2,8h18a2,2 0,0 1,2 2v10"
android:strokeColor="@android:color/white" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M2,17h20"
android:strokeColor="@android:color/white" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M6,8v9"
android:strokeColor="@android:color/white" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>
6 changes: 6 additions & 0 deletions core/src/main/res/drawable/ic_unlock_big.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="36dp"
android:height="36dp"
android:drawable="@drawable/ic_unlock" />
</layer-list>
Loading