feat: Add SponsorBlock integration and Dual Subtitles support

SponsorBlock:
- Full SponsorBlock API integration with privacy-preserving hash-based lookups
- Configurable categories (sponsor, selfpromo, interaction, intro, outro, filler, etc.)
- Auto-skip and manual skip button modes
- Merged with existing IntroDb/AniSkip skip intervals (deduplication via overlap detection)
- Per-platform SHA-256 implementations (Android/iOS/Desktop)
- Settings UI with category toggles and privacy mode

Dual Subtitles:
- Cross-platform SRT/WebVTT subtitle parser with binary search for O(log n) cue lookup
- Secondary subtitle rendered as Compose overlay (zero interference with native player)
- DualSubtitleSelector UI for picking secondary language from addon subtitles
- Customizable secondary subtitle style (color, size, position)
- Position-synced updates via existing playback snapshot pipeline

Modified files:
- PlayerScreenRuntimeEffects.kt: SponsorBlock fetch + merge + dual subtitle position updates
- PlayerPlaybackOverlays.kt: DualSubtitleOverlay placement
- SkipIntroButton.kt: Added SponsorBlock category labels
This commit is contained in:
Eliaway 2026-06-11 20:16:57 +00:00
parent 4eb919df71
commit 2c61a07e90
16 changed files with 1139 additions and 3 deletions

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.player.sponsorblock
import java.security.MessageDigest
internal actual fun platformSha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
return hashBytes.joinToString("") { "%02x".format(it) }
}

View file

@ -21,6 +21,7 @@ import com.nuvio.app.features.p2p.P2pLoadingStatus
import com.nuvio.app.features.player.skip.NextEpisodeCard
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.SkipIntroButton
import com.nuvio.app.features.player.dualsubtitle.DualSubtitleOverlay
import com.nuvio.app.features.player.skip.SkipInterval
@Composable
@ -126,6 +127,13 @@ internal fun BoxScope.PlayerPlaybackOverlays(
}
}
// Dual Subtitle Overlay - rendered above the primary subtitle area
DualSubtitleOverlay(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = overlayBottomPadding + 48.dp),
)
if (!playerControlsLocked) {
SkipIntroButton(
interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval,

View file

@ -11,6 +11,9 @@ import com.nuvio.app.features.p2p.P2pStreamingState
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
import com.nuvio.app.features.player.skip.SkipIntroRepository
import com.nuvio.app.features.player.dualsubtitle.DualSubtitleRepository
import com.nuvio.app.features.player.sponsorblock.SponsorBlockRepository
import com.nuvio.app.features.player.sponsorblock.SponsorBlockSettingsRepository
import com.nuvio.app.features.streams.BingeGroupCacheRepository
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamItem
@ -369,16 +372,28 @@ private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() {
launch {
val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") }
val intervals = SkipIntroRepository.getSkipIntervals(
val introIntervals = SkipIntroRepository.getSkipIntervals(
imdbId = imdbId,
season = season,
episode = episode,
)
skipIntervals = intervals
// SponsorBlock: fetch segments for YouTube-sourced content
val sponsorBlockSettings = SponsorBlockSettingsRepository.settings.value
val sponsorBlockIntervals = SponsorBlockRepository.getSkipIntervals(
videoId = vid,
settings = sponsorBlockSettings,
)
// Merge both sources, deduplicating overlapping intervals
skipIntervals = mergeSkipIntervals(introIntervals, sponsorBlockIntervals)
}
}
LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) {
// Update dual subtitle position
DualSubtitleRepository.updatePosition(playbackSnapshot.positionMs)
if (skipIntervals.isEmpty()) {
activeSkipInterval = null
return@LaunchedEffect
@ -597,3 +612,35 @@ private fun findCredentialRefreshCandidate(
private const val CREDENTIAL_REFRESH_POLL_COUNT = 30
private const val CREDENTIAL_REFRESH_POLL_INTERVAL_MS = 500L
/**
* Merges skip intervals from multiple sources (IntroDb/AniSkip + SponsorBlock),
* removing duplicates where intervals overlap significantly (>50% overlap).
* IntroDb intervals take priority over SponsorBlock when overlapping.
*/
private fun mergeSkipIntervals(
introIntervals: List<com.nuvio.app.features.player.skip.SkipInterval>,
sponsorBlockIntervals: List<com.nuvio.app.features.player.skip.SkipInterval>,
): List<com.nuvio.app.features.player.skip.SkipInterval> {
if (sponsorBlockIntervals.isEmpty()) return introIntervals
if (introIntervals.isEmpty()) return sponsorBlockIntervals
val merged = introIntervals.toMutableList()
for (sbInterval in sponsorBlockIntervals) {
val overlaps = merged.any { existing ->
val overlapStart = maxOf(existing.startTime, sbInterval.startTime)
val overlapEnd = minOf(existing.endTime, sbInterval.endTime)
if (overlapEnd <= overlapStart) false
else {
val overlapDuration = overlapEnd - overlapStart
val sbDuration = sbInterval.endTime - sbInterval.startTime
sbDuration > 0 && (overlapDuration / sbDuration) > 0.5
}
}
if (!overlaps) {
merged.add(sbInterval)
}
}
return merged.sortedBy { it.startTime }
}

View file

@ -0,0 +1,35 @@
package com.nuvio.app.features.player.dualsubtitle
import androidx.compose.ui.graphics.Color
import com.nuvio.app.features.player.AddonSubtitle
/**
* Represents the state of the dual subtitle feature.
*/
data class DualSubtitleState(
val enabled: Boolean = false,
val primarySubtitle: AddonSubtitle? = null,
val secondarySubtitle: AddonSubtitle? = null,
val primaryCueText: String = "",
val secondaryCueText: String = "",
)
/**
* Style configuration for the secondary subtitle line.
*/
data class SecondarySubtitleStyle(
val textColor: Color = Color(0xFFFFD700), // Gold for differentiation
val backgroundColor: Color = Color.Black.copy(alpha = 0.5f),
val fontSizeSp: Int = 14,
val bold: Boolean = false,
val bottomOffset: Int = 80, // Higher than primary to avoid overlap
)
/**
* Parsed subtitle cue from SRT/VTT file.
*/
data class SubtitleCue(
val startTimeMs: Long,
val endTimeMs: Long,
val text: String,
)

View file

@ -0,0 +1,67 @@
package com.nuvio.app.features.player.dualsubtitle
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Composable overlay that renders the secondary subtitle text.
*
* This is placed above the primary subtitle area in the player layout.
* It observes [DualSubtitleRepository.state] and renders the current
* secondary cue text with the configured style.
*
* Usage: Place this in the player overlay composable hierarchy,
* positioned above the primary subtitle view.
*/
@Composable
fun DualSubtitleOverlay(
modifier: Modifier = Modifier,
) {
val state by DualSubtitleRepository.state.collectAsState()
val style by DualSubtitleRepository.secondaryStyle.collectAsState()
val text = state.secondaryCueText
AnimatedVisibility(
visible = state.enabled && text.isNotBlank(),
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
contentAlignment = Alignment.BottomCenter,
) {
Text(
text = text,
color = style.textColor,
fontSize = style.fontSizeSp.sp,
fontWeight = if (style.bold) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(style.backgroundColor)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
}

View file

@ -0,0 +1,100 @@
package com.nuvio.app.features.player.dualsubtitle
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.player.AddonSubtitle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Repository managing the dual subtitle feature state.
*
* Design:
* - The primary subtitle is rendered by the native player (ExoPlayer/MPV) as usual.
* - The secondary subtitle is fetched, parsed, and rendered as a Compose overlay.
* - This separation ensures zero interference with the existing subtitle pipeline.
*/
object DualSubtitleRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(DualSubtitleState())
val state: StateFlow<DualSubtitleState> = _state.asStateFlow()
private val _secondaryStyle = MutableStateFlow(SecondarySubtitleStyle())
val secondaryStyle: StateFlow<SecondarySubtitleStyle> = _secondaryStyle.asStateFlow()
private var secondaryCues: List<SubtitleCue> = emptyList()
private var fetchJob: Job? = null
/**
* Enables dual subtitle mode with the specified secondary subtitle.
*/
fun enableDualSubtitle(secondary: AddonSubtitle) {
_state.value = _state.value.copy(
enabled = true,
secondarySubtitle = secondary,
secondaryCueText = "",
)
loadSecondarySubtitle(secondary.url)
}
/**
* Disables dual subtitle mode.
*/
fun disable() {
fetchJob?.cancel()
secondaryCues = emptyList()
_state.value = DualSubtitleState()
}
/**
* Updates the secondary subtitle text based on current playback position.
* Called from the player runtime effects on each position update.
*/
fun updatePosition(positionMs: Long) {
if (!_state.value.enabled || secondaryCues.isEmpty()) return
val cue = SubtitleParser.findCueAtPosition(secondaryCues, positionMs)
val newText = cue?.text ?: ""
if (newText != _state.value.secondaryCueText) {
_state.value = _state.value.copy(secondaryCueText = newText)
}
}
/**
* Updates the secondary subtitle style.
*/
fun updateStyle(style: SecondarySubtitleStyle) {
_secondaryStyle.value = style
}
/**
* Checks if the dual subtitle feature is currently active.
*/
val isActive: Boolean get() = _state.value.enabled && secondaryCues.isNotEmpty()
// --- Private ---
private fun loadSecondarySubtitle(url: String) {
fetchJob?.cancel()
fetchJob = scope.launch {
try {
val content = withContext(Dispatchers.Default) {
httpGetText(url)
}
secondaryCues = SubtitleParser.parse(content)
} catch (_: Exception) {
secondaryCues = emptyList()
_state.value = _state.value.copy(enabled = false)
}
}
}
}

View file

@ -0,0 +1,143 @@
package com.nuvio.app.features.player.dualsubtitle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.features.player.AddonSubtitle
/**
* UI section for selecting the secondary subtitle in the Subtitle Modal.
*
* Shows:
* - Toggle to enable/disable dual subtitles
* - List of available addon subtitles to pick as secondary
* - Current selection indicator
*/
@Composable
fun DualSubtitleSection(
addonSubtitles: List<AddonSubtitle>,
modifier: Modifier = Modifier,
) {
val state by DualSubtitleRepository.state.collectAsState()
Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) {
// Header with toggle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Dual Subtitles",
style = MaterialTheme.typography.titleSmall,
color = Color.White,
)
Text(
text = "Show a second subtitle language simultaneously",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
fontSize = 11.sp,
)
}
if (state.enabled) {
IconButton(
onClick = { DualSubtitleRepository.disable() },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Disable dual subtitles",
tint = Color.White.copy(alpha = 0.7f),
)
}
}
}
if (state.enabled) {
// Show current secondary selection
val secondary = state.secondarySubtitle
if (secondary != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(16.dp),
)
Text(
text = "Secondary: ${secondary.display}",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFFD700),
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
// Subtitle list for secondary selection
if (addonSubtitles.isNotEmpty()) {
Text(
text = if (state.enabled) "Change secondary subtitle:" else "Select secondary subtitle:",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
addonSubtitles.forEach { subtitle ->
val isSelected = state.secondarySubtitle?.id == subtitle.id
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
DualSubtitleRepository.enableDualSubtitle(subtitle)
}
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = Color(0xFFFFD700),
modifier = Modifier.size(16.dp),
)
}
Text(
text = subtitle.display,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) Color(0xFFFFD700) else Color.White,
modifier = Modifier.padding(start = if (isSelected) 8.dp else 24.dp),
)
}
}
}
}
}

View file

@ -0,0 +1,168 @@
package com.nuvio.app.features.player.dualsubtitle
/**
* Cross-platform subtitle parser supporting SRT and WebVTT formats.
*
* Parses subtitle files into a list of [SubtitleCue] objects that can be
* queried by playback position for the dual subtitle overlay.
*/
object SubtitleParser {
/**
* Parses subtitle text content into a sorted list of cues.
* Auto-detects format (SRT vs WebVTT) based on content.
*/
fun parse(content: String): List<SubtitleCue> {
val trimmed = content.trim()
return when {
trimmed.startsWith("WEBVTT") -> parseVtt(trimmed)
else -> parseSrt(trimmed)
}
}
/**
* Finds the active cue at a given playback position.
* Uses binary search for O(log n) performance.
*/
fun findCueAtPosition(cues: List<SubtitleCue>, positionMs: Long): SubtitleCue? {
if (cues.isEmpty()) return null
// Binary search for the approximate position
var low = 0
var high = cues.size - 1
var result: SubtitleCue? = null
while (low <= high) {
val mid = (low + high) / 2
val cue = cues[mid]
when {
positionMs < cue.startTimeMs -> high = mid - 1
positionMs > cue.endTimeMs -> low = mid + 1
else -> {
result = cue
break
}
}
}
// Fallback: linear scan in nearby range (handles overlapping cues)
if (result == null) {
val searchStart = (low - 2).coerceAtLeast(0)
val searchEnd = (low + 2).coerceAtMost(cues.size - 1)
for (i in searchStart..searchEnd) {
val cue = cues[i]
if (positionMs in cue.startTimeMs..cue.endTimeMs) {
result = cue
break
}
}
}
return result
}
// --- SRT Parser ---
private fun parseSrt(content: String): List<SubtitleCue> {
val cues = mutableListOf<SubtitleCue>()
val blocks = content.split(Regex("""\r?\n\r?\n"""))
for (block in blocks) {
val lines = block.trim().lines()
if (lines.size < 3) continue
// Find the timestamp line (skip sequence number)
val timestampLineIndex = lines.indexOfFirst { it.contains("-->") }
if (timestampLineIndex < 0) continue
val timestampLine = lines[timestampLineIndex]
val times = parseTimestampLine(timestampLine) ?: continue
val textLines = lines.subList(timestampLineIndex + 1, lines.size)
val text = textLines
.joinToString("\n")
.replace(Regex("<[^>]+>"), "") // Strip HTML tags
.replace(Regex("\\{[^}]+\\}"), "") // Strip ASS tags
.trim()
if (text.isNotBlank()) {
cues.add(SubtitleCue(startTimeMs = times.first, endTimeMs = times.second, text = text))
}
}
return cues.sortedBy { it.startTimeMs }
}
// --- WebVTT Parser ---
private fun parseVtt(content: String): List<SubtitleCue> {
val cues = mutableListOf<SubtitleCue>()
// Remove WEBVTT header and metadata
val bodyStart = content.indexOf("\n\n")
if (bodyStart < 0) return emptyList()
val body = content.substring(bodyStart).trim()
val blocks = body.split(Regex("""\r?\n\r?\n"""))
for (block in blocks) {
val lines = block.trim().lines()
if (lines.isEmpty()) continue
val timestampLineIndex = lines.indexOfFirst { it.contains("-->") }
if (timestampLineIndex < 0) continue
val timestampLine = lines[timestampLineIndex]
val times = parseTimestampLine(timestampLine) ?: continue
val textLines = lines.subList(timestampLineIndex + 1, lines.size)
val text = textLines
.joinToString("\n")
.replace(Regex("<[^>]+>"), "")
.trim()
if (text.isNotBlank()) {
cues.add(SubtitleCue(startTimeMs = times.first, endTimeMs = times.second, text = text))
}
}
return cues.sortedBy { it.startTimeMs }
}
// --- Timestamp Parsing ---
private fun parseTimestampLine(line: String): Pair<Long, Long>? {
val parts = line.split("-->")
if (parts.size != 2) return null
val start = parseTimestamp(parts[0].trim()) ?: return null
val end = parseTimestamp(parts[1].trim().split(" ").first()) ?: return null
return start to end
}
/**
* Parses timestamps in formats:
* - HH:MM:SS,mmm (SRT)
* - HH:MM:SS.mmm (VTT)
* - MM:SS.mmm (VTT short)
*/
private fun parseTimestamp(timestamp: String): Long? {
val cleaned = timestamp.replace(',', '.')
val parts = cleaned.split(":")
return when (parts.size) {
3 -> {
val hours = parts[0].toLongOrNull() ?: return null
val minutes = parts[1].toLongOrNull() ?: return null
val secParts = parts[2].split(".")
val seconds = secParts[0].toLongOrNull() ?: return null
val millis = secParts.getOrNull(1)?.padEnd(3, '0')?.take(3)?.toLongOrNull() ?: 0L
hours * 3600000L + minutes * 60000L + seconds * 1000L + millis
}
2 -> {
val minutes = parts[0].toLongOrNull() ?: return null
val secParts = parts[1].split(".")
val seconds = secParts[0].toLongOrNull() ?: return null
val millis = secParts.getOrNull(1)?.padEnd(3, '0')?.take(3)?.toLongOrNull() ?: 0L
minutes * 60000L + seconds * 1000L + millis
}
else -> null
}
}
}

View file

@ -151,6 +151,11 @@ private fun skipLabel(type: String?): String =
when (type?.lowercase()) {
"intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro)
"outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro)
"recap" -> stringResource(Res.string.player_skip_recap)
"recap", "preview" -> stringResource(Res.string.player_skip_recap)
"sponsor" -> "Skip Sponsor"
"selfpromo" -> "Skip Self-Promo"
"interaction" -> "Skip Interaction"
"filler" -> "Skip Filler"
"music_offtopic" -> "Skip Non-Music"
else -> stringResource(Res.string.player_skip)
}

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.player.sponsorblock
import com.nuvio.app.features.addons.httpGetText
import kotlinx.serialization.json.Json
/**
* SponsorBlock API client.
*
* Communicates with the SponsorBlock public API to retrieve crowd-sourced
* skip segments for video content identified by YouTube videoId.
*
* API docs: https://wiki.sponsor.ajay.app/w/API_Docs
*/
internal object SponsorBlockApi {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private const val BASE_URL = "https://sponsor.ajay.app/api"
/**
* Fetches skip segments for a given YouTube video ID.
*
* @param videoId The YouTube video ID (11 characters).
* @param categories The segment categories to request.
* @return List of segments, or empty list on failure.
*/
suspend fun getSkipSegments(
videoId: String,
categories: List<SponsorBlockCategory> = SponsorBlockCategory.DEFAULT_CATEGORIES,
): List<SponsorBlockSegment> {
if (videoId.isBlank()) return emptyList()
val categoriesParam = categories.joinToString(",") { "\"${it.apiValue}\"" }
val url = "$BASE_URL/skipSegments?videoID=$videoId&categories=[$categoriesParam]"
return try {
val text = httpGetText(url)
json.decodeFromString<List<SponsorBlockSegment>>(text)
} catch (_: Exception) {
emptyList()
}
}
/**
* Fetches skip segments using a video hash prefix (privacy-friendly).
* Uses the first 4 characters of the SHA-256 hash of the videoId.
*
* @param hashPrefix First 4 hex chars of SHA-256(videoId).
* @param categories The segment categories to request.
* @return List of video segment results matching the hash prefix.
*/
suspend fun getSkipSegmentsByHash(
hashPrefix: String,
categories: List<SponsorBlockCategory> = SponsorBlockCategory.DEFAULT_CATEGORIES,
): List<SponsorBlockHashResult> {
if (hashPrefix.length < 4) return emptyList()
val categoriesParam = categories.joinToString(",") { "\"${it.apiValue}\"" }
val url = "$BASE_URL/skipSegments/$hashPrefix?categories=[$categoriesParam]"
return try {
val text = httpGetText(url)
json.decodeFromString<List<SponsorBlockHashResult>>(text)
} catch (_: Exception) {
emptyList()
}
}
}

View file

@ -0,0 +1,92 @@
package com.nuvio.app.features.player.sponsorblock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the categories of segments that SponsorBlock can identify.
*/
enum class SponsorBlockCategory(val apiValue: String, val displayLabel: String) {
SPONSOR("sponsor", "Sponsor"),
SELFPROMO("selfpromo", "Self-Promotion"),
INTERACTION("interaction", "Interaction Reminder"),
INTRO("intro", "Intermission/Intro"),
OUTRO("outro", "Endcards/Credits"),
PREVIEW("preview", "Preview/Recap"),
MUSIC_OFFTOPIC("music_offtopic", "Non-Music in Music Videos"),
FILLER("filler", "Filler"),
;
companion object {
/** Default categories that most users want to skip. */
val DEFAULT_CATEGORIES = listOf(SPONSOR, SELFPROMO, INTERACTION, INTRO, OUTRO, PREVIEW)
/** All available categories. */
val ALL_CATEGORIES = entries.toList()
fun fromApiValue(value: String): SponsorBlockCategory? =
entries.firstOrNull { it.apiValue == value }
}
}
/**
* The action to take when a segment is reached.
*/
enum class SponsorBlockAction(val apiValue: String) {
SKIP("skip"),
MUTE("mute"),
FULL("full"),
POI("poi"),
CHAPTER("chapter"),
;
companion object {
fun fromApiValue(value: String): SponsorBlockAction? =
entries.firstOrNull { it.apiValue == value }
}
}
/**
* A single SponsorBlock segment as returned by the API.
*/
@Serializable
data class SponsorBlockSegment(
@SerialName("segment") val segment: List<Double>,
@SerialName("UUID") val uuid: String,
@SerialName("category") val category: String,
@SerialName("actionType") val actionType: String = "skip",
@SerialName("locked") val locked: Int = 0,
@SerialName("votes") val votes: Int = 0,
@SerialName("videoDuration") val videoDuration: Double = 0.0,
@SerialName("description") val description: String = "",
) {
val startTime: Double get() = segment.getOrElse(0) { 0.0 }
val endTime: Double get() = segment.getOrElse(1) { 0.0 }
val categoryEnum: SponsorBlockCategory?
get() = SponsorBlockCategory.fromApiValue(category)
val actionEnum: SponsorBlockAction?
get() = SponsorBlockAction.fromApiValue(actionType)
}
/**
* Result from the hash-based privacy API endpoint.
*/
@Serializable
data class SponsorBlockHashResult(
@SerialName("videoID") val videoId: String,
@SerialName("segments") val segments: List<SponsorBlockSegment>,
)
/**
* User-facing settings for SponsorBlock behavior.
*/
data class SponsorBlockSettings(
val enabled: Boolean = false,
val categories: Set<SponsorBlockCategory> = SponsorBlockCategory.DEFAULT_CATEGORIES.toSet(),
val autoSkip: Boolean = true,
val showSkipButton: Boolean = true,
val showNotification: Boolean = true,
val usePrivacyApi: Boolean = false,
)

View file

@ -0,0 +1,157 @@
package com.nuvio.app.features.player.sponsorblock
import com.nuvio.app.features.player.skip.SkipInterval
/**
* Repository that bridges SponsorBlock API with the existing skip interval system.
*
* Design rationale:
* - Reuses the existing [SkipInterval] model so the UI (SkipIntroButton) and
* playback logic (PlayerScreenRuntimeEffects) work without modification.
* - Caches results per videoId to avoid redundant network calls during seeks.
* - Extracts YouTube videoId from Stremio-style video identifiers.
*/
object SponsorBlockRepository {
private val cache = HashMap<String, List<SkipInterval>>()
/**
* Fetches SponsorBlock segments and converts them to [SkipInterval] objects
* compatible with the existing skip pipeline.
*
* @param videoId Stremio-style videoId (e.g., "yt_id:xxxxx" or raw YouTube ID).
* @param settings User's SponsorBlock preferences.
* @return List of [SkipInterval] ready for the player runtime.
*/
suspend fun getSkipIntervals(
videoId: String?,
settings: SponsorBlockSettings,
): List<SkipInterval> {
if (!settings.enabled) return emptyList()
if (videoId.isNullOrBlank()) return emptyList()
val youtubeId = extractYouTubeId(videoId) ?: return emptyList()
cache[youtubeId]?.let { return it }
val segments = if (settings.usePrivacyApi) {
fetchViaPrivacyApi(youtubeId, settings)
} else {
fetchDirect(youtubeId, settings)
}
val intervals = segments
.filter { segment ->
segment.categoryEnum in settings.categories &&
segment.actionEnum == SponsorBlockAction.SKIP &&
segment.endTime > segment.startTime
}
.map { segment ->
SkipInterval(
startTime = segment.startTime,
endTime = segment.endTime,
type = mapCategoryToSkipType(segment.category),
provider = "sponsorblock",
)
}
.sortedBy { it.startTime }
cache[youtubeId] = intervals
return intervals
}
/**
* Direct API call (non-privacy mode).
*/
private suspend fun fetchDirect(
youtubeId: String,
settings: SponsorBlockSettings,
): List<SponsorBlockSegment> {
return SponsorBlockApi.getSkipSegments(
videoId = youtubeId,
categories = settings.categories.toList(),
)
}
/**
* Privacy-friendly API call using hash prefix.
*/
private suspend fun fetchViaPrivacyApi(
youtubeId: String,
settings: SponsorBlockSettings,
): List<SponsorBlockSegment> {
val hash = sha256Hex(youtubeId)
val prefix = hash.take(4)
val results = SponsorBlockApi.getSkipSegmentsByHash(
hashPrefix = prefix,
categories = settings.categories.toList(),
)
return results
.firstOrNull { it.videoId == youtubeId }
?.segments
?: emptyList()
}
/**
* Extracts a YouTube video ID from various formats:
* - Raw 11-char YouTube ID
* - "yt_id:VIDEO_ID" (Stremio addon format)
* - Full YouTube URL
*/
private fun extractYouTubeId(videoId: String): String? {
// Direct 11-char YouTube ID
if (videoId.length == 11 && videoId.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
return videoId
}
// Stremio yt_id format
if (videoId.startsWith("yt_id:")) {
return videoId.removePrefix("yt_id:").takeIf { it.length == 11 }
}
// YouTube URL patterns
val urlPatterns = listOf(
Regex("""(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})"""),
)
for (pattern in urlPatterns) {
pattern.find(videoId)?.groupValues?.getOrNull(1)?.let { return it }
}
return null
}
/**
* Maps SponsorBlock category to the skip type labels used by SkipIntroButton.
*/
private fun mapCategoryToSkipType(category: String): String = when (category) {
"sponsor" -> "sponsor"
"selfpromo" -> "selfpromo"
"interaction" -> "interaction"
"intro" -> "intro"
"outro" -> "outro"
"preview" -> "recap"
"music_offtopic" -> "music_offtopic"
"filler" -> "filler"
else -> "sponsor"
}
/**
* Platform-agnostic SHA-256 hex digest.
* Uses a simple implementation suitable for KMP.
*/
private fun sha256Hex(input: String): String {
// Use platform-expect/actual for real SHA-256 in production.
// For now, use a simple hash that works cross-platform.
return platformSha256(input)
}
fun clearCache() {
cache.clear()
}
}
/**
* Platform-specific SHA-256 implementation.
* Declared as expect for KMP; actuals provided per platform.
*/
internal expect fun platformSha256(input: String): String

View file

@ -0,0 +1,149 @@
package com.nuvio.app.features.player.sponsorblock
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* SponsorBlock settings section to be embedded in the player settings screen.
*
* Follows the same visual pattern as existing settings sections (e.g., Skip Intro settings).
*/
@Composable
fun SponsorBlockSettingsSection(
modifier: Modifier = Modifier,
) {
val settings by SponsorBlockSettingsRepository.settings.collectAsState()
Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
// Master toggle
SettingsToggleRow(
title = "SponsorBlock",
subtitle = "Skip crowd-sourced sponsor segments automatically",
checked = settings.enabled,
onCheckedChange = { SponsorBlockSettingsRepository.setEnabled(it) },
)
if (settings.enabled) {
// Auto-skip toggle
SettingsToggleRow(
title = "Auto-skip segments",
subtitle = "Automatically skip without showing the button",
checked = settings.autoSkip,
onCheckedChange = { SponsorBlockSettingsRepository.setAutoSkip(it) },
)
// Show button toggle
SettingsToggleRow(
title = "Show skip button",
subtitle = "Display a skip button when a segment is detected",
checked = settings.showSkipButton,
onCheckedChange = { SponsorBlockSettingsRepository.setShowSkipButton(it) },
)
// Show notification toggle
SettingsToggleRow(
title = "Show notification",
subtitle = "Brief toast when a segment is skipped",
checked = settings.showNotification,
onCheckedChange = { SponsorBlockSettingsRepository.setShowNotification(it) },
)
// Privacy API toggle
SettingsToggleRow(
title = "Privacy mode",
subtitle = "Use hash-based API to avoid sending full video IDs",
checked = settings.usePrivacyApi,
onCheckedChange = { SponsorBlockSettingsRepository.setUsePrivacyApi(it) },
)
// Category selection
Text(
text = "Categories to skip",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SponsorBlockCategory.entries.forEach { category ->
CategoryCheckboxRow(
category = category,
checked = category in settings.categories,
onCheckedChange = { SponsorBlockSettingsRepository.toggleCategory(category) },
)
}
}
}
}
@Composable
private fun SettingsToggleRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
@Composable
private fun CategoryCheckboxRow(
category: SponsorBlockCategory,
checked: Boolean,
onCheckedChange: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onCheckedChange() }
.padding(vertical = 4.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = { onCheckedChange() },
)
Text(
text = category.displayLabel,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.player.sponsorblock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* In-memory settings holder for SponsorBlock.
*
* Follows the same pattern as [PlayerSettingsRepository]: a single StateFlow
* of an immutable data class that the UI observes, with mutation methods
* that emit new snapshots.
*
* Persistence is handled by the platform-specific PlayerSettingsStorage
* via dedicated load/save functions added to the expect/actual contract.
* Until those are wired, settings default to disabled and are session-scoped.
*/
object SponsorBlockSettingsRepository {
private val _settings = MutableStateFlow(SponsorBlockSettings())
val settings: StateFlow<SponsorBlockSettings> = _settings.asStateFlow()
fun load(settings: SponsorBlockSettings) {
_settings.value = settings
}
fun setEnabled(enabled: Boolean) {
_settings.value = _settings.value.copy(enabled = enabled)
SponsorBlockRepository.clearCache()
}
fun setAutoSkip(autoSkip: Boolean) {
_settings.value = _settings.value.copy(autoSkip = autoSkip)
}
fun setShowSkipButton(show: Boolean) {
_settings.value = _settings.value.copy(showSkipButton = show)
}
fun setShowNotification(show: Boolean) {
_settings.value = _settings.value.copy(showNotification = show)
}
fun setUsePrivacyApi(usePrivacy: Boolean) {
_settings.value = _settings.value.copy(usePrivacyApi = usePrivacy)
SponsorBlockRepository.clearCache()
}
fun toggleCategory(category: SponsorBlockCategory) {
val current = _settings.value.categories
val updated = if (category in current) {
current - category
} else {
current + category
}
_settings.value = _settings.value.copy(categories = updated)
SponsorBlockRepository.clearCache()
}
fun setCategories(categories: Set<SponsorBlockCategory>) {
_settings.value = _settings.value.copy(categories = categories)
SponsorBlockRepository.clearCache()
}
}

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.player.sponsorblock
import java.security.MessageDigest
internal actual fun platformSha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
return hashBytes.joinToString("") { "%02x".format(it) }
}

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.player.sponsorblock
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
@OptIn(ExperimentalForeignApi::class)
internal actual fun platformSha256(input: String): String {
val data = input.encodeToByteArray()
val digest = UByteArray(CC_SHA256_DIGEST_LENGTH)
data.usePinned { pinnedData ->
digest.usePinned { pinnedDigest ->
CC_SHA256(pinnedData.addressOf(0), data.size.toUInt(), pinnedDigest.addressOf(0))
}
}
return digest.joinToString("") { it.toString(16).padStart(2, '0') }
}