From 849c265a3f0580ee8c157fe953fd6c5ce50cb989 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 12 May 2026 18:53:20 +0530 Subject: [PATCH] feat: add submit intro functionality and UI support --- MPVKit/Sources/DesktopMPVBridge/Bridge.swift | 45 ++++ .../DesktopMPVBridge/NuvioControlsView.swift | 254 ++++++++++++++++-- .../DesktopMPVBridge/NuvioPlayerState.swift | 14 +- .../DesktopMPVBridge/NuvioPlayerWindow.swift | 33 ++- .../nuvio/app/features/player/PlayerEngine.kt | 3 + .../nuvio/app/features/player/PlayerScreen.kt | 102 ++++--- .../player/MacOSMpvPlayerBackend.desktop.kt | 37 ++- .../player/MacOSNativePlayerBridge.desktop.kt | 6 + 8 files changed, 440 insertions(+), 54 deletions(-) diff --git a/MPVKit/Sources/DesktopMPVBridge/Bridge.swift b/MPVKit/Sources/DesktopMPVBridge/Bridge.swift index 767579e3..4aafc209 100644 --- a/MPVKit/Sources/DesktopMPVBridge/Bridge.swift +++ b/MPVKit/Sources/DesktopMPVBridge/Bridge.swift @@ -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? { + 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 + } +} diff --git a/MPVKit/Sources/DesktopMPVBridge/NuvioControlsView.swift b/MPVKit/Sources/DesktopMPVBridge/NuvioControlsView.swift index 01db1852..f9f0654b 100644 --- a/MPVKit/Sources/DesktopMPVBridge/NuvioControlsView.swift +++ b/MPVKit/Sources/DesktopMPVBridge/NuvioControlsView.swift @@ -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) -> 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) } } - - diff --git a/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerState.swift b/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerState.swift index 105e6833..be3efc8e 100644 --- a/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerState.swift +++ b/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerState.swift @@ -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 { diff --git a/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerWindow.swift b/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerWindow.swift index 8c81d9ec..fc175455 100644 --- a/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerWindow.swift +++ b/MPVKit/Sources/DesktopMPVBridge/NuvioPlayerWindow.swift @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 5f940815..b1c44549 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -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, 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?) {} } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index c7e3e900..f0dd559e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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>(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( diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSMpvPlayerBackend.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSMpvPlayerBackend.desktop.kt index 9bc8c38a..94d349fb 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSMpvPlayerBackend.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSMpvPlayerBackend.desktop.kt @@ -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() } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSNativePlayerBridge.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSNativePlayerBridge.desktop.kt index 0e60e6ba..95320a29 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSNativePlayerBridge.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/MacOSNativePlayerBridge.desktop.kt @@ -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) }