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:
parent
583c14e4f1
commit
31fd1e3fdc
14 changed files with 166 additions and 6 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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
40
data/proguard-rules.pro
vendored
Normal 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;
|
||||
#}
|
16
data/src/main/java/dev/jdtech/jellyfin/models/Intro.kt
Normal file
16
data/src/main/java/dev/jdtech/jellyfin/models/Intro.kt
Normal 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
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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, "")!!
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue