feat: add submit intro functionality and UI support

This commit is contained in:
tapframe 2026-05-12 18:53:20 +05:30
parent b46706a588
commit 849c265a3f
8 changed files with 440 additions and 54 deletions

View file

@ -67,6 +67,12 @@ public func nuvio_player_set_is_series(_ ptr: UnsafeMutableRawPointer, _ value:
DispatchQueue.main.async { p.state.isSeries = value }
}
@_cdecl("nuvio_player_set_submit_intro_enabled")
public func nuvio_player_set_submit_intro_enabled(_ ptr: UnsafeMutableRawPointer, _ enabled: Bool) {
let p = player(ptr)
DispatchQueue.main.async { p.state.canSubmitIntro = enabled }
}
@_cdecl("nuvio_player_load_file")
public func nuvio_player_load_file(
_ ptr: UnsafeMutableRawPointer,
@ -347,6 +353,31 @@ public func nuvio_player_pop_next_episode_pressed(_ ptr: UnsafeMutableRawPointer
return false
}
@_cdecl("nuvio_player_pop_submit_intro_requested")
public func nuvio_player_pop_submit_intro_requested(_ ptr: UnsafeMutableRawPointer) -> Bool {
let p = player(ptr)
if p.state.submitIntroRequested {
p.state.submitIntroRequested = false
return true
}
return false
}
@_cdecl("nuvio_player_get_submit_intro_segment_type")
public func nuvio_player_get_submit_intro_segment_type(_ ptr: UnsafeMutableRawPointer) -> UnsafePointer<CChar>? {
return retainAndReturn(player(ptr).state.submitIntroSegmentType)
}
@_cdecl("nuvio_player_get_submit_intro_start_sec")
public func nuvio_player_get_submit_intro_start_sec(_ ptr: UnsafeMutableRawPointer) -> Double {
return player(ptr).state.submitIntroStartSec
}
@_cdecl("nuvio_player_get_submit_intro_end_sec")
public func nuvio_player_get_submit_intro_end_sec(_ ptr: UnsafeMutableRawPointer) -> Double {
return player(ptr).state.submitIntroEndSec
}
@_cdecl("nuvio_player_is_addon_subtitles_fetch_requested")
public func nuvio_player_is_addon_subtitles_fetch_requested(_ ptr: UnsafeMutableRawPointer) -> Bool {
let p = player(ptr)
@ -690,3 +721,17 @@ public func nuvio_player_show_episode_streams(
p.state.showEpisodeStreams = true
}
}
@_cdecl("nuvio_player_dismiss_panels")
public func nuvio_player_dismiss_panels(_ ptr: UnsafeMutableRawPointer) {
let p = player(ptr)
DispatchQueue.main.async {
p.state.showSourcesPanel = false
p.state.showEpisodesPanel = false
p.state.showEpisodeStreams = false
p.state.showSubtitlePanel = false
p.state.showAudioPanel = false
p.state.showSubmitIntroPanel = false
p.state.controlsVisible = true
}
}

View file

@ -66,9 +66,13 @@ struct NuvioControlsView: View {
var onAddSubtitleUrl: ((String) -> Void)?
var onRemoveExternalAndSelect: ((Int) -> Void)?
var onFetchAddonSubtitles: (() -> Void)?
var onSubmitIntro: ((String, Double, Double) -> Void)?
@State private var isDragging = false
@State private var dragPosition: Double = 0
@State private var submitIntroSegmentType = "intro"
@State private var submitIntroStartTime = "00:00"
@State private var submitIntroEndTime = "00:00"
var body: some View {
GeometryReader { geometry in
@ -77,7 +81,11 @@ struct NuvioControlsView: View {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
state.controlsVisible.toggle()
if state.controlsLocked {
state.lockedOverlayVisible = true
} else {
state.controlsVisible.toggle()
}
}
Group {
@ -99,10 +107,10 @@ struct NuvioControlsView: View {
.padding(.bottom, metrics.sliderBottomOffset)
}
}
.opacity(state.controlsVisible ? 1 : 0)
.opacity(state.controlsVisible && !state.controlsLocked ? 1 : 0)
.animation(.easeInOut(duration: 0.25), value: state.controlsVisible)
if state.skipButtonType != nil {
if !state.controlsLocked && state.skipButtonType != nil {
VStack {
Spacer()
HStack {
@ -114,7 +122,7 @@ struct NuvioControlsView: View {
}
}
if state.showNextEpisode {
if !state.controlsLocked && state.showNextEpisode {
VStack {
Spacer()
HStack {
@ -143,6 +151,14 @@ struct NuvioControlsView: View {
)
}
if state.showSubmitIntroPanel {
submitIntroModal
}
if state.controlsLocked && state.lockedOverlayVisible {
lockedOverlay
}
if state.showSourcesPanel {
NuvioSourcesPanel(state: state) {
state.showSourcesPanel = false
@ -237,20 +253,47 @@ struct NuvioControlsView: View {
}
}
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: metrics.headerIconSize, weight: .semibold))
.foregroundColor(.white)
.frame(width: metrics.headerIconSize + 16, height: metrics.headerIconSize + 16)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
HStack(spacing: 10) {
if state.canSubmitIntro {
headerIconButton(icon: "flag.fill", size: metrics.headerIconSize) {
state.showSubmitIntroPanel = true
}
}
headerIconButton(icon: state.controlsLocked ? "lock.open.fill" : "lock.fill", size: metrics.headerIconSize) {
if state.controlsLocked {
state.controlsLocked = false
state.lockedOverlayVisible = false
state.controlsVisible = true
} else {
state.controlsLocked = true
state.controlsVisible = false
state.lockedOverlayVisible = false
state.showSubtitlePanel = false
state.showAudioPanel = false
state.showSourcesPanel = false
state.showEpisodesPanel = false
state.showSubmitIntroPanel = false
}
}
headerIconButton(icon: "xmark", size: metrics.headerIconSize, action: onClose)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 28)
.padding(.top, 16)
}
func headerIconButton(icon: String, size: CGFloat, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: size, weight: .semibold))
.foregroundColor(.white)
.frame(width: size + 16, height: size + 16)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
func centerControls(metrics: ControlMetrics) -> some View {
HStack(spacing: metrics.centerGap) {
Button(action: onSeekBack) {
@ -662,6 +705,183 @@ struct NuvioControlsView: View {
}
}
var lockedOverlay: some View {
ZStack {
Color.black.opacity(0.45)
.onTapGesture {
state.lockedOverlayVisible = false
}
Button(action: {
state.controlsLocked = false
state.lockedOverlayVisible = false
state.controlsVisible = true
}) {
HStack(spacing: 10) {
Image(systemName: "lock.open.fill")
.font(.system(size: 18, weight: .semibold))
Text("Unlock")
.font(.system(size: 15, weight: .bold))
}
.foregroundColor(.white)
.padding(.horizontal, 22)
.padding(.vertical, 14)
.background(Color.white.opacity(0.16))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
var submitIntroModal: some View {
ZStack {
Color.black.opacity(0.6)
.onTapGesture { state.showSubmitIntroPanel = false }
VStack(spacing: 16) {
HStack {
Text("Submit Timestamps")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: { state.showSubmitIntroPanel = false }) {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.white.opacity(0.15))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
submitSegmentButton(title: "Intro", value: "intro")
submitSegmentButton(title: "Recap", value: "recap")
submitSegmentButton(title: "Outro", value: "outro")
}
submitTimeRow(title: "Start Time", text: $submitIntroStartTime)
submitTimeRow(title: "End Time", text: $submitIntroEndTime)
HStack(spacing: 12) {
Button(action: { state.showSubmitIntroPanel = false }) {
Text("Cancel")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white.opacity(0.75))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
Button(action: {
guard let start = parseSubmitTime(submitIntroStartTime),
let end = parseSubmitTime(submitIntroEndTime),
end > start else { return }
onSubmitIntro?(submitIntroSegmentType, start, end)
state.showSubmitIntroPanel = false
}) {
HStack(spacing: 8) {
Image(systemName: "paperplane.fill")
.font(.system(size: 13, weight: .bold))
Text("Submit")
.font(.system(size: 14, weight: .bold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding(20)
.frame(width: 420)
.background(Color(red: 0.12, green: 0.12, blue: 0.12))
.clipShape(RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.onAppear {
if submitIntroStartTime == "00:00" && submitIntroEndTime == "00:00" {
submitIntroStartTime = formatSubmitTime(Double(state.positionMs) / 1000.0)
submitIntroEndTime = formatSubmitTime(Double(state.positionMs) / 1000.0)
}
}
}
}
func submitSegmentButton(title: String, value: String) -> some View {
let selected = submitIntroSegmentType == value
return Button(action: { submitIntroSegmentType = value }) {
Text(title)
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selected ? Color.white.opacity(0.2) : Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
func submitTimeRow(title: String, text: Binding<String>) -> some View {
HStack(spacing: 10) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.75))
.frame(width: 92, alignment: .leading)
TextField("00:00", text: text)
.textFieldStyle(.plain)
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
Button(action: {
text.wrappedValue = formatSubmitTime(Double(state.positionMs) / 1000.0)
}) {
Text("Now")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.white.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
func parseSubmitTime(_ value: String) -> Double? {
let parts = value.split(separator: ":").map(String.init)
guard parts.count == 2 || parts.count == 3 else { return nil }
let numbers = parts.compactMap(Double.init)
guard numbers.count == parts.count else { return nil }
if numbers.contains(where: { $0 < 0 }) { return nil }
if numbers.count == 2 {
return numbers[0] * 60 + numbers[1]
}
return numbers[0] * 3600 + numbers[1] * 60 + numbers[2]
}
func formatSubmitTime(_ seconds: Double) -> String {
let total = max(Int(seconds.rounded(.down)), 0)
let h = total / 3600
let m = (total / 60) % 60
let s = total % 60
if h > 0 {
return "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))"
}
return "\(String(format: "%02d", m)):\(String(format: "%02d", s))"
}
var subtitleModal: some View {
ZStack {
Color.black.opacity(0.6)
@ -740,12 +960,14 @@ struct NuvioControlsView: View {
lang: "",
isSelected: !state.subtitleTracks.contains(where: { $0.selected })
) {
state.selectedAddonSubtitleId = nil
onRemoveExternalAndSelect?(-1)
state.showSubtitlePanel = false
}
ForEach(Array(state.subtitleTracks.enumerated()), id: \.element.id) { _, track in
let label = track.title.isEmpty ? (track.lang.isEmpty ? "Track \(track.id)" : track.lang) : track.title
trackRow(label: label, lang: track.lang, isSelected: track.selected) {
state.selectedAddonSubtitleId = nil
onRemoveExternalAndSelect?(track.id)
state.showSubtitlePanel = false
}
@ -898,9 +1120,9 @@ struct NuvioControlsView: View {
Button(action: {
state.subtitleStyleTextColor = 0
state.subtitleStyleFontSize = 30
state.subtitleStyleOutlineEnabled = true
state.subtitleStyleBottomOffset = 5
state.subtitleStyleFontSize = 18
state.subtitleStyleOutlineEnabled = false
state.subtitleStyleBottomOffset = 20
applyCurrentSubtitleStyle()
}) {
Text("Reset Defaults")
@ -1002,5 +1224,3 @@ private extension Double {
return min(max(self, range.lowerBound), range.upperBound)
}
}

View file

@ -69,6 +69,8 @@ final class NuvioPlayerState: ObservableObject {
@Published var controlsVisible: Bool = true
@Published var cursorHidden: Bool = false
@Published var controlsLocked: Bool = false
@Published var lockedOverlayVisible: Bool = false
@Published var title: String = ""
@Published var streamTitle: String = ""
@ -105,9 +107,9 @@ final class NuvioPlayerState: ObservableObject {
@Published var gestureFeedback: GestureFeedbackState? = nil
@Published var subtitleStyleTextColor: Int = 0
@Published var subtitleStyleFontSize: Int = 30
@Published var subtitleStyleOutlineEnabled: Bool = true
@Published var subtitleStyleBottomOffset: Int = 5
@Published var subtitleStyleFontSize: Int = 18
@Published var subtitleStyleOutlineEnabled: Bool = false
@Published var subtitleStyleBottomOffset: Int = 20
@Published var addonSubtitles: [AddonSubtitleInfo] = []
@Published var addonSubtitlesLoading: Bool = false
@ -120,6 +122,8 @@ final class NuvioPlayerState: ObservableObject {
@Published var showEpisodesPanel: Bool = false
@Published var hasVideoId: Bool = false
@Published var isSeries: Bool = false
@Published var canSubmitIntro: Bool = false
@Published var showSubmitIntroPanel: Bool = false
@Published var sourceStreams: [NuvioStreamInfo] = []
@Published var sourceAddonGroups: [NuvioAddonGroupInfo] = []
@ -148,6 +152,10 @@ final class NuvioPlayerState: ObservableObject {
var episodeFilterChanged: Bool = false
var episodeReloadRequested: Bool = false
var episodeBackRequested: Bool = false
var submitIntroRequested: Bool = false
var submitIntroSegmentType: String = "intro"
var submitIntroStartSec: Double = 0
var submitIntroEndSec: Double = 0
var resizeModeLabel: String {
switch resizeMode {

View file

@ -76,6 +76,12 @@ final class NuvioPlayerWindow {
},
onFetchAddonSubtitles: { [weak self] in
self?.state.addonSubtitlesFetchRequested = true
},
onSubmitIntro: { [weak self] segmentType, startSec, endSec in
self?.state.submitIntroSegmentType = segmentType
self?.state.submitIntroStartSec = startSec
self?.state.submitIntroEndSec = endSec
self?.state.submitIntroRequested = true
}
)
hostingView = NSHostingView(rootView: controlsView)
@ -140,11 +146,18 @@ final class NuvioPlayerWindow {
}
func handleMouseMoved() {
if state.controlsLocked {
return
}
showControls()
scheduleHideControls()
}
func handleMouseClicked() {
if state.controlsLocked {
state.lockedOverlayVisible = true
return
}
if state.controlsVisible {
hideControlsNow()
} else {
@ -154,6 +167,14 @@ final class NuvioPlayerWindow {
}
func handleKeyDown(_ event: NSEvent) -> Bool {
if state.controlsLocked {
if event.keyCode == 53 {
close()
return true
}
state.lockedOverlayVisible = true
return true
}
switch event.keyCode {
case 53:
close()
@ -236,6 +257,10 @@ final class NuvioPlayerWindow {
}
private func showControls() {
if state.controlsLocked {
state.lockedOverlayVisible = true
return
}
state.controlsVisible = true
if state.cursorHidden {
NSCursor.unhide()
@ -246,6 +271,9 @@ final class NuvioPlayerWindow {
private func hideControlsNow() {
hideTimer?.invalidate()
hideTimer = nil
if state.controlsLocked {
return
}
state.controlsVisible = false
if !state.cursorHidden {
NSCursor.hide()
@ -255,9 +283,12 @@ final class NuvioPlayerWindow {
private func scheduleHideControls() {
hideTimer?.invalidate()
if state.controlsLocked {
return
}
hideTimer = Timer.scheduledTimer(withTimeInterval: 3.5, repeats: false) { [weak self] _ in
guard let self, self.state.isPlaying else { return }
if self.state.showSubtitlePanel || self.state.showAudioPanel || self.state.showSourcesPanel || self.state.showEpisodesPanel { return }
if self.state.showSubtitlePanel || self.state.showAudioPanel || self.state.showSourcesPanel || self.state.showEpisodesPanel || self.state.showSubmitIntroPanel { return }
self.state.controlsVisible = false
if !self.state.cursorHidden {
NSCursor.hide()

View file

@ -29,6 +29,7 @@ interface PlayerEngineController {
logo: String? = null,
) {}
fun setPlayerFlags(hasVideoId: Boolean, isSeries: Boolean) {}
fun setSubmitIntroEnabled(enabled: Boolean) {}
fun showSkipButton(type: String, endTimeMs: Long) {}
fun hideSkipButton() {}
fun showNextEpisode(
@ -40,6 +41,7 @@ interface PlayerEngineController {
) {}
fun hideNextEpisode() {}
fun setOnNextEpisodeRequestedCallback(callback: () -> Unit) {}
fun setOnSubmitIntroSubmittedCallback(callback: (segmentType: String, startSec: Double, endSec: Double) -> Unit) {}
fun setOnCloseCallback(callback: () -> Unit) {}
fun setOnAddonSubtitlesFetchCallback(callback: () -> Unit) {}
fun pushAddonSubtitles(subtitles: List<AddonSubtitle>, isLoading: Boolean) {}
@ -69,6 +71,7 @@ interface PlayerEngineController {
currentStreamUrl: String?,
) {}
fun showEpisodeStreamsView(season: Int?, episode: Int?, title: String?) {}
fun dismissNativePanels() {}
fun switchSource(url: String, audioUrl: String?, headersJson: String?) {}
}

View file

@ -270,6 +270,9 @@ fun PlayerScreen(
}
val allEpisodes = remember(playerMetaVideos) { playerMetaVideos }
val isSeries = parentMetaType == "series"
val canSubmitIntro = isSeries &&
playerSettingsUiState.introSubmitEnabled &&
playerSettingsUiState.introDbApiKey.isNotBlank()
// Skip intro/outro/recap state
var skipIntervals by remember { mutableStateOf<List<SkipInterval>>(emptyList()) }
@ -1097,6 +1100,11 @@ fun PlayerScreen(
SubtitleRepository.fetchAddonSubtitles(type, videoId)
}
fun resolveSubmitIntroImdbId(): String? =
activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
?: parentMetaId.takeIf { it.startsWith("tt") }
?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
errorMessage = null
playerController = null
@ -1127,6 +1135,39 @@ fun PlayerScreen(
playerController?.applySubtitleStyle(subtitleStyle)
}
LaunchedEffect(
playerController,
title,
activeStreamTitle,
activeProviderName,
activeSeasonNumber,
activeEpisodeNumber,
activeEpisodeTitle,
backdropArtwork,
logo,
activeVideoId,
parentMetaType,
) {
playerController?.setMetadata(
title = title,
streamTitle = activeStreamTitle,
providerName = activeProviderName,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
artwork = backdropArtwork,
logo = logo,
)
playerController?.setPlayerFlags(
hasVideoId = activeVideoId != null,
isSeries = parentMetaType == "series",
)
}
LaunchedEffect(playerController, canSubmitIntro) {
playerController?.setSubmitIntroEnabled(canSubmitIntro)
}
LaunchedEffect(playerController, addonSubtitles, isLoadingAddonSubtitles) {
playerController?.pushAddonSubtitles(addonSubtitles, isLoadingAddonSubtitles)
}
@ -1618,6 +1659,22 @@ fun PlayerScreen(
nextEpisodeAutoPlayJob?.cancel()
playNextEpisode()
}
controller.setOnSubmitIntroSubmittedCallback { segmentType, startSec, endSec ->
val season = activeSeasonNumber ?: return@setOnSubmitIntroSubmittedCallback
val episode = activeEpisodeNumber ?: return@setOnSubmitIntroSubmittedCallback
val imdbId = resolveSubmitIntroImdbId() ?: return@setOnSubmitIntroSubmittedCallback
if (endSec <= startSec) return@setOnSubmitIntroSubmittedCallback
scope.launch {
SkipIntroRepository.submitIntro(
imdbId = imdbId,
season = season,
episode = episode,
startSec = startSec,
endSec = endSec,
segmentType = segmentType,
)
}
}
controller.setOnAddonSubtitlesFetchCallback {
if (contentType != null && activeVideoId != null) {
SubtitleRepository.fetchAddonSubtitles(contentType, activeVideoId!!)
@ -1638,20 +1695,6 @@ fun PlayerScreen(
val stream = allStreams.firstOrNull { it.directPlaybackUrl == url }
?: return@setOnSourceStreamSelectedCallback
switchToSource(stream)
val headers = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
val headersJson = headers.takeIf { it.isNotEmpty() }?.entries
?.joinToString(",", "{", "}") { (k, v) -> "\"${k}\":\"${v}\"" }
controller.switchSource(url, null, headersJson)
controller.setMetadata(
title = title,
streamTitle = activeStreamTitle,
providerName = activeProviderName,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
artwork = backdropArtwork,
logo = logo,
)
}
controller.setOnSourceFilterChangedCallback { addonId ->
PlayerStreamsRepository.selectSourceFilter(addonId)
@ -1678,6 +1721,17 @@ fun PlayerScreen(
controller.setOnEpisodeSelectedCallback { episodeId ->
val episode = playerMetaVideos.firstOrNull { it.id == episodeId }
?: return@setOnEpisodeSelectedCallback
val downloadedEpisode = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
episodeNumber = episode.episode,
videoId = episode.id,
)
if (downloadedEpisode != null) {
switchToDownloadedEpisode(downloadedEpisode, episode)
controller.dismissNativePanels()
return@setOnEpisodeSelectedCallback
}
episodeStreamsPanelState = episodeStreamsPanelState.copy(
showStreams = true,
selectedEpisode = episode,
@ -1698,20 +1752,6 @@ fun PlayerScreen(
val episode = playerMetaVideos.firstOrNull { it.id == episodeStreamsPanelState.selectedEpisode?.id }
?: return@setOnEpisodeStreamSelectedCallback
switchToEpisodeStream(stream, episode)
val headers = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
val headersJson = headers.takeIf { it.isNotEmpty() }?.entries
?.joinToString(",", "{", "}") { (k, v) -> "\"${k}\":\"${v}\"" }
controller.switchSource(url, null, headersJson)
controller.setMetadata(
title = title,
streamTitle = activeStreamTitle,
providerName = activeProviderName,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
artwork = backdropArtwork,
logo = logo,
)
}
controller.setOnEpisodeFilterChangedCallback { addonId ->
PlayerStreamsRepository.selectEpisodeStreamsFilter(addonId)
@ -1821,7 +1861,7 @@ fun PlayerScreen(
},
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
onSubmitIntroClick = if (canSubmitIntro) { { showSubmitIntroModal = true } } else null,
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
onScrubFinished = { positionMs ->
scrubbingPositionMs = null
@ -2075,9 +2115,7 @@ fun PlayerScreen(
val season = activeSeasonNumber
val episode = activeEpisodeNumber
val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
?: parentMetaId.takeIf { it.startsWith("tt") }
?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
val imdbId = resolveSubmitIntroImdbId()
if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) {
com.nuvio.app.features.player.skip.SubmitIntroDialog(

View file

@ -49,6 +49,9 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
var onEpisodeReloadCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
var onEpisodeBackCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
var onNextEpisodeRequestedCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
var onSubmitIntroSubmittedCallback by remember {
mutableStateOf<((String, Double, Double) -> Unit)?>(null)
}
DisposableEffect(playerPtr) {
bridge.nuvio_player_show(playerPtr)
@ -71,6 +74,14 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
}
}
LaunchedEffect(playWhenReady) {
if (playWhenReady) {
bridge.nuvio_player_play(playerPtr)
} else {
bridge.nuvio_player_pause(playerPtr)
}
}
LaunchedEffect(resizeMode) {
val mode = when (resizeMode) {
PlayerResizeMode.Fit -> 0
@ -116,6 +127,11 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
label = bridge.nuvio_player_get_subtitle_track_label(playerPtr, index) ?: "",
language = bridge.nuvio_player_get_subtitle_track_lang(playerPtr, index),
isSelected = bridge.nuvio_player_is_subtitle_track_selected(playerPtr, index),
isForced = inferForcedSubtitleTrack(
label = bridge.nuvio_player_get_subtitle_track_label(playerPtr, index),
language = bridge.nuvio_player_get_subtitle_track_lang(playerPtr, index),
trackId = bridge.nuvio_player_get_subtitle_track_id(playerPtr, index).toString(),
),
)
}
}
@ -199,6 +215,10 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
bridge.nuvio_player_set_is_series(playerPtr, isSeries)
}
override fun setSubmitIntroEnabled(enabled: Boolean) {
bridge.nuvio_player_set_submit_intro_enabled(playerPtr, enabled)
}
override fun showSkipButton(type: String, endTimeMs: Long) {
bridge.nuvio_player_show_skip_button(playerPtr, type, endTimeMs)
}
@ -225,6 +245,10 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
onNextEpisodeRequestedCallback = callback
}
override fun setOnSubmitIntroSubmittedCallback(callback: (String, Double, Double) -> Unit) {
onSubmitIntroSubmittedCallback = callback
}
override fun setOnCloseCallback(callback: () -> Unit) {
onCloseCallback = callback
}
@ -378,13 +402,17 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
bridge.nuvio_player_show_episode_streams(playerPtr, season ?: 0, episode ?: 0, title)
}
override fun dismissNativePanels() {
bridge.nuvio_player_dismiss_panels(playerPtr)
}
override fun switchSource(url: String, audioUrl: String?, headersJson: String?) {
bridge.nuvio_player_load_file(playerPtr, url, audioUrl, headersJson)
}
}
}
LaunchedEffect(controller) {
LaunchedEffect(controller, sourceUrl) {
onControllerReady(controller)
}
@ -424,6 +452,13 @@ internal object MacOSMpvPlayerBackend : DesktopPlaybackBackend {
if (bridge.nuvio_player_pop_next_episode_pressed(playerPtr)) {
onNextEpisodeRequestedCallback?.invoke()
}
if (bridge.nuvio_player_pop_submit_intro_requested(playerPtr)) {
onSubmitIntroSubmittedCallback?.invoke(
bridge.nuvio_player_get_submit_intro_segment_type(playerPtr) ?: "intro",
bridge.nuvio_player_get_submit_intro_start_sec(playerPtr),
bridge.nuvio_player_get_submit_intro_end_sec(playerPtr),
)
}
if (bridge.nuvio_player_pop_sources_open_requested(playerPtr)) {
onSourcesRequestedCallback?.invoke()
}

View file

@ -54,6 +54,7 @@ internal interface MacOSMPVBridgeLib : Library {
fun nuvio_player_set_has_video_id(player: Pointer, value: Boolean)
fun nuvio_player_set_is_series(player: Pointer, value: Boolean)
fun nuvio_player_set_submit_intro_enabled(player: Pointer, enabled: Boolean)
fun nuvio_player_load_file(
player: Pointer,
@ -120,6 +121,10 @@ internal interface MacOSMPVBridgeLib : Library {
fun nuvio_player_is_closed(player: Pointer): Boolean
fun nuvio_player_pop_next_episode_pressed(player: Pointer): Boolean
fun nuvio_player_pop_submit_intro_requested(player: Pointer): Boolean
fun nuvio_player_get_submit_intro_segment_type(player: Pointer): String?
fun nuvio_player_get_submit_intro_start_sec(player: Pointer): Double
fun nuvio_player_get_submit_intro_end_sec(player: Pointer): Double
fun nuvio_player_is_addon_subtitles_fetch_requested(player: Pointer): Boolean
fun nuvio_player_set_addon_subtitles_loading(player: Pointer, loading: Boolean)
fun nuvio_player_clear_addon_subtitles(player: Pointer)
@ -159,4 +164,5 @@ internal interface MacOSMPVBridgeLib : Library {
fun nuvio_player_add_episode_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean)
fun nuvio_player_set_episode_selected_filter(player: Pointer, addonId: String?)
fun nuvio_player_show_episode_streams(player: Pointer, season: Int, episode: Int, title: String?)
fun nuvio_player_dismiss_panels(player: Pointer)
}