diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
index 2afd14d52..e2e5e2f2c 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
@@ -93,6 +93,10 @@ internal actual object ExternalPlayerPlatform {
// Required by MX Player; harmless for other players.
putExtra("return_result", true)
+ // Intro/outro skip segments for players that support auto-skipping.
+ // Players that don't understand this extra simply ignore it.
+ request.skipSegmentsJson?.let { putExtra("skip_segments", it) }
+
// Headers
if (request.sourceHeaders.isNotEmpty()) {
val headerArray = request.sourceHeaders.entries
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index 98c550808..e05257c7f 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -26,6 +26,7 @@ actual object PlayerSettingsStorage {
private const val touchGesturesEnabledKey = "touch_gestures_enabled"
private const val externalPlayerEnabledKey = "external_player_enabled"
private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles"
+ private const val externalPlayerSendSkipSegmentsKey = "external_player_send_skip_segments"
private const val externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
@@ -93,6 +94,7 @@ actual object PlayerSettingsStorage {
touchGesturesEnabledKey,
externalPlayerEnabledKey,
externalPlayerForwardSubtitlesKey,
+ externalPlayerSendSkipSegmentsKey,
externalPlayerIdKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
@@ -270,6 +272,23 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadExternalPlayerSendSkipSegments(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, false)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveExternalPlayerSendSkipSegments(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey), enabled)
+ ?.apply()
+ }
+
actual fun loadExternalPlayerId(): String? =
preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null)
@@ -1042,6 +1061,7 @@ actual object PlayerSettingsStorage {
loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) }
+ loadExternalPlayerSendSkipSegments()?.let { put(externalPlayerSendSkipSegmentsKey, encodeSyncBoolean(it)) }
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
@@ -1115,6 +1135,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled)
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles)
+ payload.decodeSyncBoolean(externalPlayerSendSkipSegmentsKey)?.let(::saveExternalPlayerSendSkipSegments)
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
index ca0a88553..0a81d6913 100644
--- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
@@ -19,6 +19,7 @@
再試行
保存
保存中…
+ 切り替え
検証
インストール中
アドオン
@@ -31,6 +32,11 @@
利用不可
アドオンを設定
アドオンを削除
+ 独自のメディアサーバーを接続して、個人ライブラリを参照・再生します。
+ Nuvioに個人ライブラリを表示させたい場合は、上にサーバーURLを追加してください。
+ 接続された個人ライブラリはありません。
+ サーバーURL
+ 追加
マニフェストURLを追加して、カタログ・メタデータ・ストリーム・字幕をNuvioに読み込みましょう。
アドオンがありません
アドオンのURLを入力してください。
@@ -319,6 +325,8 @@
サインアップ
データはこのデバイスにのみ保存されます
いつでも、どこでも、すべてをストリーム
+ サインアップにより、
+ 利用規約
おかえりなさい
ライブラリ
Traktライブラリ
@@ -533,6 +541,8 @@
匿名
サインイン済み
同期バックエンド
+ バックエンドを切り替えますか?
+ %1$s に切り替えてサインアウトしますか?選択したバックエンドで再度サインインできます。
AMOLED ブラック
OLEDスクリーン向けに純粋な黒背景を使用します。
アプリの言語
@@ -540,7 +550,7 @@
言語を選択
「視聴を続ける」セクションの設定
Liquid Glass
- iOS 26以降でネイティブのiPhoneタブバーを使用します。オン中はタブバーからのプロフィール即時切り替えが利用できません。
+ iOS 26以降でネイティブのiPhoneタブバーを使用します。
カードの幅と角の丸みを調整します。
ディスプレイ
ホーム
@@ -631,6 +641,7 @@
ホーム
ソース
コンテンツソースのインストール・削除・更新・並べ替え
+ 個人メディアソースを接続し、自分専用ライブラリへのアクセスを管理します。
JavaScriptスクレイパーリポジトリのインストールと内部テスト
ホームレイアウト・コンテンツ表示・ポスターの動作を調整
詳細画面とエピソード画面の設定
@@ -910,6 +921,16 @@
第2音声言語
第2優先言語
デコーダー
+ 再生エンジン
+ ExoPlayerを優先し、再生に失敗した場合はlibmpvにフォールバックします。
+ ExoPlayerとAndroid Media3デコーダーを使用します。
+ Android再生にlibmpvを使用します。
+ libmpvレンダラー
+ libmpvレンダラー
+ libmpvハードウェアデコード
+ 利用可能な場合、mpvハードウェアデコードを使用します。
+ libmpv YUV420P互換
+ レンダラーや色の問題があるデバイス向けにYUV420P出力を強制します。
次のエピソード
プレーヤー
スキップセグメント
@@ -1301,9 +1322,9 @@
直接のストリームリンクがありません
メタデータがありません
ストリームを更新
- %1$d%% から再開
+ %1$d% から再開
%1$s から再開
- サイズ %1$s
+ %1$s
このストリームタイプはサポートされていません
設定でアカウントを連携してください。
TorBoxにキャッシュされていません。
@@ -1321,7 +1342,7 @@
ダウンロードに失敗しました
ダウンロードボディが空です
ダウンロードした更新ファイルが見つかりません。
- ダウンロード中 %1$d%%
+ ダウンロード中 %1$d%
インストールを開始できません
最新バージョンを使用しています。
Nuvioのアプリインストールを有効にして、戻って続けてください。
@@ -1404,6 +1425,7 @@
Traktのサインイン完了に失敗しました
Traktユーザー
ウォッチリスト
+
Traktの認証が期限切れです
Traktリストが見つかりません
Traktリストの上限に達しました
@@ -1574,9 +1596,12 @@
KB
MB
GB
+
イントロを投稿
映像設定
+
%1$s ではクラウドライブラリが利用できません。
+
映像
調整をリセット
出力プリセット
@@ -1591,6 +1616,7 @@
コントラスト
彩度
ガンマ
+
ネイティブEDR
HDR対応のiPhoneとiPadに最適。
SDRトーンマッピング
@@ -1600,6 +1626,7 @@
カスタム
下の詳細設定値を使用します。
オフ
+
iOS映像出力
ハードウェアデコーダー
拡張ダイナミックレンジ
@@ -1608,11 +1635,13 @@
mpvがデフォルトでアクティブなディスプレイの色空間をターゲットにするようにします。
ターゲット原色
ターゲット転送
+
iOS音声出力
音声出力
AVFoundation出力が一時的に無効な間はAudioUnitを使用します。
空間オーディオとマルチチャンネル出力の試験的サポート。
レガシーのAudioUnit出力を使用します。
+
結果の管理
最大結果数
表示する結果数を制限します。
@@ -1624,69 +1653,83 @@
並び替え後のBluRay・WEB-DL・REMUX の重複結果を制限します。
サイズ範囲
ファイルサイズで結果をフィルターします。
+
詳しく見る
デフォルト形式
オリジナル形式
1行に1グループずつ入力してください。
+
オリジナル順
最高品質優先
ファイルサイズ大優先
ファイルサイズ小優先
最高音質優先
言語優先
+
すべて
%1$d 件選択中
+
すべての結果
%1$d 件の結果
+
最大 %1$dGB
%1$dGB 以上
%1$d〜%2$dGB
+
優先解像度
選択した解像度をデフォルト順で先頭に並べます。
必須解像度
選択した解像度のみ表示します。
除外する解像度
選択した解像度を非表示にします。
+
優先品質
選択した品質をデフォルト順で先頭に並べます。
必須品質
選択した品質のみ表示します。
除外する品質
選択した品質を非表示にします。
+
優先映像タグ
DV・HDR・10bit・IMAXなどのタグを並べ替えます。
必須映像タグ
DV・HDR・10bit・IMAX・SDRなどのタグを必須にします。
除外する映像タグ
DV・HDR・10bit・3Dなどのタグを非表示にします。
+
優先音声タグ
Atmos・TrueHD・DTS・AACなどのタグを並べ替えます。
必須音声タグ
Atmos・TrueHD・DTS・AACなどのタグを必須にします。
除外する音声タグ
選択した音声タグを非表示にします。
+
優先チャンネル
優先するチャンネルレイアウトを先頭に並べます。
必須チャンネル
選択したチャンネルレイアウトのみ表示します。
除外するチャンネル
選択したチャンネルレイアウトを非表示にします。
+
優先エンコード
AV1・HEVC・AVCなどのエンコードを並べ替えます。
必須エンコード
AV1・HEVC・AVCなどのエンコードを必須にします。
除外するエンコード
選択したエンコードを非表示にします。
+
優先言語
優先する音声言語を先頭に並べます。
必須言語
選択した言語を含む結果のみ表示します。
除外する言語
すべての言語が除外対象の結果を非表示にします。
+
必須リリースグループ
選択したリリースグループのみ表示します。
除外するリリースグループ
選択したリリースグループを非表示にします。
+
タイムスタンプを投稿
セグメントタイプ
イントロ
@@ -1696,24 +1739,31 @@
終了時刻 (MM:SS)
投稿
キャプチャ
+
ハードウェアデコーダー
音声出力
ターゲット原色
ターゲット転送
+
解決済みTraktリスト
TMDBディスカバー
US, KR, JP, IN
+
Traktリスト名、URL、またはIDを入力してください
有効なTMDB IDまたはURLを入力してください。
TMDBソースを読み込めませんでした
TraktリストIDまたはURLを入力してください
Traktリストを読み込めませんでした
+
カナダ
オーストラリア
ドイツ
+
映画
シリーズ
+
クラウドライブラリが無効です。
+
APIキーが無効か接続に失敗しました
マニフェストに「%1$s」がありません
TMDBコレクション %1$s
@@ -1834,20 +1884,24 @@
GitHub releases APIエラー:%1$d
まだアップデートが公開されていません。
リリースにタグまたは名前がありません
+
%1$s に放送
本日放送
明日放送
+ - %1$d 日後に放送
- %1$d 日後に放送
%1$s
今日
明日
+ - %1$d 日後
- %1$d 日後
新しいエピソード
新シーズン
+
Traktリスト %1$s
Androidシステムプレーヤー
P2Pストリーミング
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 71d719ef5..105c796cd 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -855,6 +855,8 @@
Open new playback with the selected installed player.
Forward Subtitles to External Player
Fetch addon subtitles in your preferred language and pass them to the external player.
+ Send Intro and Outro Timestamps
+ Pass resolved intro and outro skip timestamps to the external player for auto-skipping. Only works in players that support it; other players ignore it.
No supported external players installed
Player
Internal
@@ -1317,6 +1319,7 @@
Fetching…
Finding source…
Loading subtitles from addons…
+ Loading skip segments…
Finding streams…
Stream link copied
No direct stream link available
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 8a02385b9..c9a13b78f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -1082,14 +1082,18 @@ private fun MainAppContent(
val baseRequest = launch.toExternalPlayerPlaybackRequest()
val shouldForwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles &&
!playerSettingsUiState.preferredSubtitleLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true)
+ val shouldSendSkipSegments = playerSettingsUiState.externalPlayerSendSkipSegments
if (shouldForwardSubtitles) {
StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_subtitles))
+ } else if (shouldSendSkipSegments) {
+ StreamsRepository.setOverlayVisible(true, getString(Res.string.streams_loading_skip_segments))
}
val enrichedRequest = prepareExternalPlayerLaunch(
request = baseRequest,
type = launch.contentType ?: launch.parentMetaType,
videoId = launch.videoId ?: launch.parentMetaId,
forwardSubtitles = playerSettingsUiState.externalPlayerForwardSubtitles,
+ sendSkipSegments = shouldSendSkipSegments,
preferredLanguage = playerSettingsUiState.preferredSubtitleLanguage,
secondaryLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
onOverlayMessage = { _ -> },
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt
index 511241974..ea9b3e4e6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt
@@ -1,19 +1,41 @@
package com.nuvio.app.features.player
+import com.nuvio.app.features.player.skip.SkipInterval
+import com.nuvio.app.features.player.skip.SkipIntroRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.json.addJsonObject
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.put
+
+private const val SkipSegmentResolveTimeoutMs = 4_000L
+
+// Skip resolution runs on an app-lifetime scope rather than the caller's (often
+// composition-bound) scope, so navigating to the external player cannot cancel an
+// in-flight network lookup partway through.
+private val skipResolveScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
/**
* Orchestrates the full external player launch flow:
* fetches subtitles if forwarding is enabled, downloads them to local cache,
- * then returns an enriched request for the caller to dispatch.
+ * resolves intro/outro skip segments if enabled, then returns an enriched
+ * request for the caller to dispatch.
*/
suspend fun prepareExternalPlayerLaunch(
request: ExternalPlayerPlaybackRequest,
type: String,
videoId: String,
forwardSubtitles: Boolean,
+ sendSkipSegments: Boolean,
preferredLanguage: String,
secondaryLanguage: String?,
onOverlayMessage: (String?) -> Unit,
): ExternalPlayerPlaybackRequest {
+ var result = request
+
if (forwardSubtitles && !preferredLanguage.equals(SubtitleLanguageOption.NONE, ignoreCase = true)) {
onOverlayMessage("Loading subtitles from addons...")
@@ -27,13 +49,63 @@ suspend fun prepareExternalPlayerLaunch(
if (subtitles != null) {
onOverlayMessage("Downloading subtitles...")
val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles)
- if (cachedSubtitles != null) {
- return request.copy(subtitles = cachedSubtitles)
- }
// Fallback: use original URLs if caching fails
- return request.copy(subtitles = subtitles)
+ result = result.copy(subtitles = cachedSubtitles ?: subtitles)
}
}
- return request
+ if (sendSkipSegments) {
+ val skipSegmentsJson = resolveSkipSegmentsJson(videoId, request.season, request.episode)
+ if (skipSegmentsJson != null) {
+ result = result.copy(skipSegmentsJson = skipSegmentsJson)
+ }
+ }
+
+ return result
}
+
+/**
+ * Resolves intro/outro skip segments for the given content and serializes them to the
+ * JSON contract understood by supporting external players: a JSON array of objects with
+ * `type` (String), `start` (seconds) and `end` (seconds). Returns null if nothing resolved.
+ *
+ * Bounded by a timeout so a slow provider never delays the player launch. Resolution is
+ * intentionally independent of the in-app skip-intro toggle (requireSkipIntroEnabled = false):
+ * this is its own opt-in setting.
+ */
+private suspend fun resolveSkipSegmentsJson(videoId: String, season: Int?, episode: Int?): String? {
+ val ep = episode ?: return null
+ val intervals = skipResolveScope.async {
+ withTimeoutOrNull(SkipSegmentResolveTimeoutMs) {
+ when {
+ videoId.startsWith("mal:") -> {
+ val malId = videoId.removePrefix("mal:").substringBefore(':')
+ SkipIntroRepository.getSkipIntervalsForMal(malId, ep, requireSkipIntroEnabled = false)
+ }
+ videoId.startsWith("kitsu:") -> {
+ val kitsuId = videoId.removePrefix("kitsu:").substringBefore(':')
+ SkipIntroRepository.getSkipIntervalsForKitsu(kitsuId, ep, requireSkipIntroEnabled = false)
+ }
+ else -> {
+ val imdbId = videoId.substringBefore(':').takeIf { it.startsWith("tt") } ?: return@withTimeoutOrNull null
+ val s = season ?: return@withTimeoutOrNull null
+ SkipIntroRepository.getSkipIntervals(imdbId, s, ep, requireSkipIntroEnabled = false)
+ }
+ }
+ }
+ }.await()
+
+ if (intervals.isNullOrEmpty()) return null
+ return intervals.toSkipSegmentsJson()
+}
+
+private fun List.toSkipSegmentsJson(): String =
+ buildJsonArray {
+ forEach { interval ->
+ addJsonObject {
+ put("type", interval.type)
+ put("start", interval.startTime)
+ put("end", interval.endTime)
+ }
+ }
+ }.toString()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
index 60bfd3f41..d2727938c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
@@ -21,6 +21,8 @@ data class ExternalPlayerPlaybackRequest(
val season: Int? = null,
val episode: Int? = null,
val episodeTitle: String? = null,
+ // JSON array of intro/outro skip segments, passed to players that support auto-skipping.
+ val skipSegmentsJson: String? = null,
) {
/**
* Builds a display title for external players.
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index 900553a3c..fa03c37a2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -39,6 +39,7 @@ data class PlayerSettingsUiState(
val touchGesturesEnabled: Boolean = true,
val externalPlayerEnabled: Boolean = false,
val externalPlayerForwardSubtitles: Boolean = false,
+ val externalPlayerSendSkipSegments: Boolean = false,
val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
@@ -103,6 +104,7 @@ object PlayerSettingsRepository {
private var touchGesturesEnabled = true
private var externalPlayerEnabled = false
private var externalPlayerForwardSubtitles = false
+ private var externalPlayerSendSkipSegments = false
private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
private var preferredAudioLanguage = AudioLanguageOption.DEVICE
private var secondaryPreferredAudioLanguage: String? = null
@@ -172,6 +174,7 @@ object PlayerSettingsRepository {
touchGesturesEnabled = true
externalPlayerEnabled = false
externalPlayerForwardSubtitles = false
+ externalPlayerSendSkipSegments = false
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage = AudioLanguageOption.DEVICE
secondaryPreferredAudioLanguage = null
@@ -236,6 +239,7 @@ object PlayerSettingsRepository {
touchGesturesEnabled = PlayerSettingsStorage.loadTouchGesturesEnabled() ?: true
externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false
+ externalPlayerSendSkipSegments = PlayerSettingsStorage.loadExternalPlayerSendSkipSegments() ?: false
externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
?: ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage =
@@ -432,6 +436,14 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveExternalPlayerForwardSubtitles(enabled)
}
+ fun setExternalPlayerSendSkipSegments(enabled: Boolean) {
+ ensureLoaded()
+ if (externalPlayerSendSkipSegments == enabled) return
+ externalPlayerSendSkipSegments = enabled
+ publish()
+ PlayerSettingsStorage.saveExternalPlayerSendSkipSegments(enabled)
+ }
+
fun setPreferredAudioLanguage(language: String) {
ensureLoaded()
val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE
@@ -893,6 +905,7 @@ object PlayerSettingsRepository {
touchGesturesEnabled = touchGesturesEnabled,
externalPlayerEnabled = externalPlayerEnabled,
externalPlayerForwardSubtitles = externalPlayerForwardSubtitles,
+ externalPlayerSendSkipSegments = externalPlayerSendSkipSegments,
externalPlayerId = externalPlayerId,
preferredAudioLanguage = preferredAudioLanguage,
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
index 8ca6050ee..3b7a3a98a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
@@ -17,6 +17,8 @@ internal expect object PlayerSettingsStorage {
fun saveExternalPlayerEnabled(enabled: Boolean)
fun loadExternalPlayerForwardSubtitles(): Boolean?
fun saveExternalPlayerForwardSubtitles(enabled: Boolean)
+ fun loadExternalPlayerSendSkipSegments(): Boolean?
+ fun saveExternalPlayerSendSkipSegments(enabled: Boolean)
fun loadExternalPlayerId(): String?
fun saveExternalPlayerId(playerId: String?)
fun loadPreferredAudioLanguage(): String?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt
index 9e96d4aa9..39a532966 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt
@@ -12,87 +12,70 @@ object SkipIntroRepository {
private val introDbConfigured: Boolean
get() = IntroDbConfig.URL.isNotBlank()
- suspend fun getSkipIntervals(imdbId: String?, season: Int, episode: Int): List {
+ suspend fun getSkipIntervals(
+ imdbId: String?,
+ season: Int,
+ episode: Int,
+ requireSkipIntroEnabled: Boolean = true,
+ ): List {
if (imdbId == null) return emptyList()
val settings = PlayerSettingsRepository.uiState.value
- if (!settings.skipIntroEnabled) return emptyList()
+ if (requireSkipIntroEnabled && !settings.skipIntroEnabled) return emptyList()
val cacheKey = "$imdbId:$season:$episode"
cache[cacheKey]?.let { return it }
- if (introDbConfigured) {
- val result = fetchFromIntroDb(imdbId, season, episode)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
-
+ val introDb = if (introDbConfigured) fetchFromIntroDb(imdbId, season, episode) else emptyList()
val entries = resolveImdbEntries(imdbId)
+ val animeSkip = fetchAnimeSkipForEntries(entries, season, episode)
val malId = entries.getOrNull(season - 1)?.myanimelist?.toString()
?: entries.firstOrNull()?.myanimelist?.toString()
- if (malId != null) {
- val result = fetchFromAniSkip(malId, episode)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
+ val aniSkip = if (malId != null) fetchFromAniSkip(malId, episode) else emptyList()
- val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
- val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
- for ((anilistId, seasonFilter) in listOfNotNull(
- seasonAnilistId?.let { it to null },
- if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
- )) {
- val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
-
- return emptyList().also { cache[cacheKey] = it }
+ return mergeByPriority(introDb, animeSkip, aniSkip).also { cache[cacheKey] = it }
}
- suspend fun getSkipIntervalsForMal(malId: String, episode: Int): List {
+ suspend fun getSkipIntervalsForMal(
+ malId: String,
+ episode: Int,
+ requireSkipIntroEnabled: Boolean = true,
+ ): List {
val settings = PlayerSettingsRepository.uiState.value
- if (!settings.skipIntroEnabled) return emptyList()
+ if (requireSkipIntroEnabled && !settings.skipIntroEnabled) return emptyList()
val cacheKey = "mal:$malId:$episode"
cache[cacheKey]?.let { return it }
- val aniSkipResult = fetchFromAniSkip(malId, episode)
- if (aniSkipResult.isNotEmpty()) return aniSkipResult.also { cache[cacheKey] = it }
+ val aniSkip = fetchFromAniSkip(malId, episode)
val imdbId = try {
SkipIntroApi.resolveMalToImdb(malId)?.imdb
} catch (_: Exception) { null }
+ var introDb = emptyList()
+ var animeSkip = emptyList()
if (imdbId != null) {
val entries = resolveImdbEntries(imdbId)
val season = entries.indexOfFirst { it.myanimelist == malId.toIntOrNull() } + 1
-
- if (introDbConfigured) {
- val result = fetchFromIntroDb(imdbId, season, episode)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
- val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
- val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
- for ((anilistId, seasonFilter) in listOfNotNull(
- seasonAnilistId?.let { it to null },
- if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
- )) {
- val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
+ if (introDbConfigured) introDb = fetchFromIntroDb(imdbId, season, episode)
+ animeSkip = fetchAnimeSkipForEntries(entries, season, episode)
} else {
val anilistId = try {
SkipIntroApi.resolveMalToAnilist(malId)?.anilist?.toString()
} catch (_: Exception) { null }
- if (anilistId != null) {
- val result = fetchFromAnimeSkip(anilistId, episode, season = null)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
+ if (anilistId != null) animeSkip = fetchFromAnimeSkip(anilistId, episode, season = null)
}
- return emptyList().also { cache[cacheKey] = it }
+ return mergeByPriority(introDb, animeSkip, aniSkip).also { cache[cacheKey] = it }
}
- suspend fun getSkipIntervalsForKitsu(kitsuId: String, episode: Int): List {
+ suspend fun getSkipIntervalsForKitsu(
+ kitsuId: String,
+ episode: Int,
+ requireSkipIntroEnabled: Boolean = true,
+ ): List {
val settings = PlayerSettingsRepository.uiState.value
- if (!settings.skipIntroEnabled) return emptyList()
+ if (requireSkipIntroEnabled && !settings.skipIntroEnabled) return emptyList()
val cacheKey = "kitsu:$kitsuId:$episode"
cache[cacheKey]?.let { return it }
@@ -100,44 +83,69 @@ object SkipIntroRepository {
val malId = try {
SkipIntroApi.resolveKitsuToMal(kitsuId)?.myanimelist?.toString()
} catch (_: Exception) { null }
-
- if (malId != null) {
- val result = fetchFromAniSkip(malId, episode)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
+ val aniSkip = if (malId != null) fetchFromAniSkip(malId, episode) else emptyList()
val imdbId = try {
SkipIntroApi.resolveKitsuToImdb(kitsuId)?.imdb
} catch (_: Exception) { null }
+ var introDb = emptyList()
+ var animeSkip = emptyList()
if (imdbId != null) {
val entries = resolveImdbEntries(imdbId)
val season = entries.indexOfFirst { it.kitsu == kitsuId.toIntOrNull() } + 1
-
- if (introDbConfigured) {
- val result = fetchFromIntroDb(imdbId, season, episode)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
- val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
- val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
- for ((anilistId, seasonFilter) in listOfNotNull(
- seasonAnilistId?.let { it to null },
- if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
- )) {
- val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
+ if (introDbConfigured) introDb = fetchFromIntroDb(imdbId, season, episode)
+ animeSkip = fetchAnimeSkipForEntries(entries, season, episode)
} else {
val anilistId = try {
SkipIntroApi.resolveKitsuToAnilist(kitsuId)?.anilist?.toString()
} catch (_: Exception) { null }
- if (anilistId != null) {
- val result = fetchFromAnimeSkip(anilistId, episode, season = null)
- if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
- }
+ if (anilistId != null) animeSkip = fetchFromAnimeSkip(anilistId, episode, season = null)
}
- return emptyList().also { cache[cacheKey] = it }
+ return mergeByPriority(introDb, animeSkip, aniSkip).also { cache[cacheKey] = it }
+ }
+
+ /**
+ * Merge provider results into one best-of: fill each segment category (opening / ending /
+ * recap) from the highest-priority provider that has it. Arguments MUST be passed in priority
+ * order (IntroDB has the broadest coverage, then Anime-Skip, then AniSkip), so a partial
+ * result from one provider never shadows a complete segment from another.
+ */
+ private fun mergeByPriority(vararg providerResults: List): List {
+ val chosen = LinkedHashMap()
+ for (result in providerResults) {
+ for (interval in result) {
+ val category = segmentCategory(interval.type) ?: continue
+ if (category !in chosen) chosen[category] = interval
+ }
+ }
+ return chosen.values.toList()
+ }
+
+ private fun segmentCategory(type: String): String? = when (type.lowercase()) {
+ "intro", "op", "mixed-op" -> "opening"
+ "outro", "ed", "mixed-ed", "credits", "ending" -> "ending"
+ "recap" -> "recap"
+ else -> null
+ }
+
+ // AnimeSkip: season-specific AniList ID first, then season-1 as a season-filtered fallback.
+ private suspend fun fetchAnimeSkipForEntries(
+ entries: List,
+ season: Int,
+ episode: Int
+ ): List {
+ val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
+ val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
+ for ((anilistId, seasonFilter) in listOfNotNull(
+ seasonAnilistId?.let { it to null },
+ if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
+ )) {
+ val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
+ if (result.isNotEmpty()) return result
+ }
+ return emptyList()
}
private suspend fun fetchFromIntroDb(imdbId: String, season: Int, episode: Int): List {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
index 555060434..2bb033601 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
@@ -368,6 +368,14 @@ private fun PlaybackSettingsSection(
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setExternalPlayerForwardSubtitles,
)
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_external_player_send_skip_segments),
+ description = stringResource(Res.string.settings_playback_external_player_send_skip_segments_description),
+ checked = autoPlayPlayerSettings.externalPlayerSendSkipSegments,
+ isTablet = isTablet,
+ onCheckedChange = PlayerSettingsRepository::setExternalPlayerSendSkipSegments,
+ )
}
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
index 4e60c4d2e..e15bc88f2 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
@@ -24,6 +24,7 @@ actual object PlayerSettingsStorage {
private const val touchGesturesEnabledKey = "touch_gestures_enabled"
private const val externalPlayerEnabledKey = "external_player_enabled"
private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles"
+ private const val externalPlayerSendSkipSegmentsKey = "external_player_send_skip_segments"
private const val externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
@@ -91,6 +92,7 @@ actual object PlayerSettingsStorage {
touchGesturesEnabledKey,
externalPlayerEnabledKey,
externalPlayerForwardSubtitlesKey,
+ externalPlayerSendSkipSegmentsKey,
externalPlayerIdKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
@@ -256,6 +258,20 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerForwardSubtitlesKey))
}
+ actual fun loadExternalPlayerSendSkipSegments(): Boolean? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey)
+ return if (defaults.objectForKey(key) != null) {
+ defaults.boolForKey(key)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveExternalPlayerSendSkipSegments(enabled: Boolean) {
+ NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey))
+ }
+
actual fun loadExternalPlayerId(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(externalPlayerIdKey)