Parent directory

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}