feat: player source identity management and UI controls

This commit is contained in:
tapframe 2026-06-08 19:24:25 +05:30
parent ef14add341
commit 2c3bbbef71
5 changed files with 333 additions and 15 deletions

2
MPVKit

@ -1 +1 @@
Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5
Subproject commit 0e5e4a2f9d59c3c79585072f4e8b4aadaee2cb80

View file

@ -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

View file

@ -102,6 +102,11 @@ internal class PlayerScreenRuntime(
var activeTorrentMagnetUri by mutableStateOf(torrentMagnetUri)
var activeTorrentTrackers by mutableStateOf(torrentTrackers)
var p2pResolvedSourceUrl by mutableStateOf<String?>(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)

View file

@ -938,13 +938,16 @@ private fun PlayerScreenRuntime.buildPlayerControlEpisodeStreamItems(): List<Pla
}
private fun PlayerScreenRuntime.isCurrentPlayerControlStream(stream: StreamItem): Boolean {
val activeKey = activeSourceIdentityKey
val streamKey = stream.playerSourceIdentityKey()
if (activeKey != null) {
return streamKey == activeKey
}
val directUrl = stream.playableDirectUrl
if (directUrl != null && directUrl == activeSourceUrl) return true
val infoHash = stream.p2pInfoHash
if (infoHash != null && infoHash == activeTorrentInfoHash) return true
return activeStreamTitle.isNotBlank() &&
stream.streamLabel.equals(activeStreamTitle, ignoreCase = true) &&
stream.addonName.equals(activeProviderName, ignoreCase = true)
return false
}
@Composable

View file

@ -22,6 +22,10 @@
--side-icon: 26px;
--play-pad: 13px;
--play-icon: 34px;
--motion-fast: 120ms;
--motion-standard: 180ms;
--motion-panel: 220ms;
--ease-out: cubic-bezier(.2, .8, .2, 1);
color-scheme: dark;
}
@ -117,6 +121,11 @@
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
touch-action: manipulation;
}
button:disabled {
cursor: default;
}
@ -263,6 +272,58 @@
background: rgba(0, 0, 0, .35);
}
.header-button,
.side-control,
.play-control,
.locked-button,
.action,
.panel-button,
.filter-chip,
.capture-button,
.subtitle-tab,
.round-step,
.swatch,
.sync-cue,
.track-row,
.segment-button {
transform: translateZ(0);
transition:
transform var(--motion-fast) var(--ease-out),
background-color var(--motion-standard) ease,
border-color var(--motion-standard) ease,
color var(--motion-standard) ease,
opacity var(--motion-standard) ease,
box-shadow var(--motion-standard) ease;
will-change: transform;
}
.header-button:hover,
.locked-button:hover {
background: rgba(255, 255, 255, .14);
}
.side-control:hover,
.play-control:hover,
.action:hover {
background: rgba(255, 255, 255, .12);
}
.header-button:active,
.header-button.is-pressed,
.locked-button:active,
.locked-button.is-pressed,
.side-control:active,
.side-control.is-pressed,
.action:active,
.action.is-pressed {
transform: scale(.94);
}
.play-control:active,
.play-control.is-pressed {
transform: scale(.91);
}
.header-button svg {
width: var(--header-icon);
height: var(--header-icon);
@ -379,6 +440,14 @@
border-radius: 999px;
background: #fff;
box-shadow: 0 0 0 1px rgba(255, 255, 255, .18);
transition:
transform var(--motion-fast) var(--ease-out),
box-shadow var(--motion-standard) ease;
}
.scrub:active::-webkit-slider-thumb {
transform: scale(1.18);
box-shadow: 0 0 0 6px rgba(255, 255, 255, .16);
}
.time-row {
@ -451,7 +520,20 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, .54);
background-color: rgba(0, 0, 0, .54);
opacity: 0;
pointer-events: none;
transition: opacity var(--motion-standard) ease;
}
.modal-layer.modal-visible {
opacity: 1;
pointer-events: auto;
}
.modal-layer.modal-closing {
opacity: 0;
pointer-events: none;
}
.track-panel {
@ -465,6 +547,22 @@
background: #16171d;
color: #fff;
box-shadow: 0 20px 70px rgba(0, 0, 0, .45);
opacity: 0;
transform: translateY(14px) scale(.985);
transition:
transform var(--motion-panel) var(--ease-out),
opacity var(--motion-standard) ease;
will-change: transform, opacity;
}
.modal-layer.modal-visible .track-panel {
opacity: 1;
transform: translateY(0) scale(1);
}
.modal-layer.modal-closing .track-panel {
opacity: 0;
transform: translateY(10px) scale(.99);
}
.track-panel.wide {
@ -522,6 +620,35 @@
white-space: nowrap;
}
.panel-button:hover:not(:disabled),
.filter-chip:hover:not(:disabled),
.capture-button:hover:not(:disabled),
.subtitle-tab:hover:not(:disabled),
.round-step:hover:not(:disabled),
.segment-button:hover:not(:disabled) {
background: rgba(255, 255, 255, .13);
color: rgba(255, 255, 255, .9);
}
.panel-button:active:not(:disabled),
.panel-button.is-pressed:not(:disabled),
.filter-chip:active:not(:disabled),
.filter-chip.is-pressed:not(:disabled),
.capture-button:active:not(:disabled),
.capture-button.is-pressed:not(:disabled),
.subtitle-tab:active:not(:disabled),
.subtitle-tab.is-pressed:not(:disabled),
.round-step:active:not(:disabled),
.round-step.is-pressed:not(:disabled),
.segment-button:active:not(:disabled),
.segment-button.is-pressed:not(:disabled),
.swatch:active:not(:disabled),
.swatch.is-pressed:not(:disabled),
.sync-cue:active:not(:disabled),
.sync-cue.is-pressed:not(:disabled) {
transform: scale(.96);
}
.panel-button.primary,
.filter-chip.selected,
.segment-button.selected {
@ -530,6 +657,12 @@
border-color: rgba(255, 255, 255, .18);
}
.panel-button.primary:hover:not(:disabled),
.filter-chip.selected:hover:not(:disabled),
.segment-button.selected:hover:not(:disabled) {
background: #3c7bff;
}
.filter-row {
display: flex;
gap: 8px;
@ -740,12 +873,25 @@
opacity: .45;
}
.track-row:hover:not(:disabled) {
background: rgba(255, 255, 255, .13);
}
.track-row:active:not(:disabled),
.track-row.is-pressed:not(:disabled) {
transform: scale(.985);
}
.track-row.selected {
background: #26384f;
color: #e2ecff;
font-weight: 700;
}
.track-row.selected:hover:not(:disabled) {
background: #2d4565;
}
.track-label {
min-width: 0;
overflow: hidden;
@ -861,6 +1007,12 @@
opacity: 1;
}
button:focus-visible,
.scrub:focus-visible {
outline: 2px solid rgba(158, 202, 255, .95);
outline-offset: 3px;
}
.track-empty {
padding: 36px 12px 44px;
color: rgba(255, 255, 255, .64);
@ -988,6 +1140,33 @@
}
}
@media (prefers-reduced-motion: reduce) {
.top-gradient,
.bottom-gradient,
.metadata,
.header-actions,
.center-controls,
.progress,
.modal-layer,
.track-panel,
.header-button,
.side-control,
.play-control,
.locked-button,
.action,
.panel-button,
.filter-chip,
.capture-button,
.subtitle-tab,
.round-step,
.swatch,
.sync-cue,
.track-row,
.segment-button {
transition-duration: 1ms;
}
}
svg {
display: block;
fill: currentColor;
@ -1524,6 +1703,7 @@
let scrubPositionMs = 0;
let tapTimer = 0;
let activeModal = "";
let pressedButton = null;
let sourceFilterId = "";
let selectedEpisodeSeason = null;
let episodeStreamFilterId = "";
@ -1534,6 +1714,9 @@
status: "",
};
let lastSourceControlsDiagnostic = "";
const prefersReducedMotion = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const modalTransitionMs = prefersReducedMotion ? 1 : 240;
const sourceDiagnosticCommands = new Set([
"sources",
@ -1597,13 +1780,59 @@
return durationMs > 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();