diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.android.kt new file mode 100644 index 00000000..2f0dcf7f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.android.kt @@ -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) } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt index ea0a925f..07b148c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index e1e5f81f..4b95545c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -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, + sponsorBlockIntervals: List, +): List { + 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 } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleModels.kt new file mode 100644 index 00000000..12732e18 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleModels.kt @@ -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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleOverlay.kt new file mode 100644 index 00000000..2be96f26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleOverlay.kt @@ -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), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleRepository.kt new file mode 100644 index 00000000..25ef9b3a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleRepository.kt @@ -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 = _state.asStateFlow() + + private val _secondaryStyle = MutableStateFlow(SecondarySubtitleStyle()) + val secondaryStyle: StateFlow = _secondaryStyle.asStateFlow() + + private var secondaryCues: List = 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) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleSelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleSelector.kt new file mode 100644 index 00000000..9d69b9a4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/DualSubtitleSelector.kt @@ -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, + 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), + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/SubtitleParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/SubtitleParser.kt new file mode 100644 index 00000000..2bbf6ca9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/dualsubtitle/SubtitleParser.kt @@ -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 { + 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, 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 { + val cues = mutableListOf() + 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 { + val cues = mutableListOf() + // 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? { + 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 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt index 755a67f0..208410ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockApi.kt new file mode 100644 index 00000000..eef18876 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockApi.kt @@ -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.DEFAULT_CATEGORIES, + ): List { + 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>(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.DEFAULT_CATEGORIES, + ): List { + 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>(text) + } catch (_: Exception) { + emptyList() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockModels.kt new file mode 100644 index 00000000..8c77250d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockModels.kt @@ -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, + @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, +) + +/** + * User-facing settings for SponsorBlock behavior. + */ +data class SponsorBlockSettings( + val enabled: Boolean = false, + val categories: Set = SponsorBlockCategory.DEFAULT_CATEGORIES.toSet(), + val autoSkip: Boolean = true, + val showSkipButton: Boolean = true, + val showNotification: Boolean = true, + val usePrivacyApi: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockRepository.kt new file mode 100644 index 00000000..d9516a5d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockRepository.kt @@ -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>() + + /** + * 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 { + 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 { + 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 { + 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockSettingsUI.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockSettingsUI.kt new file mode 100644 index 00000000..58e432b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockSettingsUI.kt @@ -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), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockStorage.kt new file mode 100644 index 00000000..2b3f1274 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockStorage.kt @@ -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 = _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) { + _settings.value = _settings.value.copy(categories = categories) + SponsorBlockRepository.clearCache() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.desktop.kt new file mode 100644 index 00000000..2f0dcf7f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.desktop.kt @@ -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) } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.ios.kt new file mode 100644 index 00000000..20975189 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/sponsorblock/SponsorBlockPlatform.ios.kt @@ -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') } +}