9 changed files with 374 additions and 38 deletions
-
12kplayer.xcodeproj/project.pbxproj
-
3kplayer/core/KSettings.swift
-
1kplayer/core/MediaItem.swift
-
13kplayer/detail/DetailViewController.swift
-
3kplayer/master/KSettingsView.swift
-
129kplayer/svideo/SVideoPlayer.swift
-
170kplayer/svideo/VideoPlayerView.swift
-
51kplayer/util/AsyncImage.swift
-
30kplayer/util/Utility.swift
@ -0,0 +1,170 @@ |
|||||
|
// |
||||
|
// Created by Marco Schmickler on 28.11.21. |
||||
|
// Copyright (c) 2021 Marco Schmickler. All rights reserved. |
||||
|
// |
||||
|
|
||||
|
import Foundation |
||||
|
|
||||
|
import SwiftUI |
||||
|
import AVFoundation |
||||
|
|
||||
|
// This is the UIView that contains the AVPlayerLayer for rendering the video |
||||
|
class VideoPlayerUIView: UIView { |
||||
|
private let player: AVPlayer |
||||
|
private let playerLayer = AVPlayerLayer() |
||||
|
private let videoPos: Binding<Double> |
||||
|
private let videoDuration: Binding<Double> |
||||
|
private let seeking: Binding<Bool> |
||||
|
private var durationObservation: NSKeyValueObservation? |
||||
|
private var timeObservation: Any? |
||||
|
|
||||
|
init(player: AVPlayer, videoPos: Binding<Double>, videoDuration: Binding<Double>, seeking: Binding<Bool>) { |
||||
|
self.player = player |
||||
|
self.videoDuration = videoDuration |
||||
|
self.videoPos = videoPos |
||||
|
self.seeking = seeking |
||||
|
|
||||
|
super.init(frame: .zero) |
||||
|
|
||||
|
backgroundColor = .black |
||||
|
playerLayer.player = player |
||||
|
layer.addSublayer(playerLayer) |
||||
|
|
||||
|
// Observe the duration of the player's item so we can display it |
||||
|
// and use it for updating the seek bar's position |
||||
|
durationObservation = player.currentItem?.observe(\.duration, changeHandler: { [weak self] item, change in |
||||
|
guard let self = self else { return } |
||||
|
self.videoDuration.wrappedValue = item.duration.seconds |
||||
|
}) |
||||
|
|
||||
|
// Observe the player's time periodically so we can update the seek bar's |
||||
|
// position as we progress through playback |
||||
|
timeObservation = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: nil) { [weak self] time in |
||||
|
guard let self = self else { return } |
||||
|
// If we're not seeking currently (don't want to override the slider |
||||
|
// position if the user is interacting) |
||||
|
guard !self.seeking.wrappedValue else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
self.videoDuration.wrappedValue = self.player.currentItem!.duration.seconds |
||||
|
// update videoPos with the new video time (as a percentage) |
||||
|
self.videoPos.wrappedValue = time.seconds / self.videoDuration.wrappedValue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
required init?(coder: NSCoder) { |
||||
|
fatalError("init(coder:) has not been implemented") |
||||
|
} |
||||
|
|
||||
|
override func layoutSubviews() { |
||||
|
super.layoutSubviews() |
||||
|
|
||||
|
playerLayer.frame = bounds |
||||
|
} |
||||
|
|
||||
|
func cleanUp() { |
||||
|
// Remove observers we setup in init |
||||
|
durationObservation?.invalidate() |
||||
|
durationObservation = nil |
||||
|
|
||||
|
if let observation = timeObservation { |
||||
|
player.removeTimeObserver(observation) |
||||
|
timeObservation = nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// This is the SwiftUI view which wraps the UIKit-based PlayerUIView above |
||||
|
struct VideoPlayerView: UIViewRepresentable { |
||||
|
@Binding private(set) var videoPos: Double |
||||
|
@Binding private(set) var videoDuration: Double |
||||
|
@Binding private(set) var seeking: Bool |
||||
|
|
||||
|
let player: AVPlayer |
||||
|
|
||||
|
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<VideoPlayerView>) { |
||||
|
// This function gets called if the bindings change, which could be useful if |
||||
|
// you need to respond to external changes, but we don't in this example |
||||
|
} |
||||
|
|
||||
|
func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView { |
||||
|
let uiView = VideoPlayerUIView(player: player, |
||||
|
videoPos: $videoPos, |
||||
|
videoDuration: $videoDuration, |
||||
|
seeking: $seeking) |
||||
|
return uiView |
||||
|
} |
||||
|
|
||||
|
static func dismantleUIView(_ uiView: UIView, coordinator: ()) { |
||||
|
guard let playerUIView = uiView as? VideoPlayerUIView else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
playerUIView.cleanUp() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// This is the SwiftUI view that contains the controls for the player |
||||
|
struct VideoPlayerControlsView : View { |
||||
|
@Binding private(set) var videoPos: Double |
||||
|
@Binding private(set) var videoDuration: Double |
||||
|
@Binding private(set) var seeking: Bool |
||||
|
|
||||
|
let player: AVPlayer |
||||
|
|
||||
|
@State var playerPaused = true |
||||
|
|
||||
|
var body: some View { |
||||
|
HStack { |
||||
|
// Play/pause button |
||||
|
Button(action: togglePlayPause) { |
||||
|
Image(systemName: playerPaused ? "play" : "pause") |
||||
|
.padding(.trailing, 10) |
||||
|
} |
||||
|
// Current video time |
||||
|
Text("\(Utility.formatSecondsToHMS(videoPos * videoDuration))") |
||||
|
// Slider for seeking / showing video progress |
||||
|
Slider(value: $videoPos, in: 0...1, onEditingChanged: sliderEditingChanged) |
||||
|
// Video duration |
||||
|
Text("\(Utility.formatSecondsToHMS(videoDuration))") |
||||
|
} |
||||
|
.padding(.leading, 10) |
||||
|
.padding(.trailing, 10) |
||||
|
} |
||||
|
|
||||
|
private func togglePlayPause() { |
||||
|
pausePlayer(!playerPaused) |
||||
|
} |
||||
|
|
||||
|
private func pausePlayer(_ pause: Bool) { |
||||
|
playerPaused = pause |
||||
|
if playerPaused { |
||||
|
player.pause() |
||||
|
} |
||||
|
else { |
||||
|
player.play() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func sliderEditingChanged(editingStarted: Bool) { |
||||
|
if editingStarted { |
||||
|
// Set a flag stating that we're seeking so the slider doesn't |
||||
|
// get updated by the periodic time observer on the player |
||||
|
seeking = true |
||||
|
pausePlayer(true) |
||||
|
} |
||||
|
|
||||
|
// Do the seek if we're finished |
||||
|
if !editingStarted { |
||||
|
let targetTime = CMTime(seconds: videoPos * videoDuration, |
||||
|
preferredTimescale: 600) |
||||
|
player.seek(to: targetTime) { _ in |
||||
|
// Now the seek is finished, resume normal operation |
||||
|
self.seeking = false |
||||
|
self.pausePlayer(false) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
import SwiftUI |
||||
|
import Haneke |
||||
|
|
||||
|
struct AsyncImage<Placeholder: View>: View { |
||||
|
@StateObject private var item: MediaItem |
||||
|
private let placeholder: Placeholder |
||||
|
private let image: (UIImage) -> Image |
||||
|
|
||||
|
init( |
||||
|
item: MediaItem, |
||||
|
@ViewBuilder placeholder: () -> Placeholder, |
||||
|
@ViewBuilder image: @escaping (UIImage) -> Image = Image.init(uiImage:) |
||||
|
) { |
||||
|
self.placeholder = placeholder() |
||||
|
self.image = image |
||||
|
_item = StateObject(wrappedValue: item) |
||||
|
|
||||
|
setImage(newItem: item) |
||||
|
} |
||||
|
|
||||
|
var body: some View { |
||||
|
content |
||||
|
// .onAppear(perform: loader.load) |
||||
|
} |
||||
|
|
||||
|
private var content: some View { |
||||
|
Group { |
||||
|
if item.image != nil { |
||||
|
image(item.image!) |
||||
|
} else { |
||||
|
placeholder |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func setImage(newItem: MediaItem) { |
||||
|
if newItem.image != nil { |
||||
|
let icon = newItem.image!.scaleToSize(66.0, height: 44.0) |
||||
|
} else { |
||||
|
if newItem.thumbUrl != nil { |
||||
|
let URL = Foundation.URL(string: newItem.thumbUrlAbsolute)! |
||||
|
|
||||
|
Shared.imageCache.fetch(URL: URL).onSuccess { |
||||
|
i in |
||||
|
newItem.image = i.scaleToSize(66.0, height: 44.0) |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
// |
||||
|
// Utility.swift |
||||
|
// AVPlayer-SwiftUI |
||||
|
// |
||||
|
// Created by Chris Mash on 11/09/2019. |
||||
|
// Copyright © 2019 Chris Mash. All rights reserved. |
||||
|
// |
||||
|
|
||||
|
import Foundation |
||||
|
|
||||
|
class Utility: NSObject { |
||||
|
|
||||
|
private static var timeHMSFormatter: DateComponentsFormatter = { |
||||
|
let formatter = DateComponentsFormatter() |
||||
|
formatter.unitsStyle = .positional |
||||
|
formatter.allowedUnits = [.minute, .second] |
||||
|
formatter.zeroFormattingBehavior = [.pad] |
||||
|
return formatter |
||||
|
}() |
||||
|
|
||||
|
static func formatSecondsToHMS(_ seconds: Double) -> String { |
||||
|
guard !seconds.isNaN, |
||||
|
let text = timeHMSFormatter.string(from: seconds) else { |
||||
|
return "00:00" |
||||
|
} |
||||
|
|
||||
|
return text |
||||
|
} |
||||
|
|
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue