diff --git a/MPVKit b/MPVKit index 2e81303b7..0e5e4a2f9 160000 --- a/MPVKit +++ b/MPVKit @@ -1 +1 @@ -Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5 +Subproject commit 0e5e4a2f9d59c3c79585072f4e8b4aadaee2cb80 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 87bf6545e..7efc48f30 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -49,6 +49,58 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?) internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = stream.needsLocalDebridResolve && stream.p2pInfoHash != null +internal fun StreamItem.playerSourceIdentityKey(): String? { + p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> + return "torrent:$hash:${p2pFileIdx ?: -1}" + } + + clientResolve?.let { resolve -> + val raw = resolve.stream?.raw + val keyParts = listOf( + addonId, + resolve.service, + resolve.serviceIndex?.toString(), + resolve.infoHash?.trim()?.lowercase(), + resolve.fileIdx?.toString(), + resolve.magnetUri, + resolve.torrentName, + resolve.filename, + raw?.torrentName, + raw?.filename, + raw?.size?.toString(), + behaviorHints.filename, + behaviorHints.videoSize?.toString(), + streamLabel, + streamSubtitle, + ).map { it.orEmpty().trim() } + if (keyParts.any { it.isNotBlank() }) { + return "resolve:${keyParts.joinToString("|")}" + } + } + + behaviorHints.videoHash?.trim()?.takeIf { it.isNotBlank() }?.let { hash -> + return "hash:$addonId:$hash:${behaviorHints.videoSize ?: ""}:${behaviorHints.filename.orEmpty()}" + } + + playableDirectUrl?.trim()?.takeIf { it.isNotBlank() }?.let { url -> + return "url:$url" + } + + val fallbackParts = listOf( + addonId, + addonName, + streamLabel, + streamSubtitle.orEmpty(), + behaviorHints.filename.orEmpty(), + behaviorHints.videoSize?.toString().orEmpty(), + sourceName.orEmpty(), + sources.joinToString(","), + ).map { it.trim() } + return fallbackParts + .takeIf { parts -> parts.any { it.isNotBlank() } } + ?.joinToString(separator = "|", prefix = "meta:") +} + internal fun PlayerScreenRuntime.stopActiveP2pStream() { if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { P2pStreamingEngine.stopStream() @@ -119,6 +171,7 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { activeTorrentFilename = stream.p2pFilename activeTorrentMagnetUri = stream.torrentMagnetUri activeTorrentTrackers = stream.p2pTrackers + activeSourceIdentityKey = stream.playerSourceIdentityKey() activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -190,7 +243,11 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { return } val url = stream.playableDirectUrl ?: return - if (url == activeSourceUrl) return + val sourceIdentityKey = stream.playerSourceIdentityKey() + if (url == activeSourceUrl) { + activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey + return + } val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) flushWatchProgress() stopActiveP2pStream() @@ -202,6 +259,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeSourceIdentityKey = sourceIdentityKey activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -275,6 +333,7 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() + activeSourceIdentityKey = null activeStreamTitle = downloadItem.streamTitle.ifBlank { episode.title.ifBlank { title } } @@ -380,6 +439,7 @@ private fun PlayerScreenRuntime.applyEpisodeStreamMetadata( episode: MetaVideo, resume: EpisodeResume, ) { + activeSourceIdentityKey = stream.playerSourceIdentityKey() activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 85e4c938e..e816929bf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -102,6 +102,11 @@ internal class PlayerScreenRuntime( var activeTorrentMagnetUri by mutableStateOf(torrentMagnetUri) var activeTorrentTrackers by mutableStateOf(torrentTrackers) var p2pResolvedSourceUrl by mutableStateOf(null) + var activeSourceIdentityKey by mutableStateOf( + torrentInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> + "torrent:$hash:${torrentFileIdx ?: -1}" + } ?: sourceUrl.trim().takeIf { it.isNotBlank() }?.let { url -> "url:$url" }, + ) var activeStreamTitle by mutableStateOf(streamTitle) var activeStreamSubtitle by mutableStateOf(streamSubtitle) var activeProviderName by mutableStateOf(providerName) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index f1e4191bc..1a2416c75 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -938,13 +938,16 @@ private fun PlayerScreenRuntime.buildPlayerControlEpisodeStreamItems(): List 0 ? Math.round(durationMs * Number(seek.value) / 1000) : 0; }; - const modalElements = [audioModal, subtitleModal, sourceModal, episodesModal, submitIntroModal, p2pConsentModal]; + const modalByName = { + audio: audioModal, + subtitles: subtitleModal, + sources: sourceModal, + episodes: episodesModal, + submitIntro: submitIntroModal, + p2pConsent: p2pConsentModal, + }; + const modalElements = Object.values(modalByName); + const modalCloseTimers = new Map(); - const closePlayerModal = (notifyDismiss = false) => { + const setModalVisibility = (modal, visible, animated = true) => { + const pendingTimer = modalCloseTimers.get(modal); + if (pendingTimer) { + window.clearTimeout(pendingTimer); + modalCloseTimers.delete(modal); + } + if (visible) { + modal.dataset.modalState = "open"; + modal.hidden = false; + modal.classList.remove("modal-closing"); + window.requestAnimationFrame(() => { + if (modal.dataset.modalState === "open") { + modal.classList.add("modal-visible"); + } + }); + return; + } + + modal.dataset.modalState = "closed"; + modal.classList.remove("modal-visible"); + if (!animated || modal.hidden) { + modal.hidden = true; + modal.classList.remove("modal-closing"); + return; + } + + modal.classList.add("modal-closing"); + const timer = window.setTimeout(() => { + modalCloseTimers.delete(modal); + if (modal.dataset.modalState === "closed") { + modal.hidden = true; + modal.classList.remove("modal-closing"); + } + }, modalTransitionMs); + modalCloseTimers.set(modal, timer); + }; + + const closePlayerModal = (notifyDismiss = false, animated = true) => { const closingModal = activeModal; activeModal = ""; modalElements.forEach(modal => { - modal.hidden = true; + setModalVisibility(modal, false, animated); }); if (notifyDismiss && closingModal === "p2pConsent") { send("cancelP2pForPlayerControls", 0); @@ -1611,13 +1840,12 @@ }; const openPlayerModal = modal => { + const targetModal = modalByName[modal]; + if (!targetModal) { + closePlayerModal(false); + return; + } activeModal = modal; - audioModal.hidden = modal !== "audio"; - subtitleModal.hidden = modal !== "subtitles"; - sourceModal.hidden = modal !== "sources"; - episodesModal.hidden = modal !== "episodes"; - submitIntroModal.hidden = modal !== "submitIntro"; - p2pConsentModal.hidden = modal !== "p2pConsent"; if (modal === "submitIntro") { submitIntroDraft = { segmentType: state.submitIntroSegmentType || "intro", @@ -1627,6 +1855,9 @@ }; } renderActiveModal(); + modalElements.forEach(modalElement => { + setModalVisibility(modalElement, modalElement === targetModal); + }); }; const normalizeTracks = tracks => @@ -2246,6 +2477,25 @@ renderActiveModal(); }; + const clearPressedButton = () => { + if (!pressedButton) return; + pressedButton.classList.remove("is-pressed"); + pressedButton = null; + }; + + document.addEventListener("pointerdown", event => { + const button = event.target.closest("button"); + if (!button || button.disabled) return; + clearPressedButton(); + pressedButton = button; + button.classList.add("is-pressed"); + }, true); + + document.addEventListener("pointerup", clearPressedButton, true); + document.addEventListener("pointercancel", clearPressedButton, true); + document.addEventListener("dragend", clearPressedButton, true); + window.addEventListener("blur", clearPressedButton); + document.querySelectorAll("[data-command]").forEach(button => { button.addEventListener("click", event => { event.stopPropagation();