I am learning SwiftUI, I want to observe an AVPlayer status so I know when the videos is paused or not.
My current approach is more less like this:
I have VideosView that holds a list of a videos (in ZStack cards).
VideoViews has a VideosViewModel.
in VideosView i am calling in onAppear VideosViewModel.getItems...
struct ItemModel: Identifiable, Codable, Hashable, Equatable {
var id: String
var author: String // video owner
var url: URL? // url to the video
var player: AVPlayer? // AVPlayer created based on self.url...
mutating func setPlayer(_ avPlayer: AVPlayer) {
self.player = avPlayer
}
}
// vm
class FeedViewModel: ObservableObject {
@Published private(set) var items: [ItemModel] = []
func getItems() async {
do {
// fetch data from the API
let data = try await dataService.fetchFeeds()
// download and attach videos
downloadFeedVideos(data)
} catch {
// ....
}
}
private func downloadFeedVideos(_ feeds: [ItemModel]) {
for index in feeds.indices {
var item = feeds[index]
if let videoURL = item.url {
self.downloadQueue.queueDownloadIfFileNotExists(
videoURL,
DownloadOperation(
session: URLSession.shared,
downloadTaskURL: videoURL,
completionHandler: { [weak self] (localURL, response, error) in
guard let tempUrl = localURL else { return }
let saveResult = self?.fileManagerService.saveInTemp(tempUrl, fileName: videoURL.lastPathComponent)
switch saveResult {
case .success(let savedURL):
DispatchQueue.main.async {
// maybe this is a wrong place to have it?
item.setPlayer(AVPlayer(url: savedURL))
self?.items.append(item)
if self?.items.count ?? 0 > 1 {
// once first video is downloaded, use all device cores to fetch next videos
// all newest iOS devices has 6 cores
self?.downloadQueue.setMaxConcurrentOperationCount(.max)
}
}
case .none: break
case .failure(_):
EventTracking().track("Video download fail", [
"id": item.id,
"ulr": videoURL.absoluteString.decodeURL()
])
}
}), { fileCacheURL in
// file already downloaded
DispatchQueue.main.async {
item.setPlayer(AVPlayer(url: fileCacheURL))
self.items.append(item)
}
})
}
}
}
}
I found this article with some pseudo-code of how to track video playback state but I'm not sure how to implement it in my code....
https://developer.apple.com/documentation/avfoundation/media_playback/observing_playback_state
Post
Replies
Boosts
Views
Activity
Is it possible to disable NavigationLink transition animation in SwiftUI? To be more precisely. I want to disable it only for a few links/views. Not the whole app
I want to use AVPlayerViewController to display the video but it should be in auto-play mode.
Previously I was using AVPlayer for that and listening to the .AVPlayerItemDidPlayToEndTime notification but I wonder if there is a better way? eg. using AVPlayerLooper for instance so I don't have to use that .AVPlayerItemDidPlayToEndTime anymore
I wrote something like this but it is not working - I have a black screen with video controls - probably because AVPlayerViewController does not have any playable content...
struct VideoPlayerQueuedView: UIViewControllerRepresentable {
let videoUrl: URL
func makeUIViewController(context: Context) -> AVPlayerViewController {
let queuePlayer = AVQueuePlayer()
let playerViewController = AVPlayerViewController()
// Create an AVPlayerItem from the videoUrl
let playerItem = AVPlayerItem(url: videoUrl)
// Create an AVPlayerLooper with the queuePlayer and the playerItem as the template item
let playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: playerItem)
// Set the player property of AVPlayerViewController
playerViewController.player = queuePlayer
return playerViewController
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
// Update the video player if needed
}
}
I need to get video duration in seconds.
If I use code below
guard let playerItem = self.player?.currentItem else { return 0 }
print("playerItem.duration", playerItem.duration, playerItem.duration.seconds, playerItem.duration.toString)
it returns me a double value 37.568
CMTime(value: 37568, timescale: 1000, flags: __C.CMTimeFlags(rawValue: 1), epoch: 0) 37.568 00:00:37
I wonder why it's a 37.568 instead of 37.000? if video is 00:00:37?
Does it means, it is a 37,5s long?
So I have a class like below
class DownloadOperation: Operation {
private var task : URLSessionDownloadTask!
enum OperationState : Int {
case ready
case executing
case finished
}
// default state is ready (when the operation is created)
private var state : OperationState = .ready {
willSet {
self.willChangeValue(forKey: "isExecuting")
self.willChangeValue(forKey: "isFinished")
}
didSet {
self.didChangeValue(forKey: "isExecuting")
self.didChangeValue(forKey: "isFinished")
}
}
override var isReady: Bool { return state == .ready }
override var isExecuting: Bool { return state == .executing }
override var isFinished: Bool { return state == .finished }
init(
session: URLSession,
downloadTaskURL: URL,
item: ItemModel,
completionHandler: ((URL?, URLResponse?, ItemModel, Error?) -> Void)?
) {
super.init()
// use weak self to prevent retain cycle
task = session.downloadTask(
with: downloadTaskURL, completionHandler: { [weak self] (localURL, response, error) in
/*
if there is a custom completionHandler defined,
pass the result gotten in downloadTask's completionHandler to the
custom completionHandler
*/
if let completionHandler = completionHandler {
// localURL is the temporary URL the downloaded file is located
completionHandler(localURL, response, item, error)
}
/*
set the operation state to finished once
the download task is completed or have error
*/
self?.state = .finished
})
}
override func start() {
/*
if the operation or queue got cancelled even
before the operation has started, set the
operation state to finished and return
*/
if(self.isCancelled) {
state = .finished
return
}
// set the state to executing
state = .executing
// start the downloading
self.task.resume()
}
override func cancel() {
super.cancel()
// cancel the downloading
self.task.cancel()
}
}
I would like to call it in async func, but I'm having difficulties with converting it to asyn func
func getItemsAsync() async {
requestStatus = .pending
do {
let feedsData = try await dataService.fetchAllFeedsAsync()
for index in feedsData.indices {
var item = feedsData[index]
// make sure Item has a URL
guard let videoURL = item.url else { return }
let operation = DownloadOperation(
session: URLSession.shared,
downloadTaskURL: videoURL,
item: item,
completionHandler: { [weak self] (localURL, response, item, error) in
guard let tempUrl = localURL else { return }
let saveResult = self?.fileManagerService.saveInTemp(tempUrl, fileName: videoURL.lastPathComponent)
switch saveResult {
case .success(let savedURL):
let newItem: ItemModel = .init(
id: item.id,
player: AVPlayer(url: savedURL)
)
await MainActor.run(body: {
self?.items.append(newItem)
if items.count ?? 0 > 1 {
// once first video is downloaded, use all device cores to fetch next videos
// all newest iOS devices has 6 cores
downloadQueue.setMaxConcurrentOperationCount(.max)
}
})
case .none: break
case .failure(_):
EventTracking().track("Video download fail", [
"id": item.id,
"ulr": videoURL.absoluteString.decodeURL()
])
}
})
let fileCaheURL = downloadQueue.queueDownloadIfFileNotExists2(videoURL, operation)
if let fileCaheURL = fileCaheURL {
// ... do some other magic
}
}
} catch let error {
requestStatus = .error
errorMessage = error.localizedDescription
}
}
I have an API endpoint that returns array of various items, so I have created an enum that can hold those items and therefore I can create an array of them - more less like below
let arr: [CollectableEnum] = [
.feed(.init(id: "1")),
.clinic(.init(id: "1")),
.feed(.init(id: "2")),
]
Now I need to iterate through them and display properly in SwiftUI
I wonder, how to get an value from that enum, so I can get an ItemModel rather than CollectableEnum that holds that ItemModel
enum code:
enum CollectableEnum: Identifiable, Hashable {
case feed(ItemModel)
case clinic(ClinicModel)
var id: String {
switch self {
case .feed(let feed):
return feed.id
case .clinic(let clinic):
return clinic.id
}
}
// what return type should I put here?
var associatedValue {
switch self {
case .feed(let feed):
return feed
case .clinic(let clinic):
return clinic
}
}
public func hash(into hasher: inout Hasher) {
return hasher.combine(self)
}
}
I am trying to make simple app auth.
In ContentView I have two tabs, each tab will have their own .task{} in which I will make an HTTP request to obtain some data.
The problem that I am facing is when you open the app, switch tabs and tap "toggle" at the top, you will see in the console that both .task{} and .onAppear{} has been called event tho these views wren't visible at all. Why? And how to make API calls only when app states is logged
As a workaround I could add extra check in each .task to check the app state but it doesn't seems right...
import SwiftUI
@main
struct myApp: App {
@StateObject private var auth: Auth = Auth()
var body: some Scene {
WindowGroup {
if auth.isLogged {
ContentView()
.environmentObject(auth)
} else {
VStack {
Button("toggle auth") {
auth.isLogged.toggle()
}
}
}
}
}
}
@MainActor
class Auth: ObservableObject {
@Published var isLogged: Bool = false
}
struct ContentView: View {
// Annimatin Properties
@State private var expanded: Bool = false
@Namespace private var animation
@EnvironmentObject var auth: Auth
var body: some View {
Button("toggle") {
self.auth.isLogged.toggle()
}
TabView {
SampleTab("Listen Now", "play.circle.fill")
SampleTab("Browse", "square.grid.2x2.fill")
}
}
@ViewBuilder
func CustomBottomSheet() -> some View {
// Animating sheet backgrund (To look like it's expanding from the Bottom)
VStack {
Text("Hello")
}
.frame(height: 70)
.overlay(alignment: .bottom, content: {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 1)
})
.offset(y: -49)
}
@ViewBuilder
func SampleTab(_ title: String, _ icon: String) -> some View {
// iOS Bug, it can be avoided by wrapping the view inside ScrollView
ScrollView(.vertical, showsIndicators: false, content: {
Text(title)
.padding(.top, 25)
})
.tabItem {
Image(systemName: icon)
Text(title)
}
.onAppear {
print("======= onappear \(title)")
}
.task {
// I am gonna make an HTPP request here...
print("======= task \(title)")
}
}
}
I wonder how TikTok/Instagram caches the video so the playing is so smooth.
I was thinking about downloading them on a sequence and allow user to skip n video only when n+1 is downloaded. But where should I store/download it?
Should I use NACache for that or just save them in app temp with FileManager?
Also some pseudo-code of how to do that with SwiftUI might be very helpful. 🙏🏼
So I have created simple code like below.
I have 2 problems:
first is when you try do drag first video it is not being removed, instead last one (from the background) is.
I have a Task in .onAppear which download all videos at the same time. How to make is sync, so next video will be downloaded only when first one is completed?
import SwiftUI
import AVKit
struct Video: Identifiable {
var id: Int
var cacheName: String
var reply: Bool
}
struct PlayerView: View {
@Binding var data: [Video]
@State private var offset = CGSize.zero
@State private var video: Video?
var body: some View {
ZStack {
ForEach(self.data.indices.reversed(), id: \.self) { index in
let i = self.data[index]
if index < 3 {
if let player = CacheManager.shared.get(name: i.cacheName) {
Player(player: player)
.frame(width: 300, height: 300)
.overlay {
Text("\(index) \(i.id)").foregroundColor(.blue)
}
.scaleEffect(1 - 0.05 * CGFloat(index), anchor: .top)
.offset(
x: 0,
y: ({
if (index > 0) {
return CGFloat(index) * -5.5
}
//not a first card, or swiping up
if offset.height < 0 {
return 0
}
// swiping down
return offset.height * 1.1
}())
)
.onTapGesture(perform: {
print("tapped", index)
if index == 0 {
self.video = i
}
})
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { value in
if abs(offset.height) > 100 && index == 0 {
withAnimation {
print("iiii", i)
// <<<<<< this function removes invalid element from the array, WHY?
self.data.removeAll { $0.cacheName == i.cacheName }
offset = .zero
}
} else {
offset = .zero
}
})
.onAppear {
if index == 0 {
player.play()
} else {
player.pause()
}
}
.onDisappear {
player.pause()
}
} else {
Text("not found")
}
} else {
EmptyView()
}
}
}
.fullScreenCover(item: $video, content: { video in
if let player = CacheManager.shared.get(name: video.cacheName) {
ZStack {
Player(player: player)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.ignoresSafeArea()
VStack {
Button(action: {
player.seek(to: .zero, completionHandler: {_ in
player.play()
})
}, label: {
Text("PLAY").foregroundColor(.black)
})
Button(action: {
self.video = nil
}, label: {
Text("CLOSE").foregroundColor(.black)
})
}
}
} else {
Text("none")
}
})
}
}
struct Player: UIViewControllerRepresentable {
var player: AVPlayer
func makeUIViewController(context: Context) -> AVPlayerViewController {
let view = AVPlayerViewController()
view.player = player
view.showsPlaybackControls = false
view.videoGravity = .resizeAspectFill
return view
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
}
}
let urls = [
"https://dnr07ippqik25.cloudfront.net/01GXX7WFGJVZ11RCC6Q7XMYH02.mp4",
"https://dnr07ippqik25.cloudfront.net/01GXX7D1MJRNG10BT16EV3RF4Z.mp4",
"https://dnr07ippqik25.cloudfront.net/01GXX7FXXHPP3WGX7ZY5SCXCC5.mp4",
"https://dnr07ippqik25.cloudfront.net/01GXX7F5R5MTEF6AWTRA806SAZ.mp4"
]
struct ContentView: View {
@State var data: [Video] = []
private let cm = CacheManager.shared
var body: some View {
PlayerView(data: $data)
.onAppear {
urls.indices.forEach { index in
Task {
do {
let url = URL(string: urls[index])!
let folder = try! FileManager.default
.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let destination = folder.appendingPathComponent(url.lastPathComponent)
if cm.get(name: urls[index]) != nil {
data.append(.init(id: index, cacheName: urls[index], reply: false))
} else {
try await downloadFiles(url: url, destination: destination)
cm.add(player: AVPlayer(url: destination), name: urls[index])
data.append(.init(id: index, cacheName: urls[index], reply: false))
}
} catch {
print(error)
}
}
}
}
}
}
func downloadFiles(url: URL, destination: URL) async throws {
if !FileManager.default.fileExists(atPath: destination.path) {
let (source, _) = try await URLSession.shared.download(from: url)
try FileManager.default.moveItem(at: source, to: destination)
} else {
print("file already downloaded")
}
}
class CacheManager {
static let shared = CacheManager()
private init() {}
var avPlayerCache: NSCache<NSString, AVPlayer> = {
let cache = NSCache<NSString, AVPlayer>()
cache.countLimit = 50
cache.totalCostLimit = 1024 * 1024 * 100 // 100MB
return cache
}()
func add(player: AVPlayer, name: String) {
avPlayerCache.setObject(
player,
forKey: name as NSString
)
}
func remove(name: String) {
avPlayerCache.removeObject(forKey: name as NSString)
}
func get(name: String) -> AVPlayer? {
return avPlayerCache.object(forKey: name as NSString)
}
}