diff --git a/kplayer/Images.xcassets/AppIcon.appiconset/Contents.json b/kplayer/Images.xcassets/AppIcon.appiconset/Contents.json index 9221b9b..dde6b93 100644 --- a/kplayer/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/kplayer/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,95 +1,23 @@ { "images" : [ - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } + { "filename" : "icon_20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, + { "filename" : "icon_20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, + { "filename" : "icon_29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, + { "filename" : "icon_29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, + { "filename" : "icon_40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, + { "filename" : "icon_40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, + { "filename" : "icon_60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, + { "filename" : "icon_60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, + { "filename" : "icon_ipad_20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, + { "filename" : "icon_ipad_20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, + { "filename" : "icon_ipad_29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, + { "filename" : "icon_ipad_29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, + { "filename" : "icon_ipad_40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, + { "filename" : "icon_ipad_40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, + { "filename" : "icon_ipad_76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, + { "filename" : "icon_ipad_76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, + { "filename" : "icon_ipad_83@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, + { "filename" : "icon_1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", diff --git a/kplayer/core/KSettings.swift b/kplayer/core/KSettings.swift index c795166..93702c4 100644 --- a/kplayer/core/KSettings.swift +++ b/kplayer/core/KSettings.swift @@ -30,6 +30,12 @@ class KSettings: ObservableObject { @Published var automaticallyWaitsToMinimizeStalling = true + @Published + var muteAudio = false + + @Published + var docked = true + @Published var embeddedVideoUrl : URL? @@ -41,10 +47,12 @@ class KSettings: ObservableObject { jump = model.jump slow = model.slow confirm = model.confirm + muteAudio = model.muteAudio + docked = model.docked } func toModel() -> KSettingsModel { - KSettingsModel(scale: scale, autoloop: autoloop, zoomed: zoomed, jump: jump, slow: slow, confirm: confirm) + KSettingsModel(scale: scale, autoloop: autoloop, zoomed: zoomed, jump: jump, slow: slow, confirm: confirm, muteAudio: muteAudio, docked: docked) } func toJSON() -> String { diff --git a/kplayer/core/KSettingsModel.swift b/kplayer/core/KSettingsModel.swift index 90155ac..050a33a 100644 --- a/kplayer/core/KSettingsModel.swift +++ b/kplayer/core/KSettingsModel.swift @@ -12,4 +12,6 @@ struct KSettingsModel: Codable { var jump = false var slow = false var confirm = false + var muteAudio = false + var docked = true } diff --git a/kplayer/master/KSettingsView.swift b/kplayer/master/KSettingsView.swift index 74965db..b589b1d 100644 --- a/kplayer/master/KSettingsView.swift +++ b/kplayer/master/KSettingsView.swift @@ -46,6 +46,12 @@ struct KSettingsView: View { Toggle(isOn: $kSettings.automaticallyWaitsToMinimizeStalling, label: { Text("Stalling") }) + Toggle(isOn: $kSettings.muteAudio, label: { + Text("Mute Audio") + }) + Toggle(isOn: $kSettings.docked, label: { + Text("Dock Panel") + }) }.padding(30) } } diff --git a/kplayer/video/PlayerInteractionMode.swift b/kplayer/video/PlayerInteractionMode.swift new file mode 100644 index 0000000..7fd360d --- /dev/null +++ b/kplayer/video/PlayerInteractionMode.swift @@ -0,0 +1,97 @@ +// +// Created by Marco Schmickler +// Copyright (c) 2024 Marco Schmickler. All rights reserved. +// + +import CoreGraphics + +// MARK: - DragAction + +/// What a drag gesture should do, resolved independently of AVKit/SwiftUI. +enum DragAction { + case pan // caller updates model.dragOffset + case scrub(to: Double) // seekTimeSmoothly — frame-accurate scrub + case step(to: Double) // seekTime — discrete jump + case jumpSnapshot(Int) // +1 next / -1 previous + case none +} + +// MARK: - DragInput + +/// Pure inputs for interpreting a drag. No AVKit or SwiftUI types. +struct DragInput { + let translation: CGSize + let start: CGPoint + let currentTime: Double? // nil when player has no item + let anchorTime: Double // smoothTime baseline for scrubbing + let seeking: Bool +} + +// MARK: - InteractionMode + +/// One set of drag rules. Each mode is a value type with named constants, +/// so tuning a threshold is a one-line change in one place. +protocol InteractionMode { + func resolve(_ input: DragInput) -> DragAction +} + +// MARK: - ScrubMode (paused) + +/// When paused, dragging scrubs the timeline. Panning is only unlocked when +/// the video is zoomed or in crop-preview ("small") mode. +struct ScrubMode: InteractionMode { + /// Top zone height in points — finer scrub sensitivity above this line. + /// 100 when zoomed/fullscreen, 170 otherwise. + var fineZoneHeight: CGFloat = 170 + /// Whether the lower zone should pan instead of coarse-scrub. + var allowsPan: Bool = false + /// Drag-to-seconds ratio for the fine zone (upper). + var fineDivisor: Double = 100 + /// Drag-to-seconds ratio for the coarse zone (lower). + var coarseDivisor: Double = 30 + + func resolve(_ input: DragInput) -> DragAction { + // Match old behaviour: if the player has no time, fall through to pan. + guard input.currentTime != nil else { return .pan } + let d = Double(input.translation.width + input.translation.height) + if input.start.y < fineZoneHeight { + return .scrub(to: input.anchorTime + d / fineDivisor) + } + if allowsPan { return .pan } + return .scrub(to: input.anchorTime + d / coarseDivisor) + } +} + +// MARK: - PlayMode (playing) + +/// When playing, a vertical drag can jump to the prev/next snapshot, +/// a horizontal drag step-seeks, and the lower portion pans when zoomed. +struct PlayMode: InteractionMode { + /// Whether the lower area should pan instead of seeking. + var allowsPan: Bool = false + /// Y-coordinate above which pan is NOT allowed (upper area → seek only). + var panZoneTop: CGFloat = 300 + /// Whether vertical drags should jump to adjacent snapshots. + var jumpEnabled: Bool = false + /// Minimum vertical drag distance (pts) to trigger a snapshot jump. + var jumpThreshold: CGFloat = 100 + /// Minimum horizontal drag distance (pts) to trigger a step seek. + var stepThreshold: Double = 40 + /// Seconds to seek forward on a rightward drag. + var stepForward: Double = 30 + /// Seconds to seek backward on a leftward drag. + var stepBackward: Double = 30 + + func resolve(_ input: DragInput) -> DragAction { + let d = input.translation + if jumpEnabled { + if d.height > jumpThreshold { return .jumpSnapshot(+1) } + if d.height < -jumpThreshold { return .jumpSnapshot(-1) } + } + guard let t = input.currentTime, !input.seeking else { return .none } + if allowsPan && input.start.y > panZoneTop { return .pan } + if d.width > CGFloat(stepThreshold) { return .step(to: t + stepForward) } + if d.width < -CGFloat(stepThreshold) { return .step(to: t - stepBackward) } + return .none + } +} diff --git a/kplayer/video/SVideoPlayer.swift b/kplayer/video/SVideoPlayer.swift index 4e56523..5406566 100644 --- a/kplayer/video/SVideoPlayer.swift +++ b/kplayer/video/SVideoPlayer.swift @@ -43,9 +43,11 @@ struct SVideoPlayer: View, EditItemDelegate { @State var xoffs = 0.0 @State var rotazero = -1000.0 - @State var loop5 = false + @State var loop5 = 0 // 0 = off, 1 = 5 s, 2 = 10 s @State var loop5start = 0.0 + @State var fullscreen = false + @State var showHelp = false @State var cutFlag = false @State var lores = false @State var second = false @@ -93,6 +95,7 @@ struct SVideoPlayer: View, EditItemDelegate { if !model.baseItem.compilation { doSnapshot() } + if fullscreen { fullscreen = false } print("triple tap!") } else if gestureValue.first != nil { doubleTapZoom() @@ -102,6 +105,7 @@ struct SVideoPlayer: View, EditItemDelegate { var body: some View { VStack { + if !fullscreen { HStack { VStack { HStack { @@ -183,7 +187,10 @@ struct SVideoPlayer: View, EditItemDelegate { }) .foregroundColor(model.favorite ? Color.yellow : Color.blue).buttonStyle(BorderlessButtonStyle()) - KToggleButton(text: "loop5", binding: $loop5) + Button(action: { loop5 = (loop5 + 1) % 3 }) { + Text(loop5 == 0 ? "loop5" : loop5 == 1 ? "loop5" : "loop10") + }.foregroundColor(loop5 > 0 ? Color.yellow : Color.blue) + .buttonStyle(BorderlessButtonStyle()) KToggleButton(text: "cfg", binding: $showSettings).frame(height: 30) Button(action: { confirmationShown = true }, label: { Text(savetext) @@ -205,6 +212,9 @@ struct SVideoPlayer: View, EditItemDelegate { } KToggleButton(text: "embd", binding: $embedded).frame(height: 30) + Button(action: { showHelp = true }, label: { + Text("?") + }).buttonStyle(BorderlessButtonStyle()) } } @@ -265,6 +275,7 @@ struct SVideoPlayer: View, EditItemDelegate { } } } + } // end if !fullscreen // .frame(height: 50) GeometryReader { geometry in @@ -487,7 +498,7 @@ struct SVideoPlayer: View, EditItemDelegate { let controlsView = VideoPlayerControlsView(model: model, player: player) - if !second { + if !second && !fullscreen { controlsView } else { controlsView.hidden() @@ -501,16 +512,21 @@ struct SVideoPlayer: View, EditItemDelegate { } } + .statusBarHidden(fullscreen) .sheet(isPresented: $showSettings, onDismiss: { showSettings = false }) { KSettingsView(kSettings: LocalManager.sharedInstance.settings, completionHandler: { showSettings = false }) } + .sheet(isPresented: $showHelp, onDismiss: { showHelp = false }) { + SVideoHelpView(completionHandler: { showHelp = false }) + } .onAppear() { model.observed = player model.observer = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.02, preferredTimescale: 600), queue: nil, using: timeObserver()) player.automaticallyWaitsToMinimizeStalling = LocalManager.sharedInstance.settings.automaticallyWaitsToMinimizeStalling + player.isMuted = LocalManager.sharedInstance.settings.muteAudio let item = model.currentPlayerItem() @@ -548,7 +564,7 @@ struct SVideoPlayer: View, EditItemDelegate { model.bitRate = Int(lastEvent.indicatedBitrate / 1024 / 1024) }.onChange(of: loop5) { new in - if new { + if new == 1 { if model.paused { model.paused = false player.play() @@ -570,21 +586,7 @@ struct SVideoPlayer: View, EditItemDelegate { } private func doubleTapZoom() { - second = !second - if second { - model.scale = lastScale - } else { - lastScale = model.scale - - if (lores) { - model.scale = secondScale - model.dragOffset = secondOffset - } else { - model.scale = 1 - model.dragOffset = CGSize.zero - } - - } + fullscreen.toggle() } private func timeObserver() -> (CMTime) -> () { @@ -595,8 +597,9 @@ struct SVideoPlayer: View, EditItemDelegate { if timeSlomoCounter >= 1 { timeSlomoCounter -= 1 } - if loop5 { - if time.seconds > loop5start + 5.0 { + if loop5 > 0 { + let loopDuration = loop5 == 2 ? 10.0 : 5.0 + if time.seconds > loop5start + loopDuration { seekTime(loop5start) } } @@ -701,90 +704,63 @@ struct SVideoPlayer: View, EditItemDelegate { scrollTarget = model.allItems.count } - private func move(_ dragged: CGSize, start: CGPoint) -> Bool { + /// Maps the current combination of mode flags to an InteractionMode value + /// with the appropriate constants. This is the single place to change + /// which flags affect drag behaviour and what the thresholds are. + private func currentMode() -> InteractionMode { + let zoomed = second || fullscreen + let pan = zoomed || small if model.paused { - if let time = getCurrentTime() { - if smoothTime < 0 { - smoothTime = time - } - - if (start.y < 170) { - let delta = (dragged.width + dragged.height) / 100.0 - seekTimeSmoothly(smoothTime + delta) - - return false - } else { - if second || small { - return true - } else { - let delta = (dragged.width + dragged.height) / 30.0 - seekTimeSmoothly(smoothTime + delta) - - return false - } - } - } else { - return true - } + return ScrubMode( + fineZoneHeight: zoomed ? 100 : 170, + allowsPan: pan) } + let fine = model.slow || second + let (threshold, forward, backward): (Double, Double, Double) = { + if fullscreen { return (20, 10, 10) } + else if fine { return (20, 5, 8) } + else { return (40, 30, 30) } + }() + return PlayMode( + allowsPan: pan, + jumpEnabled: model.jump && !fullscreen, + stepThreshold: threshold, + stepForward: forward, + stepBackward: backward) + } - if dragged.height > 100 && model.jump { - if timeCounter == 0 { - if let i = model.allItems.index(where: { m in m === model.currentSnapshot }) { - if i + 1 < model.allItems.count { - gotoSnapshot(model.allItems[i + 1]) - } else { - gotoSnapshot(model.allItems[0]) - } - timeCounter = 50 - print("jump") - } - } - return false - } + /// Wrapping prev/next snapshot navigation (replaces two identical blocks). + private func jumpSnapshot(by dir: Int) { + guard let i = model.allItems.index(where: { $0 === model.currentSnapshot }) + else { return } + let n = model.allItems.count + gotoSnapshot(model.allItems[(i + dir + n) % n]) + print("jump") + } - if dragged.height < -100 && model.jump { - if timeCounter == 0 { - if let i = model.allItems.index(where: { m in m === model.currentSnapshot }) { - if i - 1 >= 0 { - gotoSnapshot(model.allItems[i - 1]) - } else { - gotoSnapshot(model.allItems[model.allItems.count - 1]) - } - timeCounter = 50 - print("jump") - } - } + private func move(_ dragged: CGSize, start: CGPoint) -> Bool { + if model.paused, smoothTime < 0, let t = getCurrentTime() { smoothTime = t } + let input = DragInput( + translation: dragged, + start: start, + currentTime: getCurrentTime(), + anchorTime: smoothTime, + seeking: model.seeking) + switch currentMode().resolve(input) { + case .pan: + return true + case .scrub(let target): + seekTimeSmoothly(target) + return false + case .step(let target): + seekTime(target) + return false + case .jumpSnapshot(let dir): + if timeCounter == 0 { jumpSnapshot(by: dir); timeCounter = 50 } + return false + case .none: return false } - - if let time = getCurrentTime() { - let dragWidth = 20.0 - if !model.seeking { - if (second || small) && start.y > 300 { - return true - } - - if (model.slow || second) { - if dragged.width > dragWidth { - seekTime(time + 5.0) - } else if dragged.width < -dragWidth { - seekTime(time - 8.0) - } - } else { - if dragged.width > dragWidth * 2 { - seekTime(time + 30.0) - } else if dragged.width < -dragWidth * 2 { - seekTime(time - 30.0) - } - } - } - - // model.seeking = sk - } - - - return false } private func relative() -> String {