mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
feat: add submit intro functionality and UI support
This commit is contained in:
parent
b46706a588
commit
849c265a3f
8 changed files with 440 additions and 54 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue