Skip to content

Commit

Permalink
Feat: Background playback (#1103)
Browse files Browse the repository at this point in the history
* add background playback initial support

* add initial background playback support

* fix issue with surface view and view initialization

* add background playback button

* remove unused preference

* run ktlintFormat

* fix background playback toggle

* fix keep screen on

* fix issue with status bars visibility

* some minor fixes

* run ktlintFormat

* move save medium state logic to player service

* get and set title from the media metadata

* use exoplayer playlist

* fix state not saved between lifecycle events

* save state when media session disconnected from player activity

* play from last played position if preference resume is yes

* fix view closes when resetting the playlist

* restore state of the media

* fix subtitle launcher to select external subtitles

* fix issues with restoring media state

* auto select newly added external subtitle

* fix state race condition with coroutines

* update subtitle track selection for external subtitle that just added

* run ktlintFormat

* use futures to set mediaitem data

* use async to make it more performant

* update logic to save media state

* move custom commands to separate folders

* update last played state when updating medium state

* remove unused functions

* some refactoring

* add local subs to media item

* fix issue with add external subs

* refactor uri to subtitle configuration function

* save external subtitle to database

* fix issue with resume video

* auto select newly added external subtitle

* move shared controller future creation logic to seprate function

* fix issue with selecting external subtitle

* fix issue with saving and restore audio and subtitle track indices

* add custom command to set skip silence enabled

* add custom command to get skip silence enabled

* fix issue with media not playing when moving player into background and then foreground

* fix crash on clicking on the notification

* remove unnecessary restore playback position

* lint: run ktlintFormat

* improve performance of setting large playlists

* get path only if path is null

* restore track selections only if remember selections is on

* if auto play is disabled exit after playback is ended

* restore tracks asap to fix issue with audio

* lint: run ktlintFormat

* fix restore media position

* rename player to mediaController in player activity

* save and restore video zoom values

* add close button to notification to close the player completely

* add auto background playback preference

* lint: run ktlintFormat

* fix issue with first video is played when opened from file manager

* fix issue with video scale is zero sometimes

* fix issue with position not saved when service is destroyed

* fix issue with video not marked as completed
  • Loading branch information
anilbeesetti authored Dec 7, 2024
1 parent 7154a05 commit 48513fc
Show file tree
Hide file tree
Showing 29 changed files with 1,042 additions and 516 deletions.
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
package dev.anilbeesetti.nextplayer.core.common.extensions

import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.core.net.toUri
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

fun File.getSubtitles(): List<File> {
val mediaName = this.nameWithoutExtension
val subs = this.parentFile?.listFiles { file ->
file.nameWithoutExtension.startsWith(mediaName) && file.isSubtitle()
}?.toList() ?: emptyList()
suspend fun File.getSubtitles(): List<File> = withContext(Dispatchers.IO) {
val mediaName = this@getSubtitles.nameWithoutExtension
val parentDir = this@getSubtitles.parentFile
val subtitleExtensions = listOf("srt", "ssa", "ass", "vtt", "ttml")

subtitleExtensions.mapNotNull { extension ->
val file = File(parentDir, "$mediaName.$extension")
file.takeIf { it.exists() }
}
}

return subs
suspend fun File.getLocalSubtitles(
context: Context,
excludeSubsList: List<Uri> = emptyList(),
): List<Uri> = withContext(Dispatchers.Default) {
val excludeSubsPathSet = excludeSubsList.mapNotNull { context.getPath(it) }.toSet()

getSubtitles().mapNotNull { file ->
if (file.path !in excludeSubsPathSet) {
file.toUri()
} else {
null
}
}
}

fun String.getThumbnail(): File? {
Expand All @@ -24,7 +46,7 @@ fun String.getThumbnail(): File? {

fun File.isSubtitle(): Boolean {
val subtitleExtensions = listOf("srt", "ssa", "ass", "vtt", "ttml")
return extension in subtitleExtensions
return extension.lowercase() in subtitleExtensions
}

fun File.deleteFiles() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import dev.anilbeesetti.nextplayer.core.database.entities.MediumEntity
fun MediumEntity.toVideoState(): VideoState {
return VideoState(
path = path,
position = playbackPosition,
title = name,
position = playbackPosition.takeIf { it != 0L },
audioTrackIndex = audioTrackIndex,
subtitleTrackIndex = subtitleTrackIndex,
playbackSpeed = playbackSpeed,
externalSubs = UriListConverter.fromStringToList(externalSubs),
videoScale = videoScale,
thumbnailPath = thumbnailPath,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import android.net.Uri

data class VideoState(
val path: String,
val position: Long,
val title: String,
val position: Long?,
val audioTrackIndex: Int?,
val subtitleTrackIndex: Int?,
val playbackSpeed: Float?,
val externalSubs: List<Uri>,
val videoScale: Float,
val thumbnailPath: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import timber.log.Timber

class LocalMediaRepository @Inject constructor(
private val mediumDao: MediumDao,
Expand All @@ -42,29 +41,52 @@ class LocalMediaRepository @Inject constructor(
return mediumDao.get(uri)?.toVideoState()
}

override suspend fun saveVideoState(
uri: String,
position: Long,
audioTrackIndex: Int?,
subtitleTrackIndex: Int?,
playbackSpeed: Float?,
externalSubs: List<Uri>,
videoScale: Float,
) {
Timber.d(
"save state for [$uri]: [$position, $audioTrackIndex, $subtitleTrackIndex, $playbackSpeed]",
)

override fun updateMediumPosition(uri: String, position: Long) {
applicationScope.launch {
mediumDao.updateMediumState(
val duration = mediumDao.get(uri)?.duration ?: position.plus(1)
mediumDao.updateMediumPosition(
uri = uri,
position = position,
audioTrackIndex = audioTrackIndex,
subtitleTrackIndex = subtitleTrackIndex,
playbackSpeed = playbackSpeed,
externalSubs = UriListConverter.fromListToString(externalSubs),
lastPlayedTime = System.currentTimeMillis(),
videoScale = videoScale,
position = position.takeIf { it < duration } ?: Long.MIN_VALUE.plus(1),
)
mediumDao.updateMediumLastPlayedTime(uri, System.currentTimeMillis())
}
}

override fun updateMediumPlaybackSpeed(uri: String, playbackSpeed: Float) {
applicationScope.launch {
mediumDao.updateMediumPlaybackSpeed(uri, playbackSpeed)
mediumDao.updateMediumLastPlayedTime(uri, System.currentTimeMillis())
}
}

override fun updateMediumAudioTrack(uri: String, audioTrackIndex: Int) {
applicationScope.launch {
mediumDao.updateMediumAudioTrack(uri, audioTrackIndex)
mediumDao.updateMediumLastPlayedTime(uri, System.currentTimeMillis())
}
}

override fun updateMediumSubtitleTrack(uri: String, subtitleTrackIndex: Int) {
applicationScope.launch {
mediumDao.updateMediumSubtitleTrack(uri, subtitleTrackIndex)
mediumDao.updateMediumLastPlayedTime(uri, System.currentTimeMillis())
}
}

override fun updateMediumZoom(uri: String, zoom: Float) {
applicationScope.launch {
mediumDao.updateMediumZoom(uri, zoom)
mediumDao.updateMediumLastPlayedTime(uri, System.currentTimeMillis())
}
}

override fun addExternalSubtitleToMedium(uri: String, subtitleUri: Uri) {
applicationScope.launch {
val currentExternalSubs = getVideoState(uri)?.externalSubs ?: emptyList()
if (currentExternalSubs.contains(subtitleUri)) return@launch
mediumDao.addExternalSubtitle(
mediumUri = uri,
externalSubs = UriListConverter.fromListToString(urlList = currentExternalSubs + subtitleUri),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ interface MediaRepository {
fun getVideosFlow(): Flow<List<Video>>
fun getVideosFlowFromFolderPath(folderPath: String): Flow<List<Video>>
fun getFoldersFlow(): Flow<List<Folder>>
suspend fun saveVideoState(
uri: String,
position: Long,
audioTrackIndex: Int?,
subtitleTrackIndex: Int?,
playbackSpeed: Float?,
externalSubs: List<Uri>,
videoScale: Float,
)

suspend fun getVideoState(uri: String): VideoState?

fun updateMediumPosition(uri: String, position: Long)
fun updateMediumPlaybackSpeed(uri: String, playbackSpeed: Float)
fun updateMediumAudioTrack(uri: String, audioTrackIndex: Int)
fun updateMediumSubtitleTrack(uri: String, subtitleTrackIndex: Int)
fun updateMediumZoom(uri: String, zoom: Float)

fun addExternalSubtitleToMedium(uri: String, subtitleUri: Uri)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,25 @@ class FakeMediaRepository : MediaRepository {
return flowOf(directories)
}

override suspend fun saveVideoState(
uri: String,
position: Long,
audioTrackIndex: Int?,
subtitleTrackIndex: Int?,
playbackSpeed: Float?,
externalSubs: List<Uri>,
videoScale: Float,
) {
}

override suspend fun getVideoState(uri: String): VideoState? {
return null
}

override fun updateMediumPosition(uri: String, position: Long) {
}

override fun updateMediumPlaybackSpeed(uri: String, playbackSpeed: Float) {
}

override fun updateMediumAudioTrack(uri: String, audioTrackIndex: Int) {
}

override fun updateMediumSubtitleTrack(uri: String, subtitleTrackIndex: Int) {
}

override fun updateMediumZoom(uri: String, zoom: Float) {
}

override fun addExternalSubtitleToMedium(uri: String, subtitleUri: Uri) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ interface MediumDao {
@Query("SELECT * FROM media WHERE uri = :uri")
suspend fun get(uri: String): MediumEntity?

@Query("SELECT * FROM media WHERE uri = :uri")
fun getAsFlow(uri: String): Flow<MediumEntity?>

@Query("SELECT * FROM media")
fun getAll(): Flow<List<MediumEntity>>

Expand All @@ -44,15 +47,25 @@ interface MediumDao {
@Query("DELETE FROM media WHERE uri in (:uris)")
suspend fun delete(uris: List<String>)

@Query(
"UPDATE OR REPLACE media SET " +
"external_subs = :externalSubs, " +
"video_scale = :videoScale " +
"WHERE uri = :uri",
)
suspend fun updateMediumUiState(
uri: String,
externalSubs: String,
videoScale: Float,
)

@Query(
"UPDATE OR REPLACE media SET " +
"playback_position = :position, " +
"audio_track_index = :audioTrackIndex, " +
"subtitle_track_index = :subtitleTrackIndex, " +
"playback_speed = :playbackSpeed, " +
"external_subs = :externalSubs, " +
"last_played_time = :lastPlayedTime, " +
"video_scale = :videoScale " +
"last_played_time = :lastPlayedTime " +
"WHERE uri = :uri",
)
suspend fun updateMediumState(
Expand All @@ -61,11 +74,48 @@ interface MediumDao {
audioTrackIndex: Int?,
subtitleTrackIndex: Int?,
playbackSpeed: Float?,
externalSubs: String,
lastPlayedTime: Long?,
videoScale: Float,
)

@Query("UPDATE OR REPLACE media SET playback_position = :position WHERE uri = :uri")
suspend fun updateMediumPosition(
uri: String,
position: Long,
)

@Query("UPDATE OR REPLACE media SET playback_speed = :playbackSpeed WHERE uri = :uri")
suspend fun updateMediumPlaybackSpeed(
uri: String,
playbackSpeed: Float,
)

@Query("UPDATE OR REPLACE media SET audio_track_index = :audioTrackIndex WHERE uri = :uri")
suspend fun updateMediumAudioTrack(
uri: String,
audioTrackIndex: Int,
)

@Query("UPDATE OR REPLACE media SET subtitle_track_index = :subtitleTrackIndex WHERE uri = :uri")
suspend fun updateMediumSubtitleTrack(
uri: String,
subtitleTrackIndex: Int,
)

@Query("UPDATE OR REPLACE media SET video_scale = :zoom WHERE uri = :uri")
suspend fun updateMediumZoom(
uri: String,
zoom: Float,
)

@Query("UPDATE OR REPLACE media SET last_played_time = :lastPlayedTime WHERE uri = :uri")
suspend fun updateMediumLastPlayedTime(
uri: String,
lastPlayedTime: Long,
)

@Query("UPDATE OR REPLACE media SET external_subs = :externalSubs WHERE uri = :mediumUri")
suspend fun addExternalSubtitle(mediumUri: String, externalSubs: String)

@Upsert
fun upsertVideoStreamInfo(videoStreamInfoEntity: VideoStreamInfoEntity)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dev.anilbeesetti.nextplayer.core.common.Dispatcher
import dev.anilbeesetti.nextplayer.core.common.NextDispatchers
import dev.anilbeesetti.nextplayer.core.common.extensions.getPath
import dev.anilbeesetti.nextplayer.core.data.repository.PreferencesRepository
import dev.anilbeesetti.nextplayer.core.model.MediaViewMode
import dev.anilbeesetti.nextplayer.core.model.Video
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
Expand All @@ -14,13 +17,16 @@ import kotlinx.coroutines.withContext

class GetSortedPlaylistUseCase @Inject constructor(
private val getSortedVideosUseCase: GetSortedVideosUseCase,
private val preferencesRepository: PreferencesRepository,
@ApplicationContext private val context: Context,
@Dispatcher(NextDispatchers.Default) private val defaultDispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(uri: Uri): List<Uri> = withContext(defaultDispatcher) {
suspend operator fun invoke(uri: Uri): List<Video> = withContext(defaultDispatcher) {
val path = context.getPath(uri) ?: return@withContext emptyList()
val parent = File(path).parent
val parent = File(path).parent.takeIf {
preferencesRepository.applicationPreferences.first().mediaViewMode != MediaViewMode.VIDEOS
}

getSortedVideosUseCase.invoke(parent).first().map { Uri.parse(it.uriString) }
getSortedVideosUseCase.invoke(parent).first()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data class PlayerPreferences(
val seekIncrement: Int = 10,
val autoplay: Boolean = true,
val autoPip: Boolean = true,
val autoBackgroundPlay: Boolean = false,

// Controls (Gestures)
val useSwipeControls: Boolean = true,
Expand Down
5 changes: 5 additions & 0 deletions core/ui/src/main/res/drawable/ic_close.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">

<path android:fillColor="@android:color/white" android:pathData="M18.3,5.71c-0.39,-0.39 -1.02,-0.39 -1.41,0L12,10.59 7.11,5.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41L10.59,12 5.7,16.89c-0.39,0.39 -0.39,1.02 0,1.41 0.39,0.39 1.02,0.39 1.41,0L12,13.41l4.89,4.89c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L13.41,12l4.89,-4.89c0.38,-0.38 0.38,-1.02 0,-1.4z"/>

</vector>
5 changes: 5 additions & 0 deletions core/ui/src/main/res/drawable/ic_headset.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">

<path android:fillColor="@android:color/white" android:pathData="M11.4,1.02C6.62,1.33 3,5.52 3,10.31V17c0,1.66 1.34,3 3,3h1c1.1,0 2,-0.9 2,-2v-4c0,-1.1 -0.9,-2 -2,-2H5v-1.71C5,6.45 7.96,3.11 11.79,3 15.76,2.89 19,6.06 19,10v2h-2c-1.1,0 -2,0.9 -2,2v4c0,1.1 0.9,2 2,2h1c1.66,0 3,-1.34 3,-3v-7c0,-5.17 -4.36,-9.32 -9.6,-8.98z"/>

</vector>
3 changes: 3 additions & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,7 @@
<string name="control_buttons_alignment_left">Left</string>
<string name="control_buttons_alignment_right">Right</string>
<string name="control_buttons_alignment">Control buttons alignment</string>
<string name="stop_player_session">Stop player session</string>
<string name="background_play">Background play</string>
<string name="background_play_description">Play in background when tapped on home or screen is locked.</string>
</resources>
3 changes: 3 additions & 0 deletions feature/player/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ dependencies {
implementation(libs.github.anilbeesetti.nextlib.media3ext)
implementation(libs.github.anilbeesetti.nextlib.mediainfo)

implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.guava)

// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
Expand Down
Loading

0 comments on commit 48513fc

Please sign in to comment.