You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1069 lines
45 KiB
1069 lines
45 KiB
//
|
|
// Created by Marco Schmickler on 15.11.21.
|
|
// Copyright (c) 2021 Marco Schmickler. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import AVKit
|
|
import CoreMotion
|
|
|
|
struct SVideoPlayer: View, EditItemDelegate {
|
|
// url: URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")!
|
|
var player = AVQueuePlayer(items: [AVPlayerItem]())
|
|
var playerLooper: AVPlayerLooper
|
|
var completionHandler: ((Bool) -> Void)?
|
|
let motionManager = CMMotionManager()
|
|
|
|
@ObservedObject
|
|
var model: SVideoModel
|
|
|
|
@State private var lastScaleValue: CGFloat = 1.0
|
|
@State private var lastDragOffset: CGSize = CGSize.zero
|
|
|
|
@State var savetext = "save"
|
|
@State var confirmationShown = false
|
|
@State var dirtyShown = false
|
|
@State var blackShown = false
|
|
@State var showSettings = false
|
|
@State var truncateShown = false
|
|
@State var seekSmoothly = false
|
|
@State var upsidedown = false
|
|
@State var more = false
|
|
@State var frames = false
|
|
@State var small = false
|
|
@State var embedded = false
|
|
@State var embDown = false
|
|
@State var tilt = false
|
|
@State var smoothTime = -1.0
|
|
@State var smoothSeekTime = -1.0
|
|
@State var timeCounter = 0
|
|
@State var timeSlomoCounter = 0
|
|
@State var lastScale = 1.25
|
|
@State var xoffs = 0.0
|
|
@State var rotazero = -1000.0
|
|
|
|
@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
|
|
@State var tap3 = false
|
|
@State var secondScale = 0.75
|
|
@State var secondOffset = CGSize(width: -70, height: 50)
|
|
|
|
@State var orientation = UIDevice.current.orientation
|
|
@State private var scrollTarget: Int?
|
|
|
|
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
|
|
.makeConnectable()
|
|
.autoconnect()
|
|
|
|
let bitrateChanged = NotificationCenter.default
|
|
.publisher(for: NSNotification.Name.AVPlayerItemNewAccessLogEntry)
|
|
|
|
let steps: [Float] = [0.25, 0.5, 1.0, 2.0]
|
|
|
|
init(completionHandler: ((Bool) -> ())?, model: SVideoModel) {
|
|
self.completionHandler = completionHandler
|
|
self.model = model
|
|
self.playerLooper = AVPlayerLooper(player: player, templateItem: model.currentPlayerItem())
|
|
}
|
|
|
|
func isEnd() -> Bool {
|
|
model.paused && model.currentSnapshot.time > 0
|
|
}
|
|
|
|
func cleanup() {
|
|
if model.observer != nil && model.observed != nil && model.observed! === player {
|
|
do {
|
|
player.removeTimeObserver(model.observer)
|
|
model.observer = nil
|
|
} catch {
|
|
print("exception")
|
|
}
|
|
}
|
|
}
|
|
|
|
var multiTapGesture: some Gesture {
|
|
SimultaneousGesture(TapGesture(count: 2), TapGesture(count: 3))
|
|
.onEnded { gestureValue in
|
|
if gestureValue.second != nil {
|
|
if !model.baseItem.compilation {
|
|
doSnapshot()
|
|
}
|
|
if fullscreen { fullscreen = false }
|
|
print("triple tap!")
|
|
} else if gestureValue.first != nil {
|
|
doubleTapZoom()
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
if !fullscreen {
|
|
HStack {
|
|
VStack {
|
|
HStack {
|
|
Text(model.currentSnapshot.name).foregroundColor(Color.blue).fontWeight(Font.Weight.light)
|
|
}
|
|
LazyVGrid(columns: [
|
|
GridItem(.adaptive(minimum: 45))
|
|
], spacing: 5) {
|
|
Button(action: {
|
|
if model.dirty {
|
|
if LocalManager.sharedInstance.settings.confirm {
|
|
dirtyShown = true
|
|
} else {
|
|
cleanup()
|
|
closePlayer(withSave: true)
|
|
cleanup()
|
|
}
|
|
} else {
|
|
let withSave = model.edit
|
|
cleanup()
|
|
closePlayer(withSave: withSave)
|
|
}
|
|
}, label: {
|
|
Text(LocalManager.sharedInstance.settings.confirm ? "cancel" : "next")
|
|
})
|
|
.buttonStyle(BorderlessButtonStyle()).confirmationDialog("Save?", isPresented: $dirtyShown) {
|
|
Button("save") {
|
|
closePlayer(withSave: true)
|
|
}
|
|
Button("cancel", role: .cancel) {
|
|
closePlayer(withSave: false)
|
|
}
|
|
}
|
|
/* if !model.baseItem.compilation {
|
|
Button(action: {
|
|
if isEnd() {
|
|
setEnd()
|
|
} else {
|
|
blackShown = true
|
|
}
|
|
}) {
|
|
Text(isEnd() ? "end" : "black")
|
|
}
|
|
.foregroundColor(NetworkManager.sharedInstance.isBlack(model.baseItem) ? Color.yellow : Color.blue)
|
|
.frame(height: 30).buttonStyle(BorderlessButtonStyle()).confirmationDialog("Delete?", isPresented: $blackShown) {
|
|
Button("delete") {
|
|
black();
|
|
closePlayer(withSave: false)
|
|
blackShown = false
|
|
}
|
|
Button("cancel", role: .cancel) {
|
|
blackShown = false
|
|
}
|
|
}
|
|
} */
|
|
KToggleButton(text: "\(relative())", binding: $more)
|
|
|
|
Button(action: {
|
|
let tag = DatabaseManager.sharedInstance.currentTag
|
|
if tag != "" {
|
|
let item = model.currentSnapshot
|
|
if item.tags.contains(tag) {
|
|
item.tags.removeAll(where: { toRemove in toRemove == tag })
|
|
} else {
|
|
item.tags.append(tag)
|
|
}
|
|
model.dirty = true
|
|
}
|
|
}, label: {
|
|
Text(DatabaseManager.sharedInstance.currentTag)
|
|
})
|
|
.foregroundColor(model.currentSnapshot.tags.contains(DatabaseManager.sharedInstance.currentTag) ? Color.yellow : Color.blue)
|
|
|
|
Button(action: {
|
|
model.favorite.toggle()
|
|
model.dirty = true
|
|
}, label: {
|
|
Image(systemName: "heart.fill")
|
|
})
|
|
.foregroundColor(model.favorite ? Color.yellow : Color.blue).buttonStyle(BorderlessButtonStyle())
|
|
|
|
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)
|
|
})
|
|
.buttonStyle(BorderlessButtonStyle())
|
|
.confirmationDialog("Save to folder", isPresented: $confirmationShown) {
|
|
Button("1") {
|
|
save(currentSnapshot: model.currentSnapshot, name: "1")
|
|
}
|
|
Button("2") {
|
|
save(currentSnapshot: model.currentSnapshot, name: "2")
|
|
}
|
|
Button("3") {
|
|
save(currentSnapshot: model.currentSnapshot, name: "3")
|
|
}
|
|
|
|
Button("cancel", role: .cancel) {
|
|
}
|
|
}
|
|
|
|
KToggleButton(text: "embd", binding: $embedded).frame(height: 30)
|
|
Button(action: { showHelp = true }, label: {
|
|
Text("?")
|
|
}).buttonStyle(BorderlessButtonStyle())
|
|
}
|
|
|
|
}
|
|
HStack {
|
|
ScrollViewReader { scrollvalue in
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
ForEach(model.allItems) { item in
|
|
Button(action: {
|
|
gotoSnapshot(item)
|
|
scrollvalue.scrollTo(item.id)
|
|
}) {
|
|
AsyncImage(item: item, placeholder: { Text("Loading ...") },
|
|
image: { Image(uiImage: $0).resizable() }).border(.yellow, width: (item === model.currentSnapshot) ? 1 : 0)
|
|
.overlay(Image(systemName: "repeat.circle").offset(x: 20, y: -20).opacity((item.length > 0.0) ? 1 : 0))
|
|
}
|
|
}
|
|
}
|
|
}.frame(height: 50)
|
|
// .onChange(of: scrollTarget { target in
|
|
// if let tg = target {
|
|
// scrollTarget = nil
|
|
// withAnimation {
|
|
// scrollvalue.scrollTo(tg)
|
|
// }
|
|
// }
|
|
// })
|
|
}
|
|
|
|
Spacer()
|
|
VStack {
|
|
Text("""
|
|
(\(model.codec) \(model.height), \(model.nominalFrameRate), \(model.bitRate)m)\n\(model.scale, specifier: "%.2f")x (\(model.dragOffset.width, specifier: "%.0f"),\(model.dragOffset.height, specifier: "%.0f"))
|
|
""").foregroundColor(Color.blue).fontWeight(Font.Weight.light)
|
|
LazyVGrid(columns: [
|
|
GridItem(.adaptive(minimum: 45))
|
|
], spacing: 5) {
|
|
Button(action: { model.edit.toggle() }, label: {
|
|
Text("edit")
|
|
})
|
|
.buttonStyle(BorderlessButtonStyle());
|
|
|
|
if !model.baseItem.compilation {
|
|
if model.currentSnapshot != nil {
|
|
Button(action: addFrame, label: {
|
|
Text("frame")
|
|
})
|
|
.buttonStyle(BorderlessButtonStyle())
|
|
}
|
|
|
|
Button(action: doSnapshot, label: {
|
|
Text("snap")
|
|
})
|
|
.buttonStyle(BorderlessButtonStyle());
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} // end if !fullscreen
|
|
// .frame(height: 50)
|
|
|
|
GeometryReader { geometry in
|
|
VStack {
|
|
let v = VideoPlayerView(model: model,
|
|
player: player)
|
|
.scaleEffect(small ? 1 : model.scale)
|
|
.rotation3DEffect(.degrees(upsidedown ? 180 : 0), axis: (x: 1, y: 0, z: 0))
|
|
.offset(small ? CGSize.zero : model.dragOffset).modifier(KAnimate(dragOffset: model.dragOffset, spring: false)).offset(x: xoffs, y: 0)
|
|
.gesture(multiTapGesture)
|
|
.simultaneousGesture(
|
|
LongPressGesture()
|
|
.onEnded { _ in
|
|
print("Loooong")
|
|
togglePause()
|
|
}
|
|
)
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { gesture in
|
|
let dragged = gesture.translation
|
|
|
|
if move(dragged, start: gesture.startLocation) {
|
|
let f = 1.5
|
|
model.dragOffset = CGSize(width: f * dragged.width + lastDragOffset.width, height: f * dragged.height + lastDragOffset.height)
|
|
}
|
|
}
|
|
.onEnded { gesture in
|
|
lastDragOffset = model.dragOffset
|
|
smoothTime = -1.0
|
|
}
|
|
)
|
|
.gesture(MagnificationGesture()
|
|
.onChanged { val in
|
|
let delta = val / self.lastScaleValue
|
|
self.lastScaleValue = val
|
|
self.model.scale = self.model.scale * delta
|
|
}
|
|
.onEnded { val in
|
|
// without this the next gesture will be broken
|
|
self.lastScaleValue = 1.0
|
|
if let geo = model.proxy {
|
|
print("geo \(geo.size.height)");
|
|
}
|
|
})
|
|
.contentShape(Rectangle())
|
|
|
|
if model.edit && model.videoDuration != Double.nan && model.videoDuration > 0.0 {
|
|
v.overlay(EditItemView(item: model.currentSnapshot, len: model.videoDuration, delegate: self)
|
|
.frame(width: 420, alignment: .top).offset(x: 0, y: -50), alignment: .topTrailing)
|
|
} else {
|
|
if embedded && !more {
|
|
v.overlay(SEmbeddedVideo(embedded: $embedded, down: $embDown), alignment: embDown ? .bottomLeading : .topLeading)
|
|
} else if small && !more {
|
|
let normal = 385.0
|
|
v.overlay(Rectangle().stroke(Color.black, lineWidth: 2.0).opacity(0.3).frame(width: normal * 16.0 / 9.0, height: normal).offset(model.dragOffset), alignment: .topLeading)
|
|
} else if more {
|
|
v.overlay(VStack {
|
|
|
|
Button(action: {
|
|
tilt.toggle()
|
|
if tilt {
|
|
motionManager.startDeviceMotionUpdates(using: .xMagneticNorthZVertical)
|
|
} else {
|
|
motionManager.stopDeviceMotionUpdates()
|
|
}
|
|
}, label: {
|
|
Text("tilt")
|
|
})
|
|
.frame(height: 30)
|
|
.foregroundColor(tilt ? Color.yellow : Color.blue).buttonStyle(BorderlessButtonStyle())
|
|
|
|
Group {
|
|
KToggleButton(text: "slow", binding: $model.slow).frame(height: 30)
|
|
KToggleButton(text: "zoom", binding: $model.zoomed).frame(height: 30)
|
|
KToggleButton(text: "jump", binding: $model.jump).frame(height: 30)
|
|
KToggleButton(text: "small", binding: $small).frame(height: 30)
|
|
|
|
KToggleButton(text: "loop", binding: $model.loop).frame(height: 30)
|
|
KToggleButton(text: "flip", binding: $upsidedown).frame(height: 30)
|
|
KToggleButton(text: "lores", binding: $lores).frame(height: 30)
|
|
}
|
|
|
|
Button(action: {
|
|
if model.speed == 1.0 && timeSlomoCounter == 0 {
|
|
model.speed = 0.5
|
|
timeSlomoCounter = 200
|
|
} else {
|
|
for s in steps {
|
|
if model.speed < s {
|
|
model.speed = s
|
|
break
|
|
} else {
|
|
if s == 2.0 {
|
|
model.speed = steps[0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
player.rate = model.speed
|
|
|
|
}, label: {
|
|
Text("\(model.speed, specifier: "%.2f")")
|
|
})
|
|
.frame(height: 30)
|
|
.buttonStyle(BorderlessButtonStyle())
|
|
Button(action: {
|
|
togglePause()
|
|
}, label: {
|
|
Text("pause")
|
|
})
|
|
.frame(height: 30)
|
|
.foregroundColor(model.paused ? Color.yellow : Color.blue).buttonStyle(BorderlessButtonStyle())
|
|
|
|
if !model.baseItem.compilation {
|
|
Button(action: { blackShown = true }) {
|
|
Text("black")
|
|
}
|
|
.foregroundColor(NetworkManager.sharedInstance.isBlack(model.baseItem) ? Color.yellow : Color.blue)
|
|
.frame(height: 30).buttonStyle(BorderlessButtonStyle()).confirmationDialog("Delete?", isPresented: $blackShown) {
|
|
Button("delete") {
|
|
black();
|
|
closePlayer(withSave: false)
|
|
blackShown = false
|
|
}
|
|
Button("cancel", role: .cancel) {
|
|
blackShown = false
|
|
}
|
|
}
|
|
Button(action: { truncateShown = true }) {
|
|
Text("trunc")
|
|
}
|
|
.foregroundColor(Color.blue)
|
|
.frame(height: 30).buttonStyle(BorderlessButtonStyle()).confirmationDialog("Truncate?", isPresented: $truncateShown) {
|
|
Button("truncate") {
|
|
truncate();
|
|
closePlayer(withSave: false)
|
|
truncateShown = false
|
|
}
|
|
Button("cancel", role: .cancel) {
|
|
truncateShown = false
|
|
}
|
|
}
|
|
Button(action: { cut() }) {
|
|
Text("cut")
|
|
}
|
|
.foregroundColor(cutFlag ? Color.yellow : Color.blue)
|
|
.frame(height: 30).buttonStyle(BorderlessButtonStyle())
|
|
}
|
|
|
|
Button(action: {}) {
|
|
Text("start")
|
|
}
|
|
.frame(height: 30).buttonStyle(BorderlessButtonStyle())
|
|
.simultaneousGesture(
|
|
LongPressGesture()
|
|
.onEnded { _ in
|
|
print("Loooong")
|
|
}
|
|
)
|
|
.highPriorityGesture(TapGesture()
|
|
.onEnded { _ in
|
|
seekTimeSmoothly(model.currentSnapshot.time)
|
|
})
|
|
|
|
Button(action: {}) {
|
|
Text("end")
|
|
}
|
|
.frame(height: 30).buttonStyle(BorderlessButtonStyle()).foregroundColor(model.currentSnapshot.length > 0 ? Color.yellow : Color.blue)
|
|
.simultaneousGesture(
|
|
LongPressGesture()
|
|
.onEnded { _ in
|
|
setEnd()
|
|
}
|
|
)
|
|
.highPriorityGesture(TapGesture()
|
|
.onEnded { _ in
|
|
seekTimeSmoothly(model.currentSnapshot.time + model.currentSnapshot.length)
|
|
})
|
|
}
|
|
.frame(width: 50, alignment: .top).offset(x: 0, y: 0), alignment: .topLeading)
|
|
} else if model.tagging {
|
|
v.overlay(ScrollView() {
|
|
TagEditor(item: model.currentSnapshot, completionHandler: NetworkManager.sharedInstance.saveItem)
|
|
}
|
|
.frame(width: 170, alignment: .top).offset(x: 0, y: 0),
|
|
alignment: .topLeading)
|
|
} else if frames && model.zoomed {
|
|
v.overlay(VStack {
|
|
ForEach(model.frames) { f in
|
|
Button(action: {
|
|
seekTime(model.currentSnapshot.time + f.time)
|
|
model.dragOffset.width = CGFloat(f.x)
|
|
model.dragOffset.height = CGFloat(f.y)
|
|
model.scale = f.scale
|
|
}, label: {
|
|
Text("\(f.time, specifier: "%.2f")")
|
|
})
|
|
.simultaneousGesture(
|
|
LongPressGesture()
|
|
.onEnded { _ in
|
|
print("Loooong")
|
|
|
|
for i in 0...model.frames.count - 1 {
|
|
if model.frames[i].time == f.time {
|
|
model.frames.remove(at: i)
|
|
break
|
|
}
|
|
}
|
|
updateOptions()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.frame(width: 70, alignment: .top).offset(x: 0, y: 0), alignment: .topTrailing)
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
|
|
let controlsView = VideoPlayerControlsView(model: model,
|
|
player: player)
|
|
if !second && !fullscreen {
|
|
controlsView
|
|
} else {
|
|
controlsView.hidden()
|
|
}
|
|
}
|
|
.onAppear {
|
|
model.proxy = geometry
|
|
print("geo \(geometry.size.height)");
|
|
}
|
|
.clipped()
|
|
}
|
|
|
|
}
|
|
.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()
|
|
|
|
item.preferredForwardBufferDuration = 2.0
|
|
player.removeAllItems()
|
|
self.player.replaceCurrentItem(with: item)
|
|
model.currentURL = model.currentSnapshot.playerURL
|
|
print(model.currentURL!)
|
|
|
|
// player.removeAllItems()
|
|
// player.insert(model.currentPlayerItem(), after: nil)
|
|
// Start the player going, otherwise controls don't appear
|
|
player.play()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
gotoSnapshot(model.currentSnapshot)
|
|
}
|
|
|
|
}
|
|
.onDisappear {
|
|
// When this View isn't being shown anymore stop the player
|
|
player.pause()
|
|
cleanup()
|
|
self.player.replaceCurrentItem(with: nil)
|
|
}
|
|
.onReceive(orientationChanged) { _ in
|
|
self.orientation = UIDevice.current.orientation
|
|
}
|
|
.onReceive(bitrateChanged) { notification in
|
|
guard let playerItem = notification.object as? AVPlayerItem,
|
|
let lastEvent = playerItem.accessLog()?.events.last
|
|
else {
|
|
return
|
|
}
|
|
|
|
model.bitRate = Int(lastEvent.indicatedBitrate / 1024 / 1024)
|
|
}.onChange(of: loop5) { new in
|
|
if new == 1 {
|
|
if model.paused {
|
|
model.paused = false
|
|
player.play()
|
|
player.rate = 0.8
|
|
}
|
|
loop5start = player.currentTime().seconds
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private func togglePause() {
|
|
if model.paused {
|
|
player.play()
|
|
} else {
|
|
player.pause()
|
|
}
|
|
model.paused = !model.paused
|
|
}
|
|
|
|
private func doubleTapZoom() {
|
|
fullscreen.toggle()
|
|
}
|
|
|
|
private func timeObserver() -> (CMTime) -> () {
|
|
{ time in
|
|
if timeCounter >= 1 {
|
|
timeCounter -= 1
|
|
}
|
|
if timeSlomoCounter >= 1 {
|
|
timeSlomoCounter -= 1
|
|
}
|
|
if loop5 > 0 {
|
|
let loopDuration = loop5 == 2 ? 10.0 : 5.0
|
|
if time.seconds > loop5start + loopDuration {
|
|
seekTime(loop5start)
|
|
}
|
|
}
|
|
if model.loop && !model.paused && model.currentSnapshot.length > 0 {
|
|
if time.seconds > model.currentSnapshot.time + model.currentSnapshot.length {
|
|
seekTime(model.currentSnapshot.time)
|
|
}
|
|
}
|
|
if model.zoomed {
|
|
if time.seconds > model.currentSnapshot.time {
|
|
let relativeTime = time.seconds - model.currentSnapshot.time;
|
|
|
|
for f in model.frames {
|
|
// print("\(f.time) \(relativeTime)")
|
|
if f.time > relativeTime && f.time < relativeTime + 1 {
|
|
model.scale = CGFloat(f.scale)
|
|
model.dragOffset.width = CGFloat(f.x)
|
|
model.dragOffset.height = CGFloat(f.y)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if tilt {
|
|
if let data = motionManager.deviceMotion {
|
|
var rotation = atan2(data.gravity.x,
|
|
data.gravity.y) - .pi
|
|
|
|
if rotation < (-1 * .pi) {
|
|
rotation += .pi + .pi
|
|
}
|
|
rotation *= 100.0
|
|
if rotazero == -1000 {
|
|
rotazero = rotation
|
|
}
|
|
|
|
rotation -= rotazero
|
|
|
|
// let roll = accelerometerData.attitude.yaw
|
|
if rotation > 30 {
|
|
xoffs += 3;
|
|
// print(xoffs)
|
|
}
|
|
if rotation < -30 {
|
|
xoffs -= 3;
|
|
// print(xoffs)
|
|
}
|
|
// print(Int(rotation * 100.0) )
|
|
}
|
|
} else {
|
|
xoffs = 0.0
|
|
rotazero = -1000
|
|
}
|
|
}
|
|
}
|
|
|
|
private func closePlayer(withSave: Bool) {
|
|
if model.currentSnapshot.local {
|
|
LocalManager.sharedInstance.settings.embeddedVideoUrl = model.currentURL
|
|
}
|
|
model.baseItem.favorite = model.favorite
|
|
player.pause()
|
|
player.replaceCurrentItem(with: nil)
|
|
model.currentURL = nil
|
|
completionHandler!(withSave)
|
|
}
|
|
|
|
func doSnapshot() {
|
|
if player.currentItem == nil {
|
|
return
|
|
}
|
|
|
|
let currentItem = player.currentItem!
|
|
let asset = currentItem.asset
|
|
|
|
do {
|
|
let imgGenerator = AVAssetImageGenerator(asset: asset)
|
|
imgGenerator.appliesPreferredTrackTransform = true
|
|
let time = currentItem.currentTime()
|
|
let cgImage = try imgGenerator.copyCGImage(at: time, actualTime: nil)
|
|
let thumbnail = UIImage(cgImage: cgImage)
|
|
model.dirty = true
|
|
UIPasteboard.general.image = thumbnail
|
|
|
|
showThumbnail(currentItem: model.baseItem, thumbnail: thumbnail, time: time)
|
|
|
|
} catch let error {
|
|
print("*** Error generating thumbnail: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func showThumbnail(currentItem: MediaItem, thumbnail: UIImage, time: CMTime) {
|
|
let newItem = MediaItem(name: currentItem.name, path: currentItem.path, root: currentItem.root, type: ItemType.SNAPSHOT)
|
|
newItem.image = thumbnail
|
|
newItem.time = time.seconds
|
|
newItem.indexId = Int(newItem.time * 1000)
|
|
newItem.parent = currentItem
|
|
newItem.local = currentItem.local
|
|
newItem.external = currentItem.external
|
|
model.allItems.append(newItem)
|
|
model.currentSnapshot = newItem
|
|
scrollTarget = model.allItems.count
|
|
}
|
|
|
|
/// 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 {
|
|
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)
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private func relative() -> String {
|
|
if let time = getCurrentTime() {
|
|
return Utility.formatSecondsToHMS(time - model.currentSnapshot.time)
|
|
} else {
|
|
return "more"
|
|
}
|
|
}
|
|
|
|
private func getCurrentTime() -> Double? {
|
|
player.currentItem?.currentTime().seconds
|
|
}
|
|
|
|
func seekTime(_ time: Double) {
|
|
print("Seek \(time)")
|
|
|
|
if (time == 30.0) {
|
|
print("catch")
|
|
}
|
|
self.model.seeking = true
|
|
player.pause()
|
|
// player.cancelPendingPrerolls()
|
|
|
|
if let item = player.currentItem {
|
|
item.cancelPendingSeeks()
|
|
}
|
|
|
|
player.seek(to: CMTime(seconds: time, preferredTimescale: CMTimeScale(600))) { _ in
|
|
if !model.paused {
|
|
player.play()
|
|
player.rate = model.speed
|
|
}
|
|
self.model.seeking = false
|
|
}
|
|
|
|
}
|
|
|
|
func seekTimeSmoothly(_ time: Double) {
|
|
print("smooth \(time)")
|
|
if seekSmoothly {
|
|
smoothSeekTime = time
|
|
print("delay \(smoothSeekTime)")
|
|
return
|
|
}
|
|
if smoothSeekTime == time {
|
|
print("finished \(smoothSeekTime)")
|
|
smoothSeekTime = -1.0
|
|
return
|
|
}
|
|
seekSmoothly = true
|
|
|
|
print("seek \(time)")
|
|
player.seek(to: CMTime(seconds: time, preferredTimescale: CMTimeScale(10000)), toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { _ in
|
|
seekSmoothly = false
|
|
|
|
if smoothSeekTime > 0.0 {
|
|
print("next level")
|
|
seekTimeSmoothly(smoothSeekTime)
|
|
}
|
|
}
|
|
}
|
|
|
|
func gotoSnapshot(_ currentSnapshot: MediaItem) {
|
|
model.currentSnapshot = currentSnapshot
|
|
|
|
let lines = currentSnapshot.options.split(whereSeparator: \.isNewline)
|
|
model.frames.removeAll()
|
|
|
|
frames = false
|
|
|
|
for l in lines {
|
|
let values = l.components(separatedBy: CharacterSet.whitespacesAndNewlines) ?? []
|
|
if (values.count == 4) {
|
|
var f = KFrame()
|
|
f.time = Double(values[0]) ?? 0.0
|
|
f.scale = Double(values[1]) ?? 1.0
|
|
f.x = Int(values[2]) ?? 0
|
|
f.y = Int(values[3]) ?? 0
|
|
model.frames.append(f)
|
|
|
|
frames = true
|
|
}
|
|
print(l)
|
|
}
|
|
|
|
if currentSnapshot.playerURL != model.currentURL {
|
|
model.currentURL = currentSnapshot.playerURL
|
|
|
|
player.replaceCurrentItem(with: model.currentPlayerItem())
|
|
playerLooper
|
|
seekTime(currentSnapshot.time)
|
|
} else {
|
|
seekTime(currentSnapshot.time)
|
|
}
|
|
let heightSpace = model.proxy!.size.height * 2
|
|
var height = heightSpace
|
|
if let i = player.currentItem {
|
|
Task.init {
|
|
for t in i.asset.tracks {
|
|
if t.mediaType == .video {
|
|
model.nominalFrameRate = try await Int(t.load(.nominalFrameRate))
|
|
|
|
if let formats = t.formatDescriptions as? [CMFormatDescription] {
|
|
for format in formats {
|
|
printFourCC(CMFormatDescriptionGetMediaSubType(format))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
height = i.presentationSize.height
|
|
}
|
|
model.height = Int(height)
|
|
|
|
lores = (height <= 1080) && (height * 1.5 < heightSpace)
|
|
|
|
var f = height / heightSpace
|
|
print("h \(height) \(heightSpace) \(f)")
|
|
|
|
if model.zoomed && currentSnapshot.scale > 0 {
|
|
model.scale = CGFloat(currentSnapshot.scale)
|
|
model.dragOffset.width = currentSnapshot.offset.x
|
|
model.dragOffset.height = currentSnapshot.offset.y
|
|
// player.transformLayer()
|
|
} else {
|
|
if model.zoomed && f > 0.0 {
|
|
if f < 0.6 {
|
|
f = f * CGFloat(LocalManager.sharedInstance.settings.scale)
|
|
}
|
|
model.scale = f
|
|
model.dragOffset.width = 0
|
|
model.dragOffset.height = 0
|
|
} else {
|
|
if (model.scale != 1) {
|
|
lastScale = model.scale
|
|
}
|
|
|
|
if (lores) {
|
|
model.scale = secondScale
|
|
model.dragOffset = secondOffset
|
|
} else {
|
|
model.scale = 1
|
|
model.dragOffset = CGSize.zero
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func printFourCC(_ fcc: FourCharCode) {
|
|
let bytes: [CChar] = [
|
|
CChar((fcc >> 24) & 0xff),
|
|
CChar((fcc >> 16) & 0xff),
|
|
CChar((fcc >> 8) & 0xff),
|
|
CChar(fcc & 0xff),
|
|
0]
|
|
|
|
let result = String(cString: bytes)
|
|
let characterSet = CharacterSet.whitespaces
|
|
model.codec = result.trimmingCharacters(in: characterSet)
|
|
}
|
|
|
|
func captureZoom() {
|
|
model.currentSnapshot.scale = model.scale
|
|
model.currentSnapshot.offset = CGPoint(x: model.dragOffset.width, y: model.dragOffset.height)
|
|
model.dirty = true
|
|
}
|
|
|
|
func setStart() {
|
|
if let time = getCurrentTime() {
|
|
model.currentSnapshot.time = time
|
|
model.dirty = true
|
|
}
|
|
}
|
|
|
|
func addFrame() {
|
|
if let time = getCurrentTime() {
|
|
var f = KFrame()
|
|
f.time = time - model.currentSnapshot.time
|
|
f.scale = model.scale
|
|
f.x = Int(model.dragOffset.width)
|
|
f.y = Int(model.dragOffset.height)
|
|
|
|
model.frames.append(f)
|
|
updateOptions()
|
|
|
|
frames = true
|
|
}
|
|
}
|
|
|
|
private func updateOptions() {
|
|
var options = ""
|
|
|
|
for f in model.frames {
|
|
options.append("\(String(format: "%.2f", f.time))\t\(String(format: "%.2f", f.scale))\t\(f.x)\t\(f.y)\n")
|
|
}
|
|
|
|
model.currentSnapshot.options = options
|
|
model.dirty = true
|
|
}
|
|
|
|
func setEnd() {
|
|
if let time = getCurrentTime() {
|
|
let snapTime = model.currentSnapshot.time
|
|
if time > snapTime {
|
|
model.currentSnapshot.length = time - snapTime
|
|
}
|
|
model.dirty = true
|
|
}
|
|
}
|
|
|
|
func cancelEdit() {
|
|
model.edit = false
|
|
}
|
|
|
|
func okEdit() {
|
|
DatabaseManager.sharedInstance.saveItemMetaData(model.currentSnapshot)
|
|
model.edit = false
|
|
model.dirty = true
|
|
}
|
|
|
|
func seek(_ v: Double) {
|
|
seekTimeSmoothly(v)
|
|
}
|
|
|
|
func black() {
|
|
NetworkManager.sharedInstance.blackItem(model.baseItem)
|
|
}
|
|
|
|
func cut() {
|
|
NetworkManager.sharedInstance.cutItem(model.currentSnapshot)
|
|
cutFlag = true
|
|
}
|
|
|
|
func truncate() {
|
|
NetworkManager.sharedInstance.truncateItem(model.currentSnapshot)
|
|
}
|
|
|
|
func save(currentSnapshot c: MediaItem, name: String) {
|
|
do {
|
|
try FileHelper.createDir(name: name)
|
|
|
|
var file = FileHelper.getDocumentsDirectory().appendingPathComponent(name).appendingPathComponent("\(c.nameWithoutExtension)_\(Int(c.time))")
|
|
|
|
if file.pathExtension != "mp4" {
|
|
file = file.appendingPathExtension("mp4")
|
|
}
|
|
print(file)
|
|
var dur = c.length
|
|
if (dur < 0) {
|
|
return
|
|
}
|
|
player.pause()
|
|
VideoHelper.export(item: player.currentItem!, clipStart: c.time, clipDuration: dur, file: file, snapshot: c, zoomed: model.zoomed,
|
|
progress: { p in
|
|
let percent = Int(p * 100)
|
|
savetext = "\(percent)"
|
|
}) { url in
|
|
savetext = "saved"
|
|
}
|
|
var s: MediaModel;
|
|
if (c.type == ItemType.SNAPSHOT && c.parent != nil) {
|
|
s = c.parent!.toMediaModel()
|
|
do {
|
|
if c.thumbUrlAbsolute != "" {
|
|
let local = try Data(contentsOf: URL(string: c.thumbUrlAbsolute)!)
|
|
let tfile = FileHelper.getDocumentsDirectory()
|
|
.appendingPathComponent(name)
|
|
.appendingPathComponent("\(c.nameWithoutExtension)_\(Int(c.time))")
|
|
.appendingPathExtension("mp4")
|
|
.appendingPathExtension("jpg")
|
|
try local.write(to: tfile)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else {
|
|
s = c.toMediaModel()
|
|
}
|
|
|
|
let jfile = FileHelper.getDocumentsDirectory()
|
|
.appendingPathComponent(name)
|
|
.appendingPathComponent("\(c.nameWithoutExtension)_\(Int(c.time))")
|
|
.appendingPathExtension("mp4")
|
|
.appendingPathExtension("orig")
|
|
let jsonEncoder = JSONEncoder()
|
|
let jsonData = try! jsonEncoder.encode(s)
|
|
let json = String(data: jsonData, encoding: String.Encoding.utf8)
|
|
print(jfile.absoluteString)
|
|
print(json)
|
|
try json!.write(to: jfile, atomically: true, encoding: .utf8)
|
|
} catch {
|
|
print(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SVideoPlayer_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
SVideoPlayer(completionHandler: { b in }, model: SVideoModel(allItems: [MediaItem](),
|
|
currentSnapshot: MediaItem(name: "extern", path: "", root: "", type: ItemType.FAVROOT),
|
|
baseItem: MediaItem(name: "extern", path: "", root: "", type: ItemType.FAVROOT)
|
|
))
|
|
}
|
|
}
|