Swift Media Player
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.
 
 
 

695 lines
28 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 seekSmoothly = false
@State var upsidedown = false
@State var more = 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 orientation = UIDevice.current.orientation
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
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 cleanup() {
if model.observer != nil && model.observed != nil && model.observed! === player {
do {
player.removeTimeObserver(model.observer)
model.observer = nil
}
catch {
print("exception")
}
}
}
var body: some View {
VStack {
HStack {
Group {
Button(action: {
if model.dirty {
dirtyShown = true
} else {
let withSave = model.edit
closePlayer(withSave: withSave)
}
}, label: {
Text("cancel")
})
.buttonStyle(BorderlessButtonStyle()).confirmationDialog("Save?", isPresented: $dirtyShown) {
Button("save") {
closePlayer(withSave: true)
}
Button("cancel", role: .cancel) {
closePlayer(withSave: false)
}
}
KToggleButton(text: "more", binding: $more)
Button(action: {
model.favorite.toggle()
model.dirty = true
}, label: {
Image(systemName: "heart.fill")
})
.foregroundColor(model.favorite ? Color.yellow : Color.blue).buttonStyle(BorderlessButtonStyle())
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) {
}
}
Text(model.currentSnapshot.name).foregroundColor(Color.blue)
Text(" (\(model.height),\(model.nominalFrameRate)").foregroundColor(Color.blue)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(model.allItems) { item in
Button(action: {
gotoSnapshot(item)
}) {
AsyncImage(item: item, placeholder: { Text("Loading ...") },
image: { Image(uiImage: $0).resizable() })
}
}
}
}
Spacer()
Button(action: { model.edit.toggle() }, label: {
Text("edit")
})
.buttonStyle(BorderlessButtonStyle());
if !model.baseItem.compilation {
Button(action: doSnapshot, label: {
Text("snap")
})
.buttonStyle(BorderlessButtonStyle());
}
}
.frame(height: 50)
}
GeometryReader { geometry in
VStack {
let v = VideoPlayerView(model: model,
player: player)
.scaleEffect(model.scale)
.rotation3DEffect(.degrees(upsidedown ? 180 : 0), axis: (x: 1, y: 0, z: 0))
.offset(model.dragOffset).offset(x: xoffs, y: 0)
.onTapGesture(count: 2) {
print("3 tapped!")
if model.scale == 1 {
model.scale = lastScale
} else {
lastScale = model.scale
model.scale = 1
model.dragOffset = CGSize.zero
}
}
.gesture(
DragGesture()
.onChanged { gesture in
let dragged = gesture.translation
if move(dragged, start: gesture.startLocation) {
model.dragOffset = CGSize(width: dragged.width + lastDragOffset.width, height: 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
})
.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: 400, alignment: .top).offset(x: 0, y: -50), alignment: .topTrailing)
} 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())
KToggleButton(text: "zoom", binding: $model.zoomed).frame(height: 30)
KToggleButton(text: "loop", binding: $model.loop).frame(height: 30)
// .fullScreenCover(isPresented: $model.loop) {
// SVideoLoopPlayer(completionHandler: {
// model.loop = false
// }, baseModel: model)
// }
KToggleButton(text: "flip", binding: $upsidedown).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: {
if model.paused {
player.play()
} else {
player.pause()
}
model.paused = !model.paused
}, label: {
Text("pause")
})
.frame(height: 30)
.foregroundColor(model.paused ? Color.yellow : Color.blue).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 {
v
}
}
let controlsView = VideoPlayerControlsView(model: model,
player: player)
if model.scale <= 1.0 {
controlsView
} else {
controlsView.hidden()
}
}
.onAppear {
model.proxy = geometry
print("geo \(geometry.size.height)");
}
.clipped()
}
}
.onAppear() {
model.observed = player
model.observer = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.02, preferredTimescale: 600), queue: nil) { time in
if timeCounter >= 1 {
timeCounter -= 1
}
if timeSlomoCounter >= 1 {
timeSlomoCounter -= 1
}
if model.loop && model.currentSnapshot.length > 0 {
if time.seconds > model.currentSnapshot.time + model.currentSnapshot.length {
seekTime(model.currentSnapshot.time)
}
}
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
}
}
player.automaticallyWaitsToMinimizeStalling = LocalManager.sharedInstance.settings.automaticallyWaitsToMinimizeStalling
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
}
}
private func closePlayer(withSave: Bool) {
model.baseItem.favorite = model.favorite
player.pause()
player.replaceCurrentItem(with: nil)
model.currentURL = nil
completionHandler!(withSave)
}
func doSnapshot() {
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
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
}
private func move(_ dragged: CGSize, start: CGPoint) -> Bool {
if model.paused {
if let time = getCurrentTime() {
if smoothTime < 0 {
smoothTime = time
}
if (start.y < 130) {
let delta = (dragged.width + dragged.height) / 400.0
print(start.y)
seekTimeSmoothly(smoothTime + delta)
return false
} else {
if model.scale != 1.0 {
return true
}
else {
let dragWidth = 20.0
if dragged.width > dragWidth {
seekTime(time + 4.0)
} else if dragged.width < -dragWidth {
seekTime(time - 5.0)
}
}
}
} else {
return true
}
}
if dragged.height > 100 {
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
}
if let time = getCurrentTime() {
let dragWidth = 20.0
if !model.seeking {
if (start.y < 130) {
if model.scale > 1.0 {
return true
}
if dragged.width > dragWidth {
seekTime(time + 5.0)
} else if dragged.width < -dragWidth {
seekTime(time - 8.0)
}
} else {
if dragged.width > dragWidth {
seekTime(time + 30.0)
} else if dragged.width < -dragWidth {
seekTime(time - 30.0)
}
}
}
// model.seeking = sk
}
return false
}
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
// player.seek(to: CMTime(seconds: time, preferredTimescale: CMTimeScale(10000)),
// toleranceBefore: CMTime(seconds: 0.3, preferredTimescale: CMTimeScale(10000)),
// toleranceAfter: CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(10000))){ _ 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
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 {
model.nominalFrameRate = await i.asset.load(.nominalFrameRate)
height = i.presentationSize.height
}
model.height = Int(height)
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
}
}
}
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 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 seek(_ v: Double) {
seekTimeSmoothly(v)
}
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,
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)
))
}
}