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.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.TrackSelectionDialogBuilder
@ -70,6 +72,7 @@ class PlayerActivity : BasePlayerActivity() {
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
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.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) {
if (it) {
audioButton.isEnabled = true

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<androidx.media3.ui.AspectRatioFrameLayout
@ -46,6 +47,24 @@
android:textColor="@color/white"
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.SubtitleView

View file

@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) 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.hilt) 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_pause">Pause</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="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>
@ -143,6 +144,8 @@
<string name="pref_player_mpv_vo">Video 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_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="add_address">Add address</string>
<string name="add_server_address">Add server address</string>

View file

@ -92,4 +92,10 @@
app:useSimpleSummaryProvider="true" />
</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>

View file

@ -2,6 +2,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktlint)
}
@ -17,6 +18,8 @@ android {
val appVersionName: String by rootProject.extra
buildConfigField("int", "VERSION_CODE", appVersionCode.toString())
buildConfigField("String", "VERSION_NAME", "\"$appVersionName\"")
consumerProguardFile("proguard-rules.pro")
}
buildTypes {
@ -48,5 +51,6 @@ dependencies {
implementation(project(":preferences"))
implementation(libs.androidx.paging)
implementation(libs.jellyfin.core)
implementation(libs.kotlinx.serialization.json)
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
import androidx.paging.PagingData
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy
import java.util.UUID
import kotlinx.coroutines.flow.Flow
@ -60,6 +61,8 @@ interface JellyfinRepository {
suspend fun getStreamUrl(itemId: UUID, mediaSourceId: String): String
suspend fun getIntroTimestamps(itemId: UUID): Intro?
suspend fun postCapabilities()
suspend fun postPlaybackStart(itemId: UUID)

View file

@ -4,11 +4,14 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.SortBy
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
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.BaseItemKind
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() {
Timber.d("Sending capabilities")
withContext(Dispatchers.IO) {

View file

@ -19,6 +19,7 @@ glide = "4.14.2"
hilt = "2.44.2"
jellyfin = "1.4.0"
kotlin = "1.8.0"
kotlinx-serialization = "1.4.1"
ktlint = "11.0.0"
libmpv = "0.1.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" }
material = { module = "com.google.android.material:material", version.ref = "material" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
[plugins]
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-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", 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" }

View file

@ -17,6 +17,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.Intro
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
@ -46,6 +47,10 @@ constructor(
private val _currentItemTitle = MutableLiveData<String>()
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 currentSubtitleTracks: MutableList<MPVPlayer.Companion.Track> = mutableListOf()
@ -63,6 +68,8 @@ constructor(
var playbackSpeed: Float = 1f
var disableSubtitle: Boolean = false
private val handler = Handler(Looper.getMainLooper())
init {
if (appPreferences.playerMpv) {
player = MPVPlayer(
@ -119,6 +126,11 @@ constructor(
}
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")
val mediaItem =
MediaItem.Builder()
@ -167,17 +179,17 @@ constructor(
}
private fun pollPosition(player: Player) {
val handler = Handler(Looper.getMainLooper())
val runnable = object : Runnable {
val playbackProgressRunnable = object : Runnable {
override fun run() {
viewModelScope.launch {
if (player.currentMediaItem != null && player.currentMediaItem!!.mediaId.isNotEmpty()) {
val itemId = UUID.fromString(player.currentMediaItem!!.mediaId)
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 {
jellyfinRepository.postPlaybackProgress(
UUID.fromString(player.currentMediaItem!!.mediaId),
itemId,
player.currentPosition.times(10000),
!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) {
@ -254,6 +286,7 @@ constructor(
override fun onCleared() {
super.onCleared()
Timber.d("Clearing Player ViewModel")
handler.removeCallbacksAndMessages(null)
releasePlayer()
}

View file

@ -62,6 +62,7 @@ constructor(
val playerMpvVo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_VO, "gpu")!!
val playerMpvAo get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_AO, "audiotrack")!!
val playerMpvGpuApi get() = sharedPreferences.getString(Constants.PREF_PLAYER_MPV_GPU_API, "opengl")!!
val playerIntroSkipper get() = sharedPreferences.getBoolean(Constants.PREF_PLAYER_INTRO_SKIPPER, true)
// 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_AO = "pref_player_mpv_ao"
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_SUBTITLE_LANGUAGE = "pref_subtitle_language"
const val PREF_IMAGE_CACHE = "pref_image_cache"