Add intro skipper support (#219)

* Add intro skipper support

* Fix checking for 404

* Add back missing Intro class and dependencies due to rebase

* Add preference

* Clean up visibility logic

* Update skip intro button design

* Add proguard file to keep Serializable classes

* Move introCheck to a separate Runnable and fix Runnables are never cleaned up

* Simplify check before starting runnable

Co-authored-by: Jarne Demeulemeester <jarnedemeulemeester@gmail.com>
This commit is contained in:
js6pak 2023-01-15 15:20:56 +01:00 committed by GitHub
parent 583c14e4f1
commit 31fd1e3fdc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 166 additions and 6 deletions

View file

@ -5,9 +5,11 @@ import android.media.AudioManager
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.TrackSelectionDialogBuilder import androidx.media3.ui.TrackSelectionDialogBuilder
@ -70,6 +72,7 @@ class PlayerActivity : BasePlayerActivity() {
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track) val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle) val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed) val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed)
val skipIntroButton = binding.playerView.findViewById<Button>(R.id.btn_skip_intro)
audioButton.isEnabled = false audioButton.isEnabled = false
audioButton.imageAlpha = 75 audioButton.imageAlpha = 75
@ -155,6 +158,16 @@ class PlayerActivity : BasePlayerActivity() {
) )
} }
viewModel.currentIntro.observe(this) {
skipIntroButton.isVisible = it != null
}
skipIntroButton.setOnClickListener {
viewModel.currentIntro.value?.let {
binding.playerView.player?.seekTo((it.introEnd * 1000).toLong())
}
}
viewModel.fileLoaded.observe(this) { viewModel.fileLoaded.observe(this) {
if (it) { if (it) {
audioButton.isEnabled = true audioButton.isEnabled = true

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android" <merge 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"> xmlns:tools="http://schemas.android.com/tools">
<androidx.media3.ui.AspectRatioFrameLayout <androidx.media3.ui.AspectRatioFrameLayout
@ -46,6 +47,24 @@
android:textColor="@color/white" android:textColor="@color/white"
android:textSize="14sp" /> android:textSize="14sp" />
<Button
android:id="@+id/btn_skip_intro"
style="@style/Widget.Material3.Button.OutlinedButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_marginEnd="24dp"
android:layout_marginBottom="64dp"
android:text="@string/player_controls_skip_intro"
android:textColor="@android:color/white"
android:visibility="gone"
app:backgroundTint="@color/player_background"
app:icon="@drawable/ic_skip_forward"
app:iconGravity="end"
app:iconTint="@android:color/white"
app:strokeColor="@android:color/white"
tools:visibility="visible" />
</androidx.media3.ui.AspectRatioFrameLayout> </androidx.media3.ui.AspectRatioFrameLayout>
<androidx.media3.ui.SubtitleView <androidx.media3.ui.SubtitleView

View file

@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.androidx.navigation.safeargs) apply false alias(libs.plugins.androidx.navigation.safeargs) apply false
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.aboutlibraries) apply false alias(libs.plugins.aboutlibraries) apply false

View file

@ -118,6 +118,7 @@
<string name="player_controls_fast_forward">Fast forward</string> <string name="player_controls_fast_forward">Fast forward</string>
<string name="player_controls_pause">Pause</string> <string name="player_controls_pause">Pause</string>
<string name="player_controls_skip">Skip</string> <string name="player_controls_skip">Skip</string>
<string name="player_controls_skip_intro">Skip Intro</string>
<string name="track_selection">[%1$s] %2$s (%3$s)</string> <string name="track_selection">[%1$s] %2$s (%3$s)</string>
<string name="add_server_empty_error">Empty server address</string> <string name="add_server_empty_error">Empty server address</string>
<string name="display_extended_title_summary">Display extended episode title including season and episode information (SXX:EXX - EpisodeName).</string> <string name="display_extended_title_summary">Display extended episode title including season and episode information (SXX:EXX - EpisodeName).</string>
@ -143,6 +144,8 @@
<string name="pref_player_mpv_vo">Video output</string> <string name="pref_player_mpv_vo">Video output</string>
<string name="pref_player_mpv_ao">Audio output</string> <string name="pref_player_mpv_ao">Audio output</string>
<string name="pref_player_mpv_gpu_api" translatable="false">GPU API</string> <string name="pref_player_mpv_gpu_api" translatable="false">GPU API</string>
<string name="pref_player_intro_skipper">Intro Skipper</string>
<string name="pref_player_intro_skipper_summary">Requires ConfusedPolarBear\'s Intro Skipper plugin to be installed on the server</string>
<string name="addresses">Addresses</string> <string name="addresses">Addresses</string>
<string name="add_address">Add address</string> <string name="add_address">Add address</string>
<string name="add_server_address">Add server address</string> <string name="add_server_address">Add server address</string>

View file

@ -92,4 +92,10 @@
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
</PreferenceCategory> </PreferenceCategory>
<SwitchPreference
app:defaultValue="true"
app:key="pref_player_intro_skipper"
app:title="@string/pref_player_intro_skipper"
app:summary="@string/pref_player_intro_skipper_summary"/>
</PreferenceScreen> </PreferenceScreen>

View file

@ -2,6 +2,7 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktlint) alias(libs.plugins.ktlint)
} }
@ -17,6 +18,8 @@ android {
val appVersionName: String by rootProject.extra val appVersionName: String by rootProject.extra
buildConfigField("int", "VERSION_CODE", appVersionCode.toString()) buildConfigField("int", "VERSION_CODE", appVersionCode.toString())
buildConfigField("String", "VERSION_NAME", "\"$appVersionName\"") buildConfigField("String", "VERSION_NAME", "\"$appVersionName\"")
consumerProguardFile("proguard-rules.pro")
} }
buildTypes { buildTypes {
@ -48,5 +51,6 @@ dependencies {
implementation(project(":preferences")) implementation(project(":preferences"))
implementation(libs.androidx.paging) implementation(libs.androidx.paging)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.timber) implementation(libs.timber)
} }

40
data/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,40 @@
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
# If you have any, uncomment and replace classes with those containing named companion objects.
#-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
#-if @kotlinx.serialization.Serializable class
#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions.
#com.example.myapplication.HasNamedCompanion2
#{
# static **$* *;
#}
#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
# static <1>$$serializer INSTANCE;
#}

View file

@ -0,0 +1,16 @@
package dev.jdtech.jellyfin.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Intro(
@SerialName("IntroStart")
val introStart: Double,
@SerialName("IntroEnd")
val introEnd: Double,
@SerialName("ShowSkipPromptAt")
val showSkipPromptAt: Double,
@SerialName("HideSkipPromptAt")
val hideSkipPromptAt: Double
)

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin.repository package dev.jdtech.jellyfin.repository
import androidx.paging.PagingData import androidx.paging.PagingData
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.models.SortBy
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -60,6 +61,8 @@ interface JellyfinRepository {
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String
suspend fun getIntroTimestamps(itemId: UUID): Intro?
suspend fun postCapabilities() suspend fun postCapabilities()
suspend fun postPlaybackStart(itemId: UUID) suspend fun postPlaybackStart(itemId: UUID)

View file

@ -4,11 +4,14 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import dev.jdtech.jellyfin.api.JellyfinApi import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy import dev.jdtech.jellyfin.models.SortBy
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.get
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceOptionsDto import org.jellyfin.sdk.model.api.DeviceOptionsDto
@ -215,6 +218,20 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
} }
} }
override suspend fun getIntroTimestamps(itemId: UUID): Intro? =
withContext(Dispatchers.IO) {
// https://github.com/ConfusedPolarBear/intro-skipper/blob/master/docs/api.md
val pathParameters = mutableMapOf<String, UUID>()
pathParameters["itemId"] = itemId
try {
return@withContext jellyfinApi.api.get<Intro>("/Episode/{itemId}/IntroTimestamps/v1", pathParameters).content
} catch (e: InvalidStatusException) {
if (e.status != 404) throw e
return@withContext null
}
}
override suspend fun postCapabilities() { override suspend fun postCapabilities() {
Timber.d("Sending capabilities") Timber.d("Sending capabilities")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View file

@ -19,6 +19,7 @@ glide = "4.14.2"
hilt = "2.44.2" hilt = "2.44.2"
jellyfin = "1.4.0" jellyfin = "1.4.0"
kotlin = "1.8.0" kotlin = "1.8.0"
kotlinx-serialization = "1.4.1"
ktlint = "11.0.0" ktlint = "11.0.0"
libmpv = "0.1.0" libmpv = "0.1.0"
material = "1.7.0" material = "1.7.0"
@ -56,6 +57,7 @@ jellyfin-core = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jell
libmpv = { module = "dev.jdtech.mpv:libmpv", version.ref = "libmpv" } libmpv = { module = "dev.jdtech.mpv:libmpv", version.ref = "libmpv" }
material = { module = "com.google.android.material:material", version.ref = "material" } material = { module = "com.google.android.material:material", version.ref = "material" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
[plugins] [plugins]
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
@ -66,4 +68,5 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }

View file

@ -17,6 +17,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType import dev.jdtech.jellyfin.mpv.TrackType
@ -46,6 +47,10 @@ constructor(
private val _currentItemTitle = MutableLiveData<String>() private val _currentItemTitle = MutableLiveData<String>()
val currentItemTitle: LiveData<String> = _currentItemTitle val currentItemTitle: LiveData<String> = _currentItemTitle
private val intros: MutableMap<UUID, Intro> = mutableMapOf()
private val _currentIntro = MutableLiveData<Intro?>(null)
val currentIntro: LiveData<Intro?> = _currentIntro
var currentAudioTracks: MutableList<MPVPlayer.Companion.Track> = mutableListOf() var currentAudioTracks: MutableList<MPVPlayer.Companion.Track> = mutableListOf()
var currentSubtitleTracks: MutableList<MPVPlayer.Companion.Track> = mutableListOf() var currentSubtitleTracks: MutableList<MPVPlayer.Companion.Track> = mutableListOf()
@ -63,6 +68,8 @@ constructor(
var playbackSpeed: Float = 1f var playbackSpeed: Float = 1f
var disableSubtitle: Boolean = false var disableSubtitle: Boolean = false
private val handler = Handler(Looper.getMainLooper())
init { init {
if (appPreferences.playerMpv) { if (appPreferences.playerMpv) {
player = MPVPlayer( player = MPVPlayer(
@ -119,6 +126,11 @@ constructor(
} }
playFromDownloads = item.mediaSourceUri.isNotEmpty() playFromDownloads = item.mediaSourceUri.isNotEmpty()
if (appPreferences.playerIntroSkipper) {
val intro = jellyfinRepository.getIntroTimestamps(item.itemId)
if (intro != null) intros[item.itemId] = intro
}
Timber.d("Stream url: $streamUrl") Timber.d("Stream url: $streamUrl")
val mediaItem = val mediaItem =
MediaItem.Builder() MediaItem.Builder()
@ -167,17 +179,17 @@ constructor(
} }
private fun pollPosition(player: Player) { private fun pollPosition(player: Player) {
val handler = Handler(Looper.getMainLooper()) val playbackProgressRunnable = object : Runnable {
val runnable = object : Runnable {
override fun run() { override fun run() {
viewModelScope.launch { viewModelScope.launch {
if (player.currentMediaItem != null && player.currentMediaItem!!.mediaId.isNotEmpty()) { if (player.currentMediaItem != null && player.currentMediaItem!!.mediaId.isNotEmpty()) {
val itemId = UUID.fromString(player.currentMediaItem!!.mediaId)
if (playFromDownloads) { if (playFromDownloads) {
postDownloadPlaybackProgress(downloadDatabase, items[0].itemId, player.currentPosition, (player.currentPosition.toDouble() / player.duration.toDouble()).times(100)) // TODO Automatically use the correct item postDownloadPlaybackProgress(downloadDatabase, itemId, player.currentPosition, (player.currentPosition.toDouble() / player.duration.toDouble()).times(100)) // TODO Automatically use the correct item
} }
try { try {
jellyfinRepository.postPlaybackProgress( jellyfinRepository.postPlaybackProgress(
UUID.fromString(player.currentMediaItem!!.mediaId), itemId,
player.currentPosition.times(10000), player.currentPosition.times(10000),
!player.isPlaying !player.isPlaying
) )
@ -186,10 +198,30 @@ constructor(
} }
} }
} }
handler.postDelayed(this, 5000) handler.postDelayed(this, 5000L)
} }
} }
handler.post(runnable) val introCheckRunnable = object : Runnable {
override fun run() {
if (player.currentMediaItem != null && player.currentMediaItem!!.mediaId.isNotEmpty()) {
val itemId = UUID.fromString(player.currentMediaItem!!.mediaId)
intros[itemId].let {
if (it != null) {
val seconds = player.currentPosition / 1000.0
if (seconds > it.showSkipPromptAt && seconds < it.hideSkipPromptAt) {
_currentIntro.value = it
return@let
}
}
_currentIntro.value = null
}
}
handler.postDelayed(this, 1000L)
}
}
handler.post(playbackProgressRunnable)
if (intros.isNotEmpty()) handler.post(introCheckRunnable)
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@ -254,6 +286,7 @@ constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
Timber.d("Clearing Player ViewModel") Timber.d("Clearing Player ViewModel")
handler.removeCallbacksAndMessages(null)
releasePlayer() releasePlayer()
} }

View file

@ -62,6 +62,7 @@ constructor(
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu")!! val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu")!!
val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!! val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!!
val playerMpvGpuApi get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_GPU_API, "opengl")!! val playerMpvGpuApi get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_GPU_API, "opengl")!!
val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
// Language // Language
val preferredAudioLanguage get() = sharedPreferences.getString(Constants.PREF_AUDIO_LANGUAGE, "")!! val preferredAudioLanguage get() = sharedPreferences.getString(Constants.PREF_AUDIO_LANGUAGE, "")!!

View file

@ -23,6 +23,7 @@ object Constants {
const val PREF_PLAYER_MPV_VO = "pref_player_mpv_vo" const val PREF_PLAYER_MPV_VO = "pref_player_mpv_vo"
const val PREF_PLAYER_MPV_AO = "pref_player_mpv_ao" const val PREF_PLAYER_MPV_AO = "pref_player_mpv_ao"
const val PREF_PLAYER_MPV_GPU_API = "pref_player_mpv_gpu_api" const val PREF_PLAYER_MPV_GPU_API = "pref_player_mpv_gpu_api"
const val PREF_PLAYER_INTRO_SKIPPER = "pref_player_intro_skipper"
const val PREF_AUDIO_LANGUAGE = "pref_audio_language" const val PREF_AUDIO_LANGUAGE = "pref_audio_language"
const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language" const val PREF_SUBTITLE_LANGUAGE = "pref_subtitle_language"
const val PREF_IMAGE_CACHE = "pref_image_cache" const val PREF_IMAGE_CACHE = "pref_image_cache"