FeedView.swift
30815 bytes
1import SwiftUI
2
3#if os(iOS)
4import UIKit
5#endif
6
7struct FeedView: View {
8 let settings: SettingsStore
9 @Binding var isSearchVisible: Bool
10
11 @Namespace private var glassNamespace
12 @State private var videos: [Video] = []
13 @State private var isLoading = false
14 @State private var isFetching = false
15 @State private var isAddVideoPresented = false
16 @State private var searchText = ""
17 @State private var alertMessage: String?
18
19 var body: some View {
20 GlassEffectContainer {
21 List {
22 if isLoading && videos.isEmpty {
23 ProgressView()
24 .padding(32)
25 } else if videos.isEmpty {
26 ContentUnavailableView(
27 "No unwatched videos",
28 systemImage: "play.rectangle",
29 description: Text("Refresh the feed or fetch videos from the server.")
30 )
31 .frame(maxWidth: .infinity)
32 .padding(.vertical, 80)
33 } else if displayedVideos.isEmpty {
34 ContentUnavailableView.search(text: searchText)
35 .frame(maxWidth: .infinity)
36 .padding(.vertical, 80)
37 } else {
38 ForEach(displayedVideos) { video in
39 VideoCard(
40 video: video,
41 tint: .blue,
42 updateProgress: { progress in
43 try await updateProgress(for: video, to: progress)
44 },
45 downloadActions: VideoDownloadActions(
46 start: { format in
47 try await startDownload(for: video, format: format)
48 },
49 poll: {
50 try await pollDownload(for: video)
51 },
52 export: {
53 try await downloadFile(for: video)
54 }
55 ),
56 glassNamespace: glassNamespace
57 )
58 .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
59 .listRowSeparator(.hidden)
60 .listRowBackground(Color.clear)
61 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
62 Button(role: .destructive) {
63 markWatched(video)
64 } label: {
65 Label("Done", systemImage: "checkmark")
66 }
67 .tint(.blue)
68 }
69 }
70 }
71 }
72 .listStyle(.plain)
73 .scrollContentBackground(.hidden)
74 }
75 .toolbar {
76 if isSearchVisible {
77 ToolbarItem(placement: .topBarLeading) {
78 SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
79 }
80 } else {
81 ToolbarItemGroup(placement: .topBarTrailing) {
82 Button {
83 Task {
84 await reload()
85 }
86 } label: {
87 Image(systemName: "arrow.clockwise")
88 }
89 .accessibilityLabel("Refresh feed")
90 .disabled(isLoading || isFetching)
91
92 Button {
93 isAddVideoPresented = true
94 } label: {
95 Image(systemName: "plus")
96 }
97 .accessibilityLabel("Add video")
98 .disabled(isLoading || isFetching)
99
100 Button {
101 Task {
102 await fetchFromServer()
103 }
104 } label: {
105 if isFetching {
106 ProgressView()
107 } else {
108 Image(systemName: "arrow.down.circle")
109 }
110 }
111 .accessibilityLabel("Fetch videos from server")
112 .disabled(isLoading || isFetching)
113
114 NavigationLink {
115 SettingsView(settings: settings)
116 } label: {
117 Image(systemName: "gear")
118 }
119 .accessibilityLabel("Settings")
120 }
121 }
122 }
123 .sheet(isPresented: $isAddVideoPresented) {
124 AddVideoSheet { videoID in
125 try await addVideo(videoID)
126 }
127 }
128 .refreshable {
129 await reload()
130 }
131 .task(id: settings.serverURL + settings.token) {
132 await reload()
133 }
134 .alert(
135 "Could not load feed",
136 isPresented: Binding(
137 get: { alertMessage != nil },
138 set: { if !$0 { alertMessage = nil } }
139 )
140 ) {
141 Button("OK", role: .cancel) {}
142 } message: {
143 Text(alertMessage ?? "")
144 }
145 }
146
147 private var displayedVideos: [Video] {
148 guard !searchText.isEmpty else {
149 return videos
150 }
151
152 return videos.filter {
153 $0.title.localizedCaseInsensitiveContains(searchText) ||
154 $0.channelName.localizedCaseInsensitiveContains(searchText)
155 }
156 }
157
158 private func reload() async {
159 guard let configuration = settings.configuration else {
160 return
161 }
162
163 isLoading = true
164 defer { isLoading = false }
165
166 do {
167 videos = try await APIClient().fetchNewVideos(using: configuration)
168 } catch {
169 alertMessage = error.localizedDescription
170 }
171 }
172
173 private func fetchFromServer() async {
174 guard let configuration = settings.configuration else {
175 return
176 }
177
178 isFetching = true
179 defer { isFetching = false }
180
181 do {
182 try await APIClient().fetchVideos(using: configuration)
183 await reload()
184 } catch {
185 alertMessage = error.localizedDescription
186 }
187 }
188
189 private func addVideo(_ videoID: String) async throws {
190 guard let configuration = settings.configuration else {
191 throw APIError.invalidURL
192 }
193
194 try await APIClient().addCustomVideo(videoID: videoID, using: configuration)
195 await reload()
196 }
197
198 private func startDownload(for video: Video, format: Int) async throws {
199 guard let configuration = settings.configuration else {
200 throw APIError.invalidURL
201 }
202
203 try await APIClient().startVideoDownload(
204 videoID: video.videoID,
205 format: format,
206 using: configuration
207 )
208 }
209
210 private func pollDownload(for video: Video) async throws -> Video {
211 guard let configuration = settings.configuration else {
212 throw APIError.invalidURL
213 }
214
215 let updatedVideo = try await APIClient().fetchVideo(videoID: video.videoID, using: configuration)
216 if let index = videos.firstIndex(where: { $0.id == updatedVideo.id }) {
217 videos[index] = updatedVideo
218 }
219 return updatedVideo
220 }
221
222 private func downloadFile(for video: Video) async throws -> URL {
223 guard let configuration = settings.configuration else {
224 throw APIError.invalidURL
225 }
226
227 return try await APIClient().downloadVideoFile(videoID: video.videoID, using: configuration)
228 }
229
230 private func updateProgress(for video: Video, to progress: String) async throws {
231 guard let configuration = settings.configuration else {
232 return
233 }
234
235 let updatedVideo = try await APIClient().updateVideoProgress(
236 videoID: video.videoID,
237 progress: progress,
238 using: configuration
239 )
240 if let index = videos.firstIndex(where: { $0.id == updatedVideo.id }) {
241 videos[index] = updatedVideo
242 }
243 }
244
245 private func markWatched(_ video: Video) {
246 guard let configuration = settings.configuration,
247 let index = videos.firstIndex(where: { $0.id == video.id }) else {
248 return
249 }
250
251 videos.remove(at: index)
252
253 Task {
254 do {
255 try await APIClient().markWatched(videoID: video.videoID, using: configuration)
256 } catch {
257 if !videos.contains(where: { $0.id == video.id }) {
258 videos.insert(video, at: min(index, videos.count))
259 }
260 alertMessage = error.localizedDescription
261 }
262 }
263 }
264}
265
266private struct AddVideoSheet: View {
267 let addVideo: (String) async throws -> Void
268
269 @Environment(\.dismiss) private var dismiss
270 @State private var videoID = ""
271 @State private var isAdding = false
272 @State private var errorMessage: String?
273
274 var body: some View {
275 NavigationStack {
276 Form {
277 TextField("YouTube ID or URL", text: $videoID)
278 .textInputAutocapitalization(.never)
279 .autocorrectionDisabled()
280 .disabled(isAdding)
281 }
282 .navigationTitle("Add Video")
283 .toolbar {
284 ToolbarItem(placement: .cancellationAction) {
285 Button("Cancel") {
286 dismiss()
287 }
288 .disabled(isAdding)
289 }
290
291 ToolbarItem(placement: .confirmationAction) {
292 Button("Add") {
293 Task {
294 await submit()
295 }
296 }
297 .disabled(videoID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isAdding)
298 }
299 }
300 }
301 .interactiveDismissDisabled(isAdding)
302 .alert(
303 "Could not add video",
304 isPresented: Binding(
305 get: { errorMessage != nil },
306 set: { if !$0 { errorMessage = nil } }
307 )
308 ) {
309 Button("OK", role: .cancel) {}
310 } message: {
311 Text(errorMessage ?? "")
312 }
313 }
314
315 private func submit() async {
316 isAdding = true
317 defer { isAdding = false }
318
319 do {
320 try await addVideo(videoID)
321 dismiss()
322 } catch {
323 errorMessage = error.localizedDescription
324 }
325 }
326}
327
328
329struct SearchToolbarField: View {
330 @Binding var text: String
331 @Binding var isPresented: Bool
332
333 @FocusState private var isFocused: Bool
334
335 var body: some View {
336 HStack(spacing: 8) {
337 Image(systemName: "magnifyingglass")
338 .foregroundStyle(.secondary)
339
340 TextField("Search videos", text: $text)
341 .focused($isFocused)
342 .textInputAutocapitalization(.never)
343 .autocorrectionDisabled()
344
345 Button {
346 text = ""
347 isPresented = false
348 } label: {
349 Image(systemName: "xmark")
350 }
351 .accessibilityLabel("Close search")
352 }
353 .frame(width: 280)
354 .onAppear {
355 isFocused = true
356 }
357 }
358}
359
360struct VideoDownloadActions {
361 let start: (Int) async throws -> Void
362 let poll: () async throws -> Video
363 let export: () async throws -> URL
364}
365
366private struct ExportedVideo: Identifiable {
367 let url: URL
368
369 var id: URL { url }
370}
371
372private struct WatchProgressPicker: View {
373 let duration: Int
374 let updateProgress: (String) async throws -> Void
375
376 @Environment(\.dismiss) private var dismiss
377 @State private var selectedSeconds: Int
378 @State private var isSaving = false
379 @State private var errorMessage: String?
380
381 init(
382 progress: Int,
383 duration: Int,
384 updateProgress: @escaping (String) async throws -> Void
385 ) {
386 self.duration = max(duration, 0)
387 self.updateProgress = updateProgress
388 _selectedSeconds = State(initialValue: min(max(progress, 0), max(duration, 0)))
389 }
390
391 var body: some View {
392 NavigationStack {
393 HStack(spacing: 0) {
394 Picker("Hours", selection: hours) {
395 ForEach(0...maximumHours, id: \.self) { value in
396 Text("\(value) hours")
397 }
398 }
399 .pickerStyle(.wheel)
400
401 Picker("Minutes", selection: minutes) {
402 ForEach(0...59, id: \.self) { value in
403 Text("\(value) min")
404 }
405 }
406 .pickerStyle(.wheel)
407
408 Picker("Seconds", selection: seconds) {
409 ForEach(0...59, id: \.self) { value in
410 Text("\(value) sec")
411 }
412 }
413 .pickerStyle(.wheel)
414 }
415 .labelsHidden()
416 .navigationTitle("Watch Progress")
417 .toolbar {
418 ToolbarItem(placement: .cancellationAction) {
419 Button("Cancel") {
420 dismiss()
421 }
422 .disabled(isSaving)
423 }
424
425 ToolbarItem(placement: .confirmationAction) {
426 Button("Save") {
427 Task {
428 await save()
429 }
430 }
431 .disabled(isSaving)
432 }
433 }
434 }
435 .interactiveDismissDisabled(isSaving)
436 .alert(
437 "Could not update progress",
438 isPresented: Binding(
439 get: { errorMessage != nil },
440 set: { if !$0 { errorMessage = nil } }
441 )
442 ) {
443 Button("OK", role: .cancel) {}
444 } message: {
445 Text(errorMessage ?? "")
446 }
447 }
448
449 private var maximumHours: Int {
450 duration / 3_600
451 }
452
453 private var hours: Binding<Int> {
454 Binding(
455 get: { selectedSeconds / 3_600 },
456 set: { setSelection(hours: $0, minutes: selectedMinutes, seconds: selectedSecondsComponent) }
457 )
458 }
459
460 private var minutes: Binding<Int> {
461 Binding(
462 get: { selectedMinutes },
463 set: { setSelection(hours: selectedSeconds / 3_600, minutes: $0, seconds: selectedSecondsComponent) }
464 )
465 }
466
467 private var seconds: Binding<Int> {
468 Binding(
469 get: { selectedSecondsComponent },
470 set: { setSelection(hours: selectedSeconds / 3_600, minutes: selectedMinutes, seconds: $0) }
471 )
472 }
473
474 private var selectedMinutes: Int {
475 selectedSeconds % 3_600 / 60
476 }
477
478 private var selectedSecondsComponent: Int {
479 selectedSeconds % 60
480 }
481
482 private func setSelection(hours: Int, minutes: Int, seconds: Int) {
483 selectedSeconds = min(hours * 3_600 + minutes * 60 + seconds, duration)
484 }
485
486 private func save() async {
487 isSaving = true
488 defer { isSaving = false }
489
490 do {
491 try await updateProgress(formatDuration(selectedSeconds))
492 dismiss()
493 } catch {
494 errorMessage = error.localizedDescription
495 }
496 }
497}
498
499struct VideoCard: View {
500 let video: Video
501 let tint: Color
502 let updateProgress: (String) async throws -> Void
503 let downloadActions: VideoDownloadActions?
504 let glassNamespace: Namespace.ID
505
506 @Environment(\.openURL) private var openURL
507 @State private var isProgressPickerPresented = false
508 @State private var isDownloadFormatPickerPresented = false
509 @State private var isStartingDownload = false
510 @State private var isPollingDownload = false
511 @State private var isExportingDownload = false
512 @State private var exportedVideo: ExportedVideo?
513 @State private var downloadErrorMessage: String?
514
515 init(
516 video: Video,
517 tint: Color,
518 updateProgress: @escaping (String) async throws -> Void,
519 downloadActions: VideoDownloadActions? = nil,
520 glassNamespace: Namespace.ID
521 ) {
522 self.video = video
523 self.tint = tint
524 self.updateProgress = updateProgress
525 self.downloadActions = downloadActions
526 self.glassNamespace = glassNamespace
527
528 }
529
530 var body: some View {
531 VStack(alignment: .leading, spacing: 12) {
532 Button {
533 if let url = video.externalURL {
534 openURL(url)
535 }
536 } label: {
537 VStack(alignment: .leading, spacing: 16) {
538 VideoThumbnail(
539 url: video.thumbnailURL,
540 progress: video.progress,
541 duration: video.duration,
542 isShort: video.isShort
543 )
544 VideoDetails(video: video)
545 }
546 .frame(maxWidth: .infinity, alignment: .leading)
547 .contentShape(.rect)
548 }
549 .buttonStyle(.plain)
550 .accessibilityLabel("Open \(video.title) externally")
551
552 HStack(spacing: 12) {
553 if video.duration > 0 {
554 Button {
555 isProgressPickerPresented = true
556 } label: {
557 Image(systemName: "clock")
558 .frame(width: 16, height: 16)
559 }
560 .buttonStyle(.bordered)
561 .accessibilityLabel("Edit watch progress")
562 }
563
564 if downloadActions != nil {
565 downloadControl
566 }
567 }
568 }
569 .padding(16)
570 .glassEffect(
571 .regular.tint(tint.opacity(0.22)),
572 in: .rect(cornerRadius: 24)
573 )
574 .glassEffectID(video.id, in: glassNamespace)
575 .task(id: video.downloadStatus) {
576 guard video.downloadStatus == "pending" || video.downloadStatus == "downloading",
577 !isPollingDownload else {
578 return
579 }
580 await pollDownload()
581 }
582 .confirmationDialog(
583 "Choose download quality",
584 isPresented: $isDownloadFormatPickerPresented,
585 titleVisibility: .visible
586 ) {
587 ForEach([480, 720, 1080, 1440, 2160], id: \.self) { format in
588 Button("\(format)p") {
589 Task {
590 await startDownload(format: format)
591 }
592 }
593 }
594 Button("Cancel", role: .cancel) {}
595 }
596 .sheet(isPresented: $isProgressPickerPresented) {
597 WatchProgressPicker(
598 progress: video.progress,
599 duration: video.duration,
600 updateProgress: updateProgress
601 )
602 }
603 .sheet(item: $exportedVideo) { exportedVideo in
604 VideoExportSheet(fileURL: exportedVideo.url)
605 }
606 .alert(
607 "Could not download video",
608 isPresented: Binding(
609 get: { downloadErrorMessage != nil },
610 set: { if !$0 { downloadErrorMessage = nil } }
611 )
612 ) {
613 Button("OK", role: .cancel) {}
614 } message: {
615 Text(downloadErrorMessage ?? "")
616 }
617 }
618
619 @ViewBuilder
620 private var downloadControl: some View {
621 if isStartingDownload || isPollingDownload {
622 ProgressView()
623 .accessibilityLabel("Downloading \(video.title)")
624 } else if video.downloadStatus == "completed" {
625 Button {
626 Task {
627 await exportDownload()
628 }
629 } label: {
630 if isExportingDownload {
631 ProgressView()
632 } else {
633 Image(systemName: "square.and.arrow.up")
634 .frame(width: 16, height: 16)
635 }
636 }
637 .buttonStyle(.bordered)
638 .disabled(isExportingDownload)
639 .accessibilityLabel("Export downloaded video")
640 } else {
641 Button {
642 isDownloadFormatPickerPresented = true
643 } label: {
644 Image(systemName: video.downloadStatus == "failed" ? "arrow.clockwise" : "arrow.down.to.line")
645 .frame(width: 16, height: 16)
646 }
647 .buttonStyle(.bordered)
648 .accessibilityLabel(video.downloadStatus == "failed" ? "Retry download" : "Download video")
649 }
650
651 if video.downloadStatus == "pending" || video.downloadStatus == "downloading" {
652 Text(video.downloadStatus == "pending" ? "Preparing download" : "Downloading")
653 .font(.footnote)
654 .foregroundStyle(.secondary)
655 } else if video.downloadStatus == "failed", let downloadError = video.downloadError {
656 Text(downloadError)
657 .font(.footnote)
658 .foregroundStyle(.red)
659 .lineLimit(2)
660 }
661 }
662
663
664 private func startDownload(format: Int) async {
665 guard let downloadActions else {
666 return
667 }
668
669 isStartingDownload = true
670 defer { isStartingDownload = false }
671
672 do {
673 try await downloadActions.start(format)
674 await pollDownload()
675 } catch {
676 downloadErrorMessage = error.localizedDescription
677 }
678 }
679
680 private func pollDownload() async {
681 guard let downloadActions, !isPollingDownload else {
682 return
683 }
684
685 isPollingDownload = true
686 defer { isPollingDownload = false }
687
688 do {
689 while !Task.isCancelled {
690 let updatedVideo = try await downloadActions.poll()
691
692 switch updatedVideo.downloadStatus {
693 case "completed":
694 await exportDownload()
695 return
696 case "failed":
697 return
698 default:
699 try await Task.sleep(for: .seconds(2))
700 }
701 }
702 } catch is CancellationError {
703 } catch {
704 downloadErrorMessage = error.localizedDescription
705 }
706 }
707
708 private func exportDownload() async {
709 guard let downloadActions, !isExportingDownload else {
710 return
711 }
712
713 isExportingDownload = true
714 defer { isExportingDownload = false }
715
716 do {
717 exportedVideo = ExportedVideo(url: try await downloadActions.export())
718 } catch {
719 downloadErrorMessage = error.localizedDescription
720 }
721 }
722}
723
724private struct VideoExportSheet: View {
725 let fileURL: URL
726
727 var body: some View {
728 #if os(iOS)
729 ActivityView(activityItems: [fileURL])
730 #else
731 VStack(spacing: 16) {
732 Text("Export Download")
733 .font(.headline)
734 ShareLink(item: fileURL, preview: SharePreview(fileURL.lastPathComponent)) {
735 Label("Share or Save", systemImage: "square.and.arrow.up")
736 }
737 }
738 .padding()
739 #endif
740 }
741}
742
743#if os(iOS)
744private struct ActivityView: UIViewControllerRepresentable {
745 let activityItems: [Any]
746
747 func makeUIViewController(context: Context) -> UIActivityViewController {
748 UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
749 }
750
751 func updateUIViewController(_ viewController: UIActivityViewController, context: Context) {}
752}
753#endif
754
755private struct VideoThumbnail: View {
756 let url: URL?
757 let progress: Int
758 let duration: Int
759 let isShort: Bool
760
761 private var progressFraction: Double {
762 guard duration > 0, progress > 0 else {
763 return 0
764 }
765 return min(max(Double(progress) / Double(duration), 0), 1)
766 }
767
768 var body: some View {
769 GeometryReader { geometry in
770 ZStack(alignment: .bottomLeading) {
771 AsyncImage(url: url) { phase in
772 switch phase {
773 case .empty:
774 Rectangle()
775 .fill(.quaternary)
776 .overlay { ProgressView() }
777 case let .success(image):
778 image
779 .resizable()
780 .scaledToFill()
781 case .failure:
782 Rectangle()
783 .fill(.quaternary)
784 .overlay {
785 Image(systemName: "play.rectangle")
786 .font(.title2)
787 .foregroundStyle(.secondary)
788 }
789 @unknown default:
790 EmptyView()
791 }
792 }
793 .frame(width: geometry.size.width, height: geometry.size.height)
794 .clipped()
795
796 if progressFraction > 0 {
797 Rectangle()
798 .fill(Color(red: 0.69, green: 0.07, blue: 0.07))
799 .frame(width: geometry.size.width * progressFraction, height: 6)
800 }
801
802 if isShort {
803 ShortsIcon()
804 .fill(.white)
805 .frame(width: 32, height: 32)
806 .shadow(color: .black.opacity(0.65), radius: 2)
807 .padding(6)
808 .frame(
809 maxWidth: .infinity,
810 maxHeight: .infinity,
811 alignment: .bottomLeading
812 )
813 }
814
815 Text(formatDuration(duration))
816 .font(.caption2.monospacedDigit().weight(.semibold))
817 .foregroundStyle(.white)
818 .padding(.horizontal, 6)
819 .padding(.vertical, 3)
820 .background(.black.opacity(0.65), in: .rect(cornerRadius: 6))
821 .padding(6)
822 .frame(
823 maxWidth: .infinity,
824 maxHeight: .infinity,
825 alignment: .bottomTrailing
826 )
827 }
828 .clipped()
829 }
830 .aspectRatio(16 / 9, contentMode: .fit)
831 .frame(maxWidth: .infinity)
832 .clipShape(.rect(cornerRadius: 16))
833 }
834}
835
836struct ShortsIcon: Shape {
837 func path(in rect: CGRect) -> Path {
838 var path = Path()
839 path.move(to: CGPoint(x: 13.974, y: 2.052))
840 path.addLine(to: CGPoint(x: 5.974, y: 6.752))
841 path.addCurve(
842 to: CGPoint(x: 6.359, y: 13.849),
843 control1: CGPoint(x: 2.31, y: 8.906),
844 control2: CGPoint(x: 2.44, y: 12.65)
845 )
846 path.addLine(to: CGPoint(x: 7.301, y: 14.272))
847 path.addLine(to: CGPoint(x: 5.974, y: 15.052))
848 path.addCurve(
849 to: CGPoint(x: 10.026, y: 21.949),
850 control1: CGPoint(x: 2.31, y: 17.206),
851 control2: CGPoint(x: 5.91, y: 24.1)
852 )
853 path.addLine(to: CGPoint(x: 18.026, y: 17.249))
854 path.addCurve(
855 to: CGPoint(x: 17.642, y: 10.153),
856 control1: CGPoint(x: 21.69, y: 15.095),
857 control2: CGPoint(x: 21.56, y: 11.352)
858 )
859 path.addLine(to: CGPoint(x: 16.7, y: 9.73))
860 path.addLine(to: CGPoint(x: 18.026, y: 8.95))
861 path.addCurve(
862 to: CGPoint(x: 13.974, y: 2.052),
863 control1: CGPoint(x: 21.69, y: 6.796),
864 control2: CGPoint(x: 18.09, y: -0.1)
865 )
866 path.closeSubpath()
867
868 path.move(to: CGPoint(x: 10, y: 15))
869 path.addLine(to: CGPoint(x: 10, y: 9))
870 path.addLine(to: CGPoint(x: 15, y: 12))
871 path.closeSubpath()
872
873 let scale = min(rect.width, rect.height) / 24
874 let offsetX = rect.midX - 12 * scale
875 let offsetY = rect.midY - 12 * scale
876 return path.applying(CGAffineTransform(scaleX: scale, y: scale).translatedBy(
877 x: offsetX / scale,
878 y: offsetY / scale
879 ))
880 }
881}
882
883private struct VideoDetails: View {
884 let video: Video
885
886 var body: some View {
887 VStack(alignment: .leading, spacing: 8) {
888 Text(video.title)
889 .font(.headline)
890
891 if video.isLive {
892 VideoBadge(title: "LIVE", tint: .red)
893 }
894
895 HStack {
896 Text(video.channelName)
897 .lineLimit(1)
898
899 Spacer(minLength: 12)
900
901 Text(video.publishedTimestamp.formatted(.relative(presentation: .named)))
902 .lineLimit(1)
903 }
904 .font(.footnote)
905 .foregroundStyle(.secondary)
906
907 }
908 }
909}
910
911private struct VideoBadge: View {
912 let title: String
913 let tint: Color
914
915 var body: some View {
916 Text(title)
917 .font(.caption2.weight(.semibold))
918 .foregroundStyle(tint)
919 .padding(.horizontal, 6)
920 .padding(.vertical, 3)
921 .background(tint.opacity(0.14), in: .capsule)
922 }
923}
924
925private extension Video {
926 var thumbnailURL: URL? {
927 var components = URLComponents()
928 components.scheme = "https"
929 components.host = "i.ytimg.com"
930 components.path = "/vi/\(videoID)/hqdefault.jpg"
931 return components.url
932 }
933
934 var externalURL: URL? {
935 var components = URLComponents(string: "https://www.youtube.com/watch")
936 components?.queryItems = [
937 URLQueryItem(name: "v", value: videoID),
938 URLQueryItem(name: "t", value: "\(max(progress, 0))s"),
939 ]
940 return components?.url
941 }
942}
943
944private func formatDuration(_ seconds: Int) -> String {
945 let totalSeconds = max(seconds, 0)
946 let hours = totalSeconds / 3_600
947 let minutes = totalSeconds % 3_600 / 60
948 let remainingSeconds = totalSeconds % 60
949
950 if hours > 0 {
951 return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds)
952 }
953 return String(format: "%d:%02d", minutes, remainingSeconds)
954}