feat(meta,core,app,android,ios): AniSkip where files have no chapters

Most files that are not fansub releases carry no chapters, so they had no
skip button. AniSkip knows where an episode's opening and ending are, by
MyAnimeList id and episode number. ani.zip sends each entry's MyAnimeList
id and enrichment now keeps it on every episode row; schema v14 adds the
column and marks AniList titles un-enriched, deleting nothing, as v13 did.

When a file has loaded with no chapter naming an opening or ending, and
its length is known, each client asks once, with enrichment on only. The
answer lives as long as the file is open. with_skip_ranges cuts it into
the chapters as chapters named Opening and Ending, so the skip button,
auto-skip, the up-next countdown and the seek bar's marks all work
through the code a chaptered file uses. A file's own opening or ending
always wins over AniSkip's.

PRIVACY.md lists what is sent: the MyAnimeList id, the episode number and
the file's length.
This commit is contained in:
nvrl committed 2026-10-31 03:13:41 +01:00
1 parent 466630a9ba
commit cf8ee58f6b
23 files changed
+728 -11

No files matched your search

@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
*/
@Test
fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0)
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
assertNotNull(plan)
assertNull(skipOffer(plan, 12.0))
}
@@ -19,6 +19,7 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SkipRangeRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan
@@ -135,6 +136,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
/** What AniSkip said about the open episode, keyed by that episode. */
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
/** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null
@@ -226,6 +229,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
skipRanges = null
}
}
} ?: false
@@ -268,6 +272,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
/**
* Take AniSkip's answer for [media], and plan the chapters again with it.
* Dropped when another episode has opened since it was asked for.
*/
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
if (media != currentMedia) return
skipRanges = media to ranges
plannedFor = null
refreshChapters(mutableState.value)
}
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
@@ -394,7 +409,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
}
}
@@ -125,6 +125,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.ChapterKind
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
@@ -425,6 +426,20 @@ fun PlayerScreen(
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
}
// A file whose chapters name no opening or ending asks AniSkip, once, when
// its length is known. Only with posters and descriptions on: anything
// else, an error included, leaves the file as its chapters made it.
val lengthKnown = playingThis && state.duration > 0.0
LaunchedEffect(key, lengthKnown) {
if (!lengthKnown) return@LaunchedEffect
val named = chapters.entries.any { it.kind == ChapterKind.OPENING || it.kind == ChapterKind.ENDING }
if (named) return@LaunchedEffect
val ranges = runCatching {
NativeRuntime.engine().skipTimes(titleKey, episode.shareId, episode.linkId, state.duration)
}.getOrNull()
if (!ranges.isNullOrEmpty()) nativeHost.setSkipRanges(key, ranges)
}
var landscapeLocked by remember { mutableStateOf(false) }
// Set by the rotation button: from then on the viewer decides.
var rotationChosen by remember { mutableStateOf(false) }