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)