SwiftUI Video Library

Building a custom video player and library in Swift

Author Avatar

Dakshin Devanand

Intro #

I recently built a video gallery for a personal project using SwiftUI on iOS 18 that supports uploading videos, previewing them in a grid, and playing them in a custom video player. The UI mimics the native Photos app. This post shows the full approach end‑to‑end.

  • Use PhotosPicker to import videos
  • Save videos to local file storage and track them with SwiftData
  • Generate thumbnails for fast grids
  • Build a custom AVPlayer-backed video player with custom controls

Source code: https://github.com/DakshinD/VideoGallery

targets
#

SwiftUI PhotoPicker #

First, create a new SwiftUI project, and if you want to follow along exactly with me, use SwiftData as well.

Getting some boilerplate code out of the way, let’s create our main view with a button to toggle our sheet for adding a video.

 1// ContentView.swift
 2import SwiftUI
 3import SwiftData
 4
 5struct ContentView: View {
 6    @Environment(\.modelContext) private var modelContext
 7    @State private var isShowingAddView = false
 8
 9    var body: some View {
10        NavigationStack {
11            ScrollView(.vertical) {
12                // Grid of videos goes here
13            }
14            .navigationTitle("Gallery")
15            .toolbar {
16                Button {
17                    isShowingAddView.toggle()
18                } label: {
19                    HStack {
20                        Image(systemName: "plus")
21                            .resizable()
22                            .frame(width: 15, height: 15)
23                        Text("Add")
24                    }
25                    .padding(.horizontal, 15)
26                    .padding(.vertical, 8)
27                    .background(Color.gray.opacity(0.3))
28                    .clipShape(Capsule())
29                }
30            }
31            .sheet(isPresented: $isShowingAddView) { AddVideoView() }
32        }
33    }
34}

And also lets create our sheet view that lets the user add their video.

One little caveat to remember is, sheets are not pushed on the NavigationStack, so if we want a title for our sheet view, we need to give its own NavigationStack.

 1// AddVideoView.swift
 2import SwiftUI
 3import PhotosUI
 4
 5struct AddVideoView: View {
 6    @Environment(\.modelContext) private var modelContext
 7    @Environment(\.presentationMode) var presentationMode
 8
 9    @State private var date = Date()
10
11    var body: some View {
12        NavigationStack {
13            Form {
14                Section(header: Text("Video")) {
15                    Text("Add Video") // placeholder for PhotosPicker
16                }
17
18                Section(header: Text("Date")) {
19                    DatePicker("Select Date", selection: $date, displayedComponents: .date)
20                }
21
22                Button("Save Video") { saveVideo() }
23            }
24            .navigationTitle("Add Video")
25        }
26    }
27
28    private func saveVideo() {
29        // Logic to save video
30        presentationMode.wrappedValue.dismiss()
31    }
32}

Now let’s actually get into the code. We’re going to use the PhotoPicker introduced in iOS 16. Add the state variable to keep track of the selected PhotosPickerItem and the PhotoPicker itself in place of our placeholder text.

1// AddVideoView.swift
2@State private var selectedVideo: PhotosPickerItem?
3
4PhotosPicker("Select Video", selection: $selectedVideo, matching: .videos)
5    .onChange(of: selectedVideo) { _, _ in
6        if let selectedVideo {
7            Task { await loadSelectedVideo(from: selectedVideo) }
8        }
9    }

Tip: If testing on a simulator and your library has no videos, just drag and drop videos from your Mac onto the simulator and it stores them in your library automatically for use.

To break this down, our PhotosPicker returns us a PhotosPickerItem on user selection, and we want to load that item in to store in our local FileStorage asynchronously. Now let’s implement the aforementioned loading.

Loading Video #

 1// AddVideoView.swift
 2@State private var videoURL: URL? = nil
 3@State private var videoFileName: String? = nil
 4
 5private func loadSelectedVideo(from item: PhotosPickerItem) async {
 6    if let data = try? await item.loadTransferable(type: Data.self),
 7       let fileExtension = item.supportedContentTypes.first?.preferredFilenameExtension {
 8
 9        let videoName = UUID().uuidString + ".\(fileExtension)"
10
11        if let fileURL = saveDataToDocumentsDir(data, with: videoName) {
12            videoURL = fileURL
13            videoFileName = videoName
14        } else {
15            print("Failed to save data item to directory.")
16        }
17    } else {
18        print("Failed to load media item as data")
19    }
20}
21
22private func saveDataToDocumentsDir(_ data: Data, with fileName: String) -> URL? {
23    let documentsURL = URL.documentsDirectory
24    let fileURL = documentsURL.appendingPathComponent(fileName)
25    do {
26        try data.write(to: fileURL)
27        return fileURL
28    } catch {
29        print("Error saving file", error)
30        return nil
31    }
32}

Our loadSelectedVideo uses loadTransferable to get the Data object of our video, and then we create a name for our video file, save it to our local documents directory, and store the file name and URL at which we saved our file. Storing the file name instead of only the URL is critical as we will find out later. (Hint: URL.documentsDirectory changes every time the simulator is run, so absolute paths change)

Now, looking ahead, in our gallery we want to store potentially 10s to 100s of videos if our app scales. Loading and displaying videos is also costly in terms of resource consumption. Attempting to load/display a mass amount of videos onAppear of a View would be disastrous for the user experience. The solution I came to is to store thumbnails for every video, only display these thumbnails to the user, and on tap of the thumbnail, we load our video in our custom VideoPlayer we create later.

For now, let’s go ahead and modify our code to store thumbnails at the same time we load our video.

Generating a Thumbnail #

 1// AddVideoView.swift
 2
 3@State private var thumbnailURL: URL? = nil
 4@State private var thumbnailFileName: String? = nil
 5
 6...
 7
 8private func loadSelectedVideo(from item: PhotosPickerItem) async {
 9  // Try to download the item
10  if let data = try? await item.loadTransferable(type: Data.self),
11     let fileExtension = item.supportedContentTypes.first?.preferredFilenameExtension {
12      
13      // Now that we have the data, we need to save it locally
14      let videoName = UUID().uuidString + ".\(fileExtension)"
15      
16      if let fileURL = saveDataToDocumentsDir(data, with: videoName) {
17          // Success! Lets save our URL and video filename
18          videoURL = fileURL
19          videoFileName = videoName
20          
21          // Let's generate our thumbnail while we're here!
22          let result = await generateThumbnail(videoURL: fileURL)
23          guard let thumbnailURL = result.0, let thumbnailName = result.1 else {
24              print("Failed to generate thumbnail from video: \(fileURL)")
25              return
26          }
27          
28          // Save our thumbnail URL and filename
29          self.thumbnailURL = thumbnailURL
30          self.thumbnailFileName = thumbnailName
31      } else {
32          print("Failed to save data item to directory.")
33      }
34  } else {
35      print("Failed to load media item as data")
36  }
37}
38
39private func generateThumbnail(videoURL: URL) async -> (URL?, String?) {
40  var finalURL: URL? = nil
41  let fileName = UUID().uuidString + ".jpg"
42  
43  // Create a generator based on a video asset
44  let asset = AVURLAsset(url: videoURL)
45  let generator = AVAssetImageGenerator(asset: asset)
46  generator.appliesPreferredTrackTransform = true // For default rotation
47  videoDuration = try? await asset.load(.duration).seconds // Get duration for fun
48  
49  // Change the time if you want your thumbnail to be from a different part 
50  // of the video
51  let time = CMTimeMakeWithSeconds(0.0, preferredTimescale: 500)
52  
53  do {
54      // Now we need to store our thumbnail in local storage as well
55      // In order to use our saveToDocumentsDir method, we need the file in Data format
56      // To convert a CGImage --> UIImage --> Data
57      let cgImage = try await generateCGImageAsync(generator: generator, for: time)
58      let uiImage = UIImage(cgImage: cgImage)
59      
60      // Now we have a Data object
61      if let imageData = uiImage.jpegData(compressionQuality: 0.8) {
62          
63          
64          // Save our thumbnail
65          if let savedURL = saveDataToDocumentsDir(imageData, with: fileName) {
66              print("Thumbnail saved at \(savedURL)")
67              finalURL = savedURL
68          } else {
69              print("Failed to save thumbnail to directory.")
70          }
71      } else {
72          print("Failed to convert UIImage to Data.")
73      }
74  } catch {
75      print("Failed to generate thumbnail: \(error.localizedDescription)")
76  }
77  
78  // Return a pair of the URL and name for convenience
79  return (finalURL, fileName)
80}
81    
82/* Wrapper around generateCGImageAsync
83 * This converts the callback-based API into async/await using a continuation.
84 * The continuation resumes execution when the image generation completes,
85 * either with a CGImage or an error.
86 */
87private func generateCGImageAsync(generator: AVAssetImageGenerator, for time: CMTime) async throws -> CGImage {
88  try await withCheckedThrowingContinuation { continuation in
89      generator.generateCGImageAsynchronously(for: time) { cgImage, _, error in
90          if let error = error {
91              continuation.resume(throwing: error) // Resume with error
92          } else if let cgImage = cgImage {
93              continuation.resume(returning: cgImage) // Resume with the image
94          } else {
95              continuation.resume(throwing: NSError(domain: "ThumbnailError", code: -1, userInfo: nil))
96          }
97      }
98  }
99}

Now I just threw out a lot of code so let’s go through it:

  • Added state variables to store thumbnail filename and
  • Minimal changes to loadSelectedVideo, just unwrapping the result of our thumbnail generation and storing it in our state
  • generateThumbnail - Takes the brunt of the work:
  1. Makes a AVURLAsset with our video and then a AVAssetImageGenerator with that asset

  2. We use the generator to get the duration of the video and the thumbnail at time 00:00

  3. Converts our thumbnail of type CGImage to a Data object to save to our documents directory (Have to go CGImage → UIImage → Data unfortunately, if anyone knows a better way please let me know!)

  • generateCGImageAsync - Now this method is a wrapper around AVAssetImageGenerator.generateCGImageAsynchronously(). This async call returns with a callback. However, here’s the problem. Without this wrapper, we would be calling generateCGImageAsynchronously inside our generateThumbnail async function. Using old callback-based APIs and async/await doesn’t mix well and can cause concurrency issues. So we take advantage of withCheckedThrowingContinuation.

If you would like to read more about continuations, these HackingWithSwift articles (1, 2) helped me out. Now that we have that all completed, we can finally go ahead and display the selected video (in reality, we only show the user the thumbnail), and on confirmation, we save a Video object into SwiftData.

Displaying Selected Video and Saving with SwiftData #

Display a preview (thumbnail) and persist a Video record to SwiftData on Save.

  1// AddVideoView.swift
  2
  3enum VideoState {
  4  case unkown
  5  case loading
  6  case loaded
  7  case failed
  8}
  9
 10@State private var videoState: VideoState = .unkown
 11
 12...
 13
 14// New UI for adding Videos that displays the selected Video underneath 
 15Section(header: Text("Video")) {
 16  PhotosPicker("Select Video", selection: $selectedVideo, matching: .videos)
 17      .onChange(of: selectedVideo) { _, _ in
 18          if let selectedVideo {
 19              Task {
 20                  await loadSelectedVideo(from: selectedVideo)
 21              }
 22          }
 23      }
 24  
 25  switch videoState {
 26  case .unkown:
 27      EmptyView()
 28  case .loading:
 29      ProgressView()
 30  case .loaded:
 31      VideoItem(thumbnailFileName: thumbnailFileName!, duration: videoDuration ?? 0.0, size: 100)
 32  case .failed:
 33      Text("Import failed")
 34  }
 35}
 36
 37...
 38
 39// Save our Video to SwiftData
 40private func saveVideo() {
 41  // Logic to save video
 42  guard let videoFileName = videoFileName,
 43        let thumbnailFileName = thumbnailFileName,
 44        let videoDuration = videoDuration else { return }
 45
 46  let newVideo = Video(
 47      id: UUID(),
 48      thumbnailFileName: thumbnailFileName,
 49      videoFileName: videoFileName,
 50      duration: videoDuration,
 51      date: date
 52  )
 53
 54  modelContext.insert(newVideo) // Insert into SwiftData
 55
 56  do {
 57      try modelContext.save() // Save changes
 58      presentationMode.wrappedValue.dismiss()
 59  } catch {
 60      print("Failed to save video: \(error)")
 61  }
 62  presentationMode.wrappedValue.dismiss()
 63}
 64
 65...
 66
 67// *** Added change of videoState so we can display progress to the user
 68private func loadSelectedVideo(from item: PhotosPickerItem) async {
 69  videoState = .loading
 70
 71  // Try to download the item
 72  if let data = try? await item.loadTransferable(type: Data.self),
 73     let fileExtension = item.supportedContentTypes.first?.preferredFilenameExtension {
 74      
 75      // Now that we have the data, we need to save it locally
 76      let videoName = UUID().uuidString + ".\(fileExtension)"
 77      
 78      if let fileURL = saveDataToDocumentsDir(data, with: videoName) {
 79          // Success! Lets save our URL and video fileName
 80          videoURL = fileURL
 81          videoFileName = videoName
 82          
 83          // Let's generate our thumbnail while we're here!
 84          let result = await generateThumbnailURL(videoURL: fileURL)
 85          guard let thumbnailURL = result.0, let thumbnailName = result.1 else {
 86              print("Failed to generate thumbnail from video: \(fileURL)")
 87              return
 88          }
 89          
 90          self.thumbnailURL = thumbnailURL
 91          self.thumbnailFileName = thumbnailName
 92          
 93          // Done loading
 94          videoState = .loaded
 95      } else {
 96          print("Failed to save data item to directory.")
 97          videoState = .failed
 98      }
 99  } else {
100      print("Failed to load media item as data")
101      videoState = .failed
102  }
103}
 1// Video.swift
 2
 3import SwiftUI
 4import AVKit
 5import SwiftData
 6
 7/*
 8 * Swift Data object for storing our Video that the user chooses
 9 */
10@Model
11class Video: Identifiable {
12    
13    var id: UUID
14    var thumbnailFileName: String
15    var videoFileName: String
16    var duration: Double
17    var date: Date
18    
19    init(id: UUID, thumbnailFileName: String, videoFileName: String, duration: Double, date: Date) {
20        self.id = id
21        self.thumbnailFileName = thumbnailFileName
22        self.videoFileName = videoFileName
23        self.duration = duration
24        self.date = date
25    }
26    
27}
28
29struct VideoItem: View {
30    
31    var thumbnailFileName: String
32    var duration: Double
33    var size: CGFloat = 200
34    var fontSize: CGFloat = 12
35    
36    // Recreate full path on demand due to changing documentsDirectory path
37    var thumbnailURL: URL {
38        URL.documentsDirectory.appendingPathComponent(thumbnailFileName)
39    }
40    
41    var body: some View {
42        ZStack(alignment: .bottomTrailing) {
43            
44            // Thumbnail
45            AsyncImage(url: thumbnailURL) { res in
46                res.image?
47                    .resizable()
48                    .aspectRatio(contentMode: .fill)
49            }
50            .frame(width: size, height: size)
51            .clipped()
52            .overlay(Rectangle().stroke(Color.black, lineWidth: 1))
53            
54            // Duration text on bottom right
55            Text("\(formatDuration(duration))")
56                .font(.system(size: fontSize, weight: .heavy, design: .rounded))
57                .foregroundStyle(.white)
58                .padding(5)
59        }
60        
61    }
62    
63    func formatDuration(_ durationInSeconds: Double) -> String {
64        let formatter = DateComponentsFormatter()
65        formatter.allowedUnits = [.minute, .second]
66        formatter.zeroFormattingBehavior = [.pad] // Ensures double digits for seconds (e.g., 0:09)
67        formatter.unitsStyle = .positional        // Removes labels, keeps it like 1:34
68        
69        return formatter.string(from: durationInSeconds) ?? "0:00"
70    }
71
72}

Another dump of changes:

  1. The first code block consists of a few changes including storing our video state so we can display a progress view when loading, and then the final video when done. Also saveVideo function for saving to SwiftData.

  2. The 2nd code block can be in a new file. We define our SwiftData model and also a little square component to display a thumbnail.

And now we’re done with picking a video, storing it to our file system, and adding it to SwiftData.

targets

Displaying the Grid #

 1// ContentView.swift
 2
 3@Query var videos: [Video]
 4var clipSize: CGFloat = 150
 5var columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3)
 6
 7...
 8
 9ScrollView(.vertical) {
10LazyVGrid(columns: columns, spacing: 0) {
11    ForEach(videos, id: \.self) { vid in
12        VideoItem(thumbnailFileName: vid.thumbnailFileName, duration: vid.duration, size: (UIScreen.main.bounds.width)/3, fontSize: 12)
13    }
14  }
15}

This part is quite simple, we take advantage of our pre-created VideoItem component and display it in a LazyVGrid.

targets

Custom Video Player (Photos‑style) #

Now we want to display a full screen video player when we click on a item. We’re going to have to roll our own player here.

 1// VideoPlayerView.swift
 2
 3import UIKit
 4import AVKit
 5import SwiftUI
 6
 7struct VideoPlayerView: UIViewControllerRepresentable {
 8    
 9    typealias UIViewControllerType = AVPlayerViewController
10    
11    let player: AVPlayer
12    
13    func makeUIViewController(context: Context) -> AVPlayerViewController {
14        let controller = AVPlayerViewController()
15        controller.player = player
16        controller.showsPlaybackControls = false
17        return controller
18    }
19    
20    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
21        
22    }
23    
24}

Use UIViewControllerRepresentable, and make sure we turn off playbackControls so we can use our own.

  1// VideoPlayer.swift
  2
  3import SwiftUI
  4import AVFoundation
  5
  6struct VideoPlayer: View {
  7    
  8  var vid: Video
  9  private var player: AVPlayer
 10  private var dateString: String
 11  
 12  
 13  
 14  init(video: Video) {
 15      self.vid = video
 16      self.player = AVPlayer(url: vid.videoURL)
 17      self.dateString = Self.formatDateForPlayer(vid.date)
 18  }
 19  
 20  
 21  @State private var isPlaying: Bool = false
 22  @State private var isDragging: Bool = false
 23  @State private var isMuted = false
 24  @State private var currentTime: Double = 0
 25  @State private var duration: Double = 0
 26  
 27  @State private var previousPlayingState: Bool? = nil
 28  
 29  
 30  var body: some View {
 31      ZStack(alignment: .topLeading) {
 32          
 33          // Video
 34          VideoPlayerView(player: player)
 35              .edgesIgnoringSafeArea(.all)
 36              .onAppear {
 37                  duration = vid.duration
 38                  addTimeObserver()
 39              }
 40          
 41          // Date
 42          HStack {
 43              
 44              Text("\(dateString)")
 45                  .font(.system(size: 24, weight: .heavy))
 46                  .foregroundStyle(.white)
 47                  .padding([.top, .leading], 16)
 48              
 49              Spacer()
 50          }
 51          
 52          
 53          // Playback controls
 54          VStack(spacing: 10) {
 55              
 56              Spacer()
 57              
 58              // Display the durations if we are dragging the slider
 59              if isDragging {
 60                  HStack {
 61                      // Time Elapsed on left
 62                      Text(formatTimeElapsed(currentTime))
 63                          .font(.subheadline)
 64                          .foregroundStyle(.white)
 65                      
 66                      Spacer()
 67                      
 68                      Text(formatTimeLeft(duration - currentTime))
 69                          .font(.subheadline)
 70                          .foregroundStyle(.white)
 71                  }
 72                  .padding(.horizontal, 16)
 73                  
 74              }
 75              
 76              // Display the play/mute button normally
 77              if !isDragging {
 78                  HStack {
 79                      // Play Button
 80                      Button(action: {
 81                          isPlaying.toggle()
 82                          if isPlaying { player.play() }
 83                          else { player.pause() }
 84                      }) {
 85                          Image(systemName: isPlaying ? "pause.fill" : "play.fill")
 86                              .foregroundStyle(.white)
 87                              .font(.title2)
 88                              .contentTransition(.symbolEffect(.automatic))
 89                      }
 90                      
 91                      Spacer()
 92                      
 93                      // Mute/Unmute button
 94                      Button(action: {
 95                          isMuted.toggle()
 96                          player.isMuted = isMuted
 97                      }) {
 98                          Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.3.fill")
 99                              .foregroundStyle(.white)
100                              .font(.title2)
101                              .contentTransition(.symbolEffect(.automatic))
102                      }
103                      
104                  }
105                  .padding(.horizontal, 16)
106              }
107  
108              Slider(value: $currentTime, in: 0...duration) { editing in
109                  isDragging = editing
110                  if editing {
111                      // When we edit, we want to pause the video
112                      // If we were playing, we want to save that, and re-play when we are done
113                      previousPlayingState = isPlaying
114                      player.pause()
115                  } else {
116                      // If we were playing before editing, play now
117                      if previousPlayingState ?? false {
118                          isPlaying = true
119                          player.play()
120                      } else {
121                          // Else, we were paused, so don't play
122                          isPlaying = false
123                      }
124                      
125                      previousPlayingState = nil
126                      
127                  }
128              }
129              .onChange(of: currentTime) { _, newTime in
130                  if isDragging {
131                      player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 1000))
132                  }
133              }
134              .tint(.white)
135              .padding(.horizontal, 16)
136              .padding(.bottom, 15)
137  
138          }
139          
140      }
141      
142  }
143    
144  private func addTimeObserver() {
145      let interval = CMTime(seconds: 1/100, preferredTimescale: 1000)
146      player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
147          currentTime = time.seconds
148      }
149  }
150  
151  private func formatTimeLeft(_ time: Double) -> String {
152      let minutes = Int(time) / 60
153      let seconds = Int(time) % 60
154      return String(format: "%02d:%02d", minutes, seconds)
155  }
156  
157  private func formatTimeElapsed(_ time: Double) -> String {
158      let minutes = (Int(time) % 3600) / 60
159      let seconds = Int(time) % 60
160      let milliseconds = Int((time - floor(time)) * 100)
161      
162      return String(format: "%02d:%02d:%02d", minutes, seconds, milliseconds)
163  }
164  
165  static func formatDateForPlayer(_ date: Date) -> String {
166      let formatter = DateFormatter()
167      formatter.dateStyle = .medium  // Example: Jan 1, 2024
168      formatter.timeStyle = .none
169      return formatter.string(from: date)
170  }
171}

And here we are, a custom Video Player. To make it more in line with the Photos app, we could also make a custom Slider to get rid of the thumb, but that’s for another time.

targets

Let’s use this in our Gallery View along with the @Namespace property wrapper to create a zoom transition between the VideoItem and VideoPlayer.

 1// ContentView.swift
 2
 3@Namespace private var namespace
 4
 5...
 6
 7ScrollView(.vertical) {
 8  GeometryReader { geo in
 9      LazyVGrid(columns: columns) {
10          ForEach(videos, id: \.self) { vid in
11              NavigationLink {
12                  VideoPlayer(video: vid)
13                      .navigationTransition(.zoom(sourceID: vid.id, in: namespace))
14              } label: {
15                  VideoItem(thumbnailFileName: vid.thumbnailFileName, duration: vid.duration, size: (geo.size.width - 50)/3, fontSize: 12)
16                      .matchedTransitionSource(id: vid.id, in: namespace)
17              }
18              
19          }
20      }
21      .padding()
22  }
23}

targets

Final Product #

And there we have it, a video gallery with ability to add Videos using PhotosPicker and a custom VideoPlayer. Still working on refining it but it should be good for anyone who wants to use it as a base for their own work. Below is a video of the final product and I’m also linking the GitHub with full source code. GitHub: https://github.com/DakshinD/VideoGallery

targets

Files:

  • ContentView.swift - contains the grid
  • VideoPlayer.swift + VideoPlayerView.swift - Custom video player
  • Video.swift - contains the VideoItem and SwiftData model