diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c77de39..4a1fcada 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,7 +48,7 @@ android { targetSdk = 30 versionCode = 54 - versionName = "3.2.1" + versionName = "3.2.2" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") @@ -190,7 +190,7 @@ dependencies { // Networking // implementation("com.squareup.okhttp3:okhttp:4.9.2") // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") - implementation("com.github.Blatzar:NiceHttp:0.3.3") + implementation("com.github.Blatzar:NiceHttp:0.3.4") // Util to skip the URI file fuckery 🙏 implementation("com.github.tachiyomiorg:unifile:17bec43") diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 32df314f..af04a1a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -337,6 +337,9 @@ object CommonActivity { KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { PlayerEventType.SkipOp } + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + PlayerEventType.SkipCurrentChapter + } KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation PlayerEventType.PlayPauseToggle } diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index eb83b08a..ff74d6cc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -88,6 +88,9 @@ import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.fragment_result_swipe.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.internal.applyConnectionSpec import java.io.File import java.net.URI import java.nio.charset.Charset diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index 13299002..8bf1f91b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -12,7 +12,6 @@ import okhttp3.Headers.Companion.toHeaders import okhttp3.OkHttpClient import java.io.File - fun Requests.initClient(context: Context): OkHttpClient { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 9a0debcf..21047db3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -38,6 +38,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -103,6 +104,14 @@ abstract class AbstractPlayerFragment( throw NotImplementedError() } + open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + + } + + open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + + } + open fun exitedPipMode() { throw NotImplementedError() } @@ -373,7 +382,9 @@ abstract class AbstractPlayerFragment( ), subtitlesUpdates = ::subtitlesChanged, embeddedSubtitlesFetched = ::embeddedSubtitlesFetched, - onTracksInfoChanged = ::onTracksInfoChanged + onTracksInfoChanged = ::onTracksInfoChanged, + onTimestampInvoked = ::onTimestamp, + onTimestampSkipped = ::onTimestampSkipped ) if (player is CS3IPlayer) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index f60d8c78..8eda6e30 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -18,7 +18,10 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.trackselection.TrackSelectionOverride import com.google.android.exoplayer2.trackselection.TrackSelector import com.google.android.exoplayer2.ui.SubtitleView -import com.google.android.exoplayer2.upstream.* +import com.google.android.exoplayer2.upstream.DataSource +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.upstream.HttpDataSource import com.google.android.exoplayer2.upstream.cache.CacheDataSource import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor import com.google.android.exoplayer2.upstream.cache.SimpleCache @@ -32,6 +35,7 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorUri @@ -113,6 +117,8 @@ class CS3IPlayer : IPlayer { private var playerUpdated: ((Any?) -> Unit)? = null private var embeddedSubtitlesFetched: ((List) -> Unit)? = null private var onTracksInfoChanged: (() -> Unit)? = null + private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null + private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null override fun releaseCallbacks() { playerUpdated = null @@ -126,7 +132,9 @@ class CS3IPlayer : IPlayer { prevEpisode = null subtitlesUpdates = null onTracksInfoChanged = null + onTimestampInvoked = null requestSubtitleUpdate = null + onTimestampSkipped = null } override fun initCallbacks( @@ -142,6 +150,8 @@ class CS3IPlayer : IPlayer { subtitlesUpdates: (() -> Unit)?, embeddedSubtitlesFetched: ((List) -> Unit)?, onTracksInfoChanged: (() -> Unit)?, + onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?, + onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?, ) { this.playerUpdated = playerUpdated this.updateIsPlaying = updateIsPlaying @@ -155,6 +165,8 @@ class CS3IPlayer : IPlayer { this.subtitlesUpdates = subtitlesUpdates this.embeddedSubtitlesFetched = embeddedSubtitlesFetched this.onTracksInfoChanged = onTracksInfoChanged + this.onTimestampInvoked = onTimestampInvoked + this.onTimestampSkipped = onTimestampSkipped } // I know, this is not a perfect solution, however it works for fixing subs @@ -719,7 +731,7 @@ class CS3IPlayer : IPlayer { source } - println("PLAYBACK POS $playbackPosition") + //println("PLAYBACK POS $playbackPosition") return exoPlayerBuilder.build().apply { setPlayWhenReady(playWhenReady) seekTo(currentWindow, playbackPosition) @@ -735,8 +747,22 @@ class CS3IPlayer : IPlayer { } } - fun updatedTime() { - val position = exoPlayer?.currentPosition + private fun getCurrentTimestamp(writePosition : Long? = null): EpisodeSkip.SkipStamp? { + val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null + for (lastTimeStamp in lastTimeStamps) { + if (lastTimeStamp.startMs <= position && position < lastTimeStamp.endMs) { + return lastTimeStamp + } + } + return null + } + + fun updatedTime(writePosition : Long? = null) { + getCurrentTimestamp(writePosition)?.let { timestamp -> + onTimestampInvoked?.invoke(timestamp) + } + + val position = writePosition ?: exoPlayer?.currentPosition val duration = exoPlayer?.contentDuration if (duration != null && position != null) { playerPositionChanged?.invoke(Pair(position, duration)) @@ -748,12 +774,12 @@ class CS3IPlayer : IPlayer { } override fun seekTo(time: Long) { - updatedTime() + updatedTime(time) exoPlayer?.seekTo(time) } private fun ExoPlayer.seekTime(time: Long) { - updatedTime() + updatedTime(currentPosition + time) seekTo(currentPosition + time) } @@ -789,6 +815,17 @@ class CS3IPlayer : IPlayer { CSPlayerEvent.SeekBack -> seekTime(-seekActionTime) CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke() + CSPlayerEvent.SkipCurrentChapter -> { + //val dur = this@CS3IPlayer.getDuration() ?: return@apply + getCurrentTimestamp()?.let { lastTimeStamp -> + if (lastTimeStamp.skipToNextEpisode) { + handleEvent(CSPlayerEvent.NextEpisode) + } else { + seekTo(lastTimeStamp.endMs + 1L) + } + onTimestampSkipped?.invoke(lastTimeStamp) + } + } } } } catch (e: Exception) { @@ -1007,6 +1044,24 @@ class CS3IPlayer : IPlayer { } } + private var lastTimeStamps: List = emptyList() + override fun addTimeStamps(timeStamps: List) { + lastTimeStamps = timeStamps + timeStamps.forEach { timestamp -> + exoPlayer?.createMessage { _, _ -> + updatedTime() + //if (payload is EpisodeSkip.SkipStamp) // this should always be true + // onTimestampInvoked?.invoke(payload) + } + ?.setLooper(Looper.getMainLooper()) + ?.setPosition(timestamp.startMs) + //?.setPayload(timestamp) + ?.setDeleteAfterDelivery(false) + ?.send() + } + updatedTime() + } + fun onRenderFirst() { if (!hasUsedFirstRender) { // this insures that we only call this once per player load Log.i(TAG, "Rendered first frame") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 52125c68..0f9a6548 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1141,6 +1141,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { PlayerEventType.Play -> { player.handleEvent(CSPlayerEvent.Play) } + PlayerEventType.SkipCurrentChapter -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } PlayerEventType.Resize -> { nextResize() } @@ -1254,6 +1257,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.handleEvent(CSPlayerEvent.PlayPauseToggle) } + skip_chapter_button?.setOnClickListener { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + // init clicks player_resize_btt?.setOnClickListener { autoHide() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index f466dd7e..e1f1d99d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.player +import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Dialog import android.content.Context @@ -13,6 +14,7 @@ import android.view.ViewGroup import android.widget.* import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.core.animation.addListener import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible @@ -36,8 +38,8 @@ import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSub import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.SyncViewModel +import com.lagradost.cloudstream3.ui.result.setText import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.Coroutines.ioSafe @@ -49,6 +51,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.toPx import kotlinx.android.synthetic.main.dialog_online_subtitles.* import kotlinx.android.synthetic.main.dialog_online_subtitles.apply_btt import kotlinx.android.synthetic.main.dialog_online_subtitles.cancel_btt @@ -58,7 +61,6 @@ import kotlinx.android.synthetic.main.player_select_source_and_subs.* import kotlinx.android.synthetic.main.player_select_source_and_subs.subtitles_click_settings import kotlinx.android.synthetic.main.player_select_tracks.* import kotlinx.coroutines.Job -import kotlinx.coroutines.delay class GeneratorPlayer : FullScreenPlayer() { companion object { @@ -67,8 +69,7 @@ class GeneratorPlayer : FullScreenPlayer() { Log.i(TAG, "newInstance = $syncData") lastUsedGenerator = generator return Bundle().apply { - if (syncData != null) - putSerializable("syncData", syncData) + if (syncData != null) putSerializable("syncData", syncData) } } @@ -165,6 +166,7 @@ class GeneratorPlayer : FullScreenPlayer() { isActive = true setPlayerDimen(null) setTitle() + hasRequestedStamps = false loadExtractorJob(link.first) // load player @@ -180,12 +182,11 @@ class GeneratorPlayer : FullScreenPlayer() { }, currentSubs, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - currentSubs, - settings = true, - downloads = true + currentSubs, settings = true, downloads = true ), ) } + player.addTimeStamps(listOf()) // clear stamps } private fun sortLinks(useQualitySettings: Boolean = true): List> { @@ -231,9 +232,7 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun openOnlineSubPicker( - context: Context, - imdbId: Long?, - dismissCallback: (() -> Unit) + context: Context, imdbId: Long?, dismissCallback: (() -> Unit) ) { val providers = subsProviders val isSingleProvider = subsProviders.size == 1 @@ -256,8 +255,7 @@ class GeneratorPlayer : FullScreenPlayer() { val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { fun setHearingImpairedIcon( - imageViewEnd: ImageView?, - position: Int + imageViewEnd: ImageView?, position: Int ) { if (imageViewEnd == null) return val isHearingImpaired = @@ -265,13 +263,11 @@ class GeneratorPlayer : FullScreenPlayer() { val drawableEnd = if (isHearingImpaired) { ContextCompat.getDrawable( - context, - R.drawable.ic_baseline_hearing_24 + context, R.drawable.ic_baseline_hearing_24 )?.apply { setTint( ContextCompat.getColor( - context, - R.color.textColor + context, R.color.textColor ) ) } @@ -281,8 +277,7 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView ?: LayoutInflater.from(context) - .inflate(layout, null) + val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) val item = getItem(position) @@ -337,13 +332,12 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onQueryTextSubmit(query: String?): Boolean { dialog.search_loading_bar?.show() ioSafe { - val search = AbstractSubtitleEntities.SubtitleSearch( - query = query ?: return@ioSafe, - imdb = imdbId, - epNumber = currentTempMeta.episode, - seasonNumber = currentTempMeta.season, - lang = currentLanguageTwoLetters.ifBlank { null } - ) + val search = + AbstractSubtitleEntities.SubtitleSearch(query = query ?: return@ioSafe, + imdb = imdbId, + epNumber = currentTempMeta.episode, + seasonNumber = currentTempMeta.season, + lang = currentLanguageTwoLetters.ifBlank { null }) val results = providers.amap { try { it.search(search) @@ -379,14 +373,12 @@ class GeneratorPlayer : FullScreenPlayer() { dialog.search_filter.setOnClickListener { view -> val lang639_1 = languages.map { it.ISO_639_1 } - activity?.showDialog( - languages.map { it.languageName }, + activity?.showDialog(languages.map { it.languageName }, lang639_1.indexOf(currentLanguageTwoLetters), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, - { } - ) { index -> + { }) { index -> currentLanguageTwoLetters = lang639_1[index] dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) } @@ -472,8 +464,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (uri == null) return@normalSafeApiCall val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall // RW perms for the path - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ctx.contentResolver.takePersistableUriPermission(uri, flags) @@ -536,11 +528,9 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val loadFromOpenSubsFooter: TextView = - layoutInflater.inflate( - R.layout.sort_bottom_footer_add_choice, - null - ) as TextView + val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( + R.layout.sort_bottom_footer_add_choice, null + ) as TextView loadFromOpenSubsFooter.text = ctx.getString(R.string.player_load_subtitles_online) @@ -592,8 +582,7 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitleIndexStart = currentSubtitles.indexOf(currentSelectedSubtitles) + 1 var subtitleIndex = subtitleIndexStart - val subsArrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + val subsArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) subsArrayAdapter.add(ctx.getString(R.string.no_subtitles)) subsArrayAdapter.addAll(currentSubtitles.map { it.name }) @@ -631,8 +620,7 @@ class GeneratorPlayer : FullScreenPlayer() { val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) val value = settingsManager.getString( - ctx.getString(R.string.subtitles_encoding_key), - null + ctx.getString(R.string.subtitles_encoding_key), null ) val index = prefValues.indexOf(value) text = prefNames[if (index == -1) 0 else index] @@ -644,28 +632,22 @@ class GeneratorPlayer : FullScreenPlayer() { val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) - val currentPrefMedia = - settingsManager.getString( - ctx.getString(R.string.subtitles_encoding_key), - null - ) + val currentPrefMedia = settingsManager.getString( + ctx.getString(R.string.subtitles_encoding_key), null + ) shouldDismiss = false sourceDialog.dismissSafe(activity) val index = prefValues.indexOf(currentPrefMedia) - activity?.showDialog( - prefNames.toList(), + activity?.showDialog(prefNames.toList(), if (index == -1) 0 else index, ctx.getString(R.string.subtitles_encoding), true, {}) { - settingsManager.edit() - .putString( - ctx.getString(R.string.subtitles_encoding_key), - prefValues[it] - ) - .apply() + settingsManager.edit().putString( + ctx.getString(R.string.subtitles_encoding_key), prefValues[it] + ).apply() updateForcedEncoding(ctx) dismiss() @@ -878,7 +860,7 @@ class GeneratorPlayer : FullScreenPlayer() { } var maxEpisodeSet: Int? = null - + var hasRequestedStamps: Boolean = false override fun playerPositionChanged(posDur: Pair) { // Don't save livestream data if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return @@ -887,11 +869,16 @@ class GeneratorPlayer : FullScreenPlayer() { if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return val (position, duration) = posDur - if (duration == 0L) return // idk how you achieved this, but div by zero crash + if (duration <= 0L) return // idk how you achieved this, but div by zero crash + if (!hasRequestedStamps) { + hasRequestedStamps = true + viewModel.loadStamps(duration) + } viewModel.getId()?.let { DataStoreHelper.setViewPos(it, position, duration) } + val percentage = position * 100L / duration val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE @@ -939,17 +926,14 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) if (settingsManager.getBoolean( - ctx.getString(R.string.episode_sync_enabled_key), - true + ctx.getString(R.string.episode_sync_enabled_key), true ) - ) - maxEpisodeSet = meta.episode + ) maxEpisodeSet = meta.episode sync.modifyMaxEpisode(meta.episode) } } - if (meta.tvType.isAnimeOp()) - isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE + if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE } } player_skip_op?.isVisible = isOpVisible @@ -961,9 +945,7 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun getAutoSelectSubtitle( - subtitles: Set, - settings: Boolean, - downloads: Boolean + subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null val lang = SubtitleHelper.fromTwoLettersToLanguage(langCode) ?: return null @@ -1009,23 +991,20 @@ class GeneratorPlayer : FullScreenPlayer() { player.handleEvent(CSPlayerEvent.Play) return true } - } else - if (!langCode.isNullOrEmpty()) { - getAutoSelectSubtitle( - currentSubs, - settings = true, - downloads = false - )?.let { sub -> - - if (setSubtitles(sub)) { - player.saveData() - player.reloadPlayer(ctx) - player.handleEvent(CSPlayerEvent.Play) - return true - } + } else if (!langCode.isNullOrEmpty()) { + getAutoSelectSubtitle( + currentSubs, settings = true, downloads = false + )?.let { sub -> + if (setSubtitles(sub)) { + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) + return true } + } + } } return false } @@ -1081,17 +1060,17 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> //Generate video title val playerVideoTitle = if (headerName != null) { - (headerName + - if (tvType.isEpisodeBased() && episode != null) - if (season == null) - " - ${ctx.getString(R.string.episode)} $episode" - else - " \"${ctx.getString(R.string.season_short)}${season}:${ - ctx.getString( - R.string.episode_short - ) - }${episode}\"" - else "") + if (subName.isNullOrBlank() || subName == headerName) "" else " - $subName" + (headerName + if (tvType.isEpisodeBased() && episode != null) if (season == null) " - ${ + ctx.getString( + R.string.episode + ) + } $episode" + else " \"${ctx.getString(R.string.season_short)}${season}:${ + ctx.getString( + R.string.episode_short + ) + }${episode}\"" + else "") + if (subName.isNullOrBlank() || subName == headerName) "" else " - $subName" } else { "" } @@ -1131,8 +1110,7 @@ class GeneratorPlayer : FullScreenPlayer() { "" } - val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name - ?: "NULL" + val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" player_video_title_rez?.text = when (titleRez) { 0 -> "" @@ -1155,14 +1133,11 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason isTv = isTvSettings() - layout = - if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player + layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java] @@ -1174,6 +1149,68 @@ class GeneratorPlayer : FullScreenPlayer() { return super.onCreateView(inflater, container, savedInstanceState) } + var timestampShowState = false + + var skipAnimator: ValueAnimator? = null + var skipIndex = 0 + + private fun displayTimeStamp(show: Boolean) { + if (timestampShowState == show) return + skipIndex++ + println("displayTimeStamp = $show") + timestampShowState = show + skip_chapter_button?.apply { + val showWidth = 170.toPx + val noShowWidth = 10.toPx + //if((show && width == showWidth) || (!show && width == noShowWidth)) { + // return + //} + val to = if (show) showWidth else noShowWidth + val from = if (!show) showWidth else noShowWidth + + skipAnimator?.cancel() + isVisible = true + + // just in case + val lay = layoutParams + lay.width = from + layoutParams = lay + skipAnimator = ValueAnimator.ofInt( + from, to + ).apply { + addListener(onEnd = { + if (!show) skip_chapter_button?.isVisible = false + }) + addUpdateListener { valueAnimator -> + val value = valueAnimator.animatedValue as Int + val layoutParams: ViewGroup.LayoutParams = layoutParams + layoutParams.width = value + setLayoutParams(layoutParams) + } + duration = 500 + start() + } + } + } + + override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + displayTimeStamp(false) + } + + override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + if (timestamp != null) { + skip_chapter_button.setText(timestamp.uiText) + displayTimeStamp(true) + val currentIndex = skipIndex + skip_chapter_button?.handler?.postDelayed({ + if (skipIndex == currentIndex) + displayTimeStamp(false) + }, 6000) + } else { + displayTimeStamp(false) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) var langFilterList = listOf() @@ -1189,8 +1226,7 @@ class GeneratorPlayer : FullScreenPlayer() { settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) if (filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( - this.getString(R.string.provider_lang_key), - mutableSetOf("en") + this.getString(R.string.provider_lang_key), mutableSetOf("en") ) langFilterList = langFromPrefMedia?.mapNotNull { fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null @@ -1203,7 +1239,7 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = SubtitlesFragment.getAutoSelectLanguageISO639_1() + preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() if (currentSelectedLink == null) { viewModel.loadLinks() @@ -1218,6 +1254,10 @@ class GeneratorPlayer : FullScreenPlayer() { activity?.popCurrentPage() } + observe(viewModel.currentStamps) { stamps -> + player.addTimeStamps(stamps) + } + observe(viewModel.loadingLinks) { when (it) { is Resource.Loading -> { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 473b3e65..ba5a4a85 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorUri @@ -12,9 +13,9 @@ enum class PlayerEventType(val value: Int) { SeekForward(2), SeekBack(3), - //SkipCurrentChapter(4), + SkipCurrentChapter(4), NextEpisode(5), - PrevEpisode(5), + PrevEpisode(6), PlayPauseToggle(7), ToggleMute(8), Lock(9), @@ -32,7 +33,7 @@ enum class CSPlayerEvent(val value: Int) { SeekForward(2), SeekBack(3), - //SkipCurrentChapter(4), + SkipCurrentChapter(4), NextEpisode(5), PrevEpisode(6), PlayPauseToggle(7), @@ -54,7 +55,8 @@ interface Track { **/ val id: String? val label: String? -// val isCurrentlyPlaying: Boolean + + // val isCurrentlyPlaying: Boolean val language: String? } @@ -124,6 +126,8 @@ interface IPlayer { subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way embeddedSubtitlesFetched: ((List) -> Unit)? = null, // callback from player to give all embedded subtitles onTracksInfoChanged: (() -> Unit)? = null, // Callback when tracks are changed, used for UI changes + onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null, // Callback when timestamps appear, null when it should disappear + onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null, // callback for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) ) fun releaseCallbacks() @@ -131,6 +135,8 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() + fun addTimeStamps(timeStamps: List) + fun loadPlayer( context: Context, sameEpisode: Boolean, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index 0ed26b71..4f16e9f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -9,10 +9,12 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorUri import kotlinx.coroutines.Job -import kotlinx.coroutines.launch class PlayerGeneratorViewModel : ViewModel() { companion object { @@ -30,6 +32,9 @@ class PlayerGeneratorViewModel : ViewModel() { private val _loadingLinks = MutableLiveData>() val loadingLinks: LiveData> = _loadingLinks + private val _currentStamps = MutableLiveData>(emptyList()) + val currentStamps: LiveData> = _currentStamps + fun getId(): Int? { return generator?.getCurrentId() } @@ -113,10 +118,31 @@ class PlayerGeneratorViewModel : ViewModel() { } private var currentJob: Job? = null + private var currentStampJob: Job? = null + + fun loadStamps(duration: Long) { + //currentStampJob?.cancel() + currentStampJob = ioSafe { + val meta = generator?.getCurrent() + val page = (generator as? RepoLinkGenerator?)?.page + if (page != null && meta is ResultEpisode) { + _currentStamps.postValue(listOf()) + _currentStamps.postValue( + EpisodeSkip.getStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + ) + } + } + } fun loadLinks(clearCache: Boolean = false, isCasting: Boolean = false) { Log.i(TAG, "loadLinks") currentJob?.cancel() + currentJob = viewModelScope.launchSafe { val currentLinks = mutableSetOf>() val currentSubs = mutableSetOf() @@ -142,5 +168,6 @@ class PlayerGeneratorViewModel : ViewModel() { _currentLinks.postValue(currentLinks) _currentSubs.postValue(currentSubs) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt index d0ab245d..2ce53ea5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player import android.util.Log import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink @@ -11,7 +12,8 @@ import kotlin.math.min class RepoLinkGenerator( private val episodes: List, - private var currentIndex: Int = 0 + private var currentIndex: Int = 0, + val page: LoadResponse? = null, ) : IGenerator { companion object { const val TAG = "RepoLink" diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 6b04ebf9..0c26f69c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -416,7 +416,7 @@ class ResultViewModel2 : ViewModel() { return this?.firstOrNull { it.season == season } } - fun updateWatchStatus(currentResponse : LoadResponse, status: WatchType) { + fun updateWatchStatus(currentResponse: LoadResponse, status: WatchType) { val currentId = currentResponse.getId() val resultPage = currentResponse @@ -793,7 +793,7 @@ class ResultViewModel2 : ViewModel() { fun updateWatchStatus(status: WatchType) { - updateWatchStatus(currentResponse ?: return,status) + updateWatchStatus(currentResponse ?: return, status) _watchStatus.postValue(status) } @@ -1681,10 +1681,10 @@ class ResultViewModel2 : ViewModel() { preferDubStatus = indexer.dubStatus generator = if (isMovie) { - getMovie()?.let { RepoLinkGenerator(listOf(it)) } + getMovie()?.let { RepoLinkGenerator(listOf(it), page = currentResponse) } } else { episodes?.let { list -> - RepoLinkGenerator(list) + RepoLinkGenerator(list, page = currentResponse) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt new file mode 100644 index 00000000..e9b69c5b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -0,0 +1,140 @@ +package com.lagradost.cloudstream3.utils + +import android.util.Log +import androidx.annotation.StringRes +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.txt +import java.lang.Long.min + +object EpisodeSkip { + private const val TAG = "EpisodeSkip" + + enum class SkipType(@StringRes name: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_creddits), + Intro(R.string.skip_type_creddits), + } + + data class SkipStamp( + val type: SkipType, + val skipToNextEpisode: Boolean, + val startMs: Long, + val endMs: Long, + ) { + val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( + R.string.skip_type_format, + txt(type.name) + ) + } + + private val cachedStamps = HashMap>() + + private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { + return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh + } + + suspend fun getStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + val out = mutableListOf() + Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") + + if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { + data.getMalId()?.toIntOrNull()?.let { malId -> + val (resultLength, stamps) = AniSkip.getResult( + malId, + episode.episode, + episodeDurationMs + ) ?: return@let null + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + val dur = min(episodeDurationMs, resultLength) + stamps.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( + end, + dur + ), + startMs = start, + endMs = end + ) + }?.let { list -> + out.addAll(list) + } + } + } + if (out.isNotEmpty()) + cachedStamps[episode.id] = out + return out + } +} + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +object AniSkip { + private const val TAG = "AniSkip" + suspend fun getResult( + malId: Int, + episodeNumber: Int, + episodeLength: Long + ): Pair>? { + return try { + val url = + "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" + Log.i(TAG, "Requesting $url") + + val a = app.get(url) + val res = a.parsed() + Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") + if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null + } catch (t: Throwable) { + Log.i(TAG, "error = ${t.message}") + logError(t) + null + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml index 232dcaa0..412f7b5b 100644 --- a/app/src/main/res/layout/fragment_player.xml +++ b/app/src/main/res/layout/fragment_player.xml @@ -1,130 +1,130 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/player_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/black" + android:orientation="horizontal" + android:screenOrientation="sensorLandscape" + app:backgroundTint="@android:color/black" + app:surface_type="texture_view"> + android:id="@+id/player_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/black" + app:auto_show="true" + app:backgroundTint="@android:color/black" + app:controller_layout_id="@layout/player_custom_layout" + app:hide_on_touch="false" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:show_timeout="0" /> + android:id="@+id/player_loading_overlay" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/black" + android:backgroundTint="@android:color/black" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + android:text="@string/skip_loading" + android:textAllCaps="false" + android:textColor="?attr/textColor" + android:visibility="gone" + app:cornerRadius="4dp" + app:icon="@drawable/ic_baseline_skip_next_24" + app:iconTint="?attr/textColor" + app:rippleColor="?attr/colorPrimary" + tools:visibility="visible" /> + android:id="@+id/main_load" + android:layout_width="50dp" + android:layout_height="50dp" + android:layout_gravity="center" /> + android:id="@+id/video_go_back_holder_holder" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="5dp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + android:layout_width="30dp" + android:layout_height="30dp" + android:layout_gravity="center" + android:contentDescription="@string/go_back_img_des" + android:src="@drawable/ic_baseline_arrow_back_24" + app:tint="@android:color/white" /> + android:id="@+id/player_loading_go_back" + android:layout_width="70dp" + android:layout_height="70dp" + android:layout_gravity="center" + android:background="@drawable/video_tap_button_always_white" + android:clickable="true" + android:contentDescription="@string/go_back_img_des" + android:focusable="true" /> + + + android:layout_height="wrap_content" + android:layout_marginTop="15dp" + android:gravity="start" + android:textColor="@color/white" + android:textStyle="bold" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="78% at 18kb/s" /> - - + android:id="@+id/video_torrent_seeders" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="0dp" + android:gravity="start" + android:textColor="@color/white" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintTop_toBottomOf="@+id/player_video_title" + tools:text="17 seeders" /> \ No newline at end of file diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 9bbded4e..2cdcbf22 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -318,6 +318,25 @@ + + #66000000 #C0121212 + #4D121212 #121212 #66B5B5B5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5665ea5c..144d2477 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -638,4 +638,13 @@ Browser App not found All Languages + + Skip %s + Opening + Ending + Recap + Mixed ending + Mixed opening + Credits + Intro