Last active
October 8, 2025 08:47
-
-
Save luthviar/98cb06d5453acf79c391b7b4849daffe to your computer and use it in GitHub Desktop.
VideoClipsPlayerScreen.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // ContentView26Playback.swift | |
| // Coba14July2025 | |
| // | |
| // Created by Luthfi Abdurrahim on 08/10/25. | |
| // | |
| import UIKit | |
| import AVFoundation | |
| /// A simple AVPlayer-backed UIView so the demo runs without the Tuya SDK. | |
| /// If you have Tuya’s UIView, you can ignore this file and inject your view via PlayerContainer. | |
| // MARK: - Legacy Demo Video Player (still used by AVFoundationPlaybackBackend) | |
| final class VideoPlayerUIView: UIView { | |
| override static var layerClass: AnyClass { AVPlayerLayer.self } | |
| private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } | |
| private(set) var player: AVPlayer? | |
| private var timeObserverToken: Any? | |
| /// Called periodically with (currentTime, duration) | |
| var onTimeUpdate: ((CMTime, CMTime?) -> Void)? | |
| var onStatusUpdate: ((Bool) -> Void)? // playing state | |
| // Loading & error callbacks | |
| var onLoadingChanged: ((Bool) -> Void)? | |
| var onError: ((Error) -> Void)? | |
| // KVO observations we must retain | |
| private var kvoObservations: [NSKeyValueObservation] = [] | |
| deinit { | |
| removeObservers() | |
| } | |
| func load(url: URL, autoplay: Bool = true) { | |
| removeObservers() | |
| let item = AVPlayerItem(url: url) | |
| let player = AVPlayer(playerItem: item) | |
| player.automaticallyWaitsToMinimizeStalling = true | |
| self.player = player | |
| playerLayer.videoGravity = .resizeAspect | |
| playerLayer.player = player | |
| // Notify loading start | |
| DispatchQueue.main.async { [weak self] in self?.onLoadingChanged?(true) } | |
| // Observe item status to know when ready / failed | |
| let statusObs = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in | |
| guard let self else { return } | |
| switch item.status { | |
| case .readyToPlay: | |
| DispatchQueue.main.async { self.onLoadingChanged?(false) } | |
| if autoplay { self.play() } else { self.pause() } | |
| case .failed: | |
| let err = item.error ?? NSError(domain: "VideoPlayerUIView", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown playback error"]) | |
| DispatchQueue.main.async { | |
| self.onLoadingChanged?(false) | |
| self.onError?(err) | |
| } | |
| default: | |
| break | |
| } | |
| } | |
| kvoObservations.append(statusObs) | |
| // Optional: observe likelyToKeepUp to reflect buffering after start | |
| let playbackLikelyObs = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in | |
| guard let self else { return } | |
| // When playback becomes likely to keep up, ensure loading = false | |
| if item.isPlaybackLikelyToKeepUp { | |
| DispatchQueue.main.async { self.onLoadingChanged?(false) } | |
| } | |
| } | |
| kvoObservations.append(playbackLikelyObs) | |
| addObservers() | |
| } | |
| func play() { | |
| guard let player else { return } | |
| player.play() | |
| // Defer status publish to next runloop to avoid publishing during view updates | |
| DispatchQueue.main.async { [weak self] in | |
| self?.onStatusUpdate?(true) | |
| } | |
| } | |
| func pause() { | |
| player?.pause() | |
| // Defer status publish to next runloop to avoid publishing during view updates | |
| DispatchQueue.main.async { [weak self] in | |
| self?.onStatusUpdate?(false) | |
| } | |
| } | |
| func setRate(_ rate: Float) { | |
| guard let player else { return } | |
| // Ensure playback is active when changing rate | |
| player.playImmediately(atRate: rate) | |
| // Defer status publish to next runloop to avoid publishing during view updates | |
| let isPlaying = player.rate > 0 | |
| DispatchQueue.main.async { [weak self] in | |
| self?.onStatusUpdate?(isPlaying) | |
| } | |
| } | |
| func seek(to seconds: Double, completion: ((Bool) -> Void)? = nil) { | |
| guard seconds.isFinite, seconds >= 0, let player = player else { | |
| completion?(false) | |
| return | |
| } | |
| let time = CMTime(seconds: seconds, preferredTimescale: 600) | |
| player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { finished in | |
| completion?(finished) | |
| } | |
| } | |
| var durationSeconds: Double { | |
| guard let dur = player?.currentItem?.duration, dur.isNumeric else { return 0 } | |
| return CMTimeGetSeconds(dur) | |
| } | |
| var currentSeconds: Double { | |
| guard let cur = player?.currentTime(), cur.isNumeric else { return 0 } | |
| return CMTimeGetSeconds(cur) | |
| } | |
| private func addObservers() { | |
| guard let player else { return } | |
| let interval = CMTime(seconds: 0.25, preferredTimescale: 600) | |
| timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in | |
| guard let self else { return } | |
| self.onTimeUpdate?(time, player.currentItem?.duration) | |
| } | |
| // Observe end of playback to reset UI state (optional) | |
| NotificationCenter.default.addObserver(self, selector: #selector(itemDidFinish), | |
| name: .AVPlayerItemDidPlayToEndTime, | |
| object: player.currentItem) | |
| } | |
| private func removeObservers() { | |
| if let token = timeObserverToken { | |
| player?.removeTimeObserver(token) | |
| timeObserverToken = nil | |
| } | |
| NotificationCenter.default.removeObserver(self) | |
| kvoObservations.forEach { $0.invalidate() } | |
| kvoObservations.removeAll() | |
| } | |
| @objc private func itemDidFinish() { | |
| // Reset to start and pause | |
| seek(to: 0) { [weak self] _ in | |
| self?.pause() | |
| } | |
| } | |
| } | |
| import SwiftUI | |
| import UIKit | |
| /// Simplified container that just hosts the backend's provided view. | |
| struct PlayerContainer: UIViewRepresentable { | |
| let backend: PlaybackBackend | |
| func makeUIView(context: Context) -> UIView { backend.view } | |
| func updateUIView(_ uiView: UIView, context: Context) { /* state handled through backend */ } | |
| } | |
| import Foundation | |
| import SwiftUI | |
| import AVFoundation | |
| final class PlaybackViewModel: ObservableObject { | |
| struct Clip: Identifiable, Equatable { | |
| let id = UUID() | |
| let timeLabel: String | |
| let url: URL | |
| } | |
| // MARK: - Published UI State | |
| @Published var isPlaying: Bool = false | |
| @Published var rate: Float = 1.0 | |
| @Published var current: Double = 0 | |
| @Published var duration: Double = 0 | |
| @Published var showGrid: Bool = true | |
| @Published var selectedClip: Clip? | |
| @Published var isLoading: Bool = false | |
| @Published var errorMessage: String? = nil | |
| // Grid data (all pointing to the sample stream) | |
| let clips: [Clip] | |
| // Abstracted backend (AVFoundation or Tuya) | |
| private let backend: PlaybackBackend | |
| // MARK: - Init | |
| init(backend: PlaybackBackend) { | |
| self.backend = backend | |
| let sample = URL(string: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")! | |
| let times = ["06:17","06:20","06:22","06:22","06:28","06:29","06:29","06:31","06:32","06:36","06:38"] | |
| self.clips = times.map { Clip(timeLabel: $0, url: sample) } | |
| backend.delegate = self | |
| // Autoload first clip | |
| if let first = clips.first { | |
| selectedClip = first | |
| backend.load(url: first.url, autoplay: true) | |
| } | |
| } | |
| // MARK: - If you use Tuya UIView, bind it here instead | |
| // func bindTuya(view: UIView) { | |
| // // Set delegates and map these methods (play, pause, seek, setRate) to Tuya SDK calls. | |
| // } | |
| // MARK: - Intents | |
| func togglePlayPause() { | |
| isPlaying ? pause() : play() | |
| } | |
| func play() { | |
| backend.play() | |
| applyRate(rate) | |
| } | |
| func pause() { | |
| backend.pause() | |
| } | |
| func applyRate(_ newRate: Float) { | |
| rate = newRate | |
| backend.setRate(newRate) | |
| } | |
| func cycleRate() { | |
| // Simple toggle 1x <-> 2x | |
| applyRate(rate == 1.0 ? 2.0 : 1.0) | |
| } | |
| func seekProgress(to progress: Double) { | |
| guard duration > 0 else { return } | |
| let seconds = progress * duration | |
| backend.seek(seconds: seconds) | |
| } | |
| func selectClip(_ clip: Clip) { | |
| selectedClip = clip | |
| errorMessage = nil | |
| backend.load(url: clip.url, autoplay: true) | |
| current = 0 | |
| } | |
| func retryCurrentClip() { | |
| guard let clip = selectedClip else { return } | |
| selectClip(clip) | |
| } | |
| // MARK: - Formatters | |
| func formattedTime(_ seconds: Double) -> String { | |
| guard seconds.isFinite, seconds >= 0 else { return "00:00" } | |
| let s = Int(seconds.rounded()) | |
| let mm = s / 60 | |
| let ss = s % 60 | |
| return String(format: "%02d:%02d", mm, ss) | |
| } | |
| } | |
| // MARK: - PlaybackBackendDelegate | |
| extension PlaybackViewModel: PlaybackBackendDelegate { | |
| func backend(_ backend: PlaybackBackend, didUpdateTime info: PlaybackTimeInfo) { | |
| DispatchQueue.main.async { | |
| self.current = info.current | |
| self.duration = info.duration | |
| } | |
| } | |
| func backend(_ backend: PlaybackBackend, didChangeState state: PlaybackState) { | |
| DispatchQueue.main.async { | |
| switch state { | |
| case .idle: | |
| self.isLoading = false | |
| self.isPlaying = false | |
| case .loading: | |
| self.isLoading = true | |
| case .playing: | |
| self.isLoading = false | |
| self.isPlaying = true | |
| self.errorMessage = nil | |
| case .paused: | |
| self.isLoading = false | |
| self.isPlaying = false | |
| case .ended: | |
| self.isPlaying = false | |
| case .failed(let failure): | |
| self.isPlaying = false | |
| self.isLoading = false | |
| self.errorMessage = failure.message | |
| } | |
| } | |
| } | |
| func backend(_ backend: PlaybackBackend, didChangeRate rate: Float) { | |
| DispatchQueue.main.async { self.rate = rate } | |
| } | |
| } | |
| import SwiftUI | |
| struct PlaybackScreen: View { | |
| // Switch backend here: | |
| // let backend = TuyaPlaybackBackend() // when integrating Tuya | |
| private var backend: PlaybackBackend = AVFoundationPlaybackBackend() | |
| @StateObject private var vm: PlaybackViewModel | |
| private let columns = Array(repeating: GridItem(.flexible(), spacing: 14), count: 3) | |
| // MARK: - Control Visibility State | |
| @State private var controlsVisible: Bool = true // Whether overlay controls are currently visible | |
| @State private var hideWorkItem: DispatchWorkItem? // Debounced auto-hide work item | |
| @State private var isScrubbing: Bool = false // True while user is dragging the seek slider | |
| @State private var showRateSheet: Bool = false // Bottom sheet for playback speeds | |
| // Playback speed options (expanded professional list) | |
| private let speedOptions: [Float] = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 8.0, 16.0, 32.0] | |
| init() { | |
| let backend = AVFoundationPlaybackBackend() | |
| _vm = StateObject(wrappedValue: PlaybackViewModel(backend: backend)) | |
| self.backend = backend | |
| } | |
| // To integrate Tuya: | |
| // 1. Instantiate `let backend = TuyaPlaybackBackend()` instead of AVFoundationPlaybackBackend. | |
| // 2. Ensure `TuyaPlaybackBackend` wraps the real Tuya view & player logic and emits delegate callbacks. | |
| // 3. Optionally gate with `#if canImport(TuyaSmartCamera)` for conditional compilation. | |
| // 4. No other UI changes required – abstraction handles the rest. | |
| var body: some View { | |
| VStack(spacing: 0) { | |
| topBar | |
| GeometryReader { geo in | |
| VStack(spacing: 12) { | |
| ZStack { | |
| PlayerContainer(backend: backend) | |
| .frame(height: min(geo.size.width * 9/16, 300)) | |
| .background(Color.black) | |
| .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) | |
| // Tap anywhere on the video to reveal controls immediately | |
| .contentShape(Rectangle()) | |
| .onTapGesture { | |
| showControlsAndReschedule() | |
| } | |
| .overlay( | |
| // Only render overlay when we want it visible; fade in/out for accessibility & perf | |
| Group { | |
| if controlsVisible { playerOverlay } | |
| } | |
| .transition(.opacity.animation(.easeInOut(duration: 0.3))) | |
| .accessibilityHidden(!controlsVisible), | |
| alignment: .bottom | |
| ) | |
| // Center Play/Pause | |
| Button(action: { | |
| showControlsAndReschedule() | |
| vm.togglePlayPause() | |
| }) { | |
| Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") | |
| .font(.system(size: 56, weight: .regular)) | |
| .foregroundStyle(.white) | |
| .shadow(radius: 6) | |
| .accessibilityLabel(vm.isPlaying ? "Pause" : "Play") | |
| } | |
| .buttonStyle(.plain) | |
| .opacity(controlsVisible ? 0.9 : 0) // Hide play/pause button with other controls | |
| .animation(.easeInOut(duration: 0.3), value: controlsVisible) | |
| // Loading overlay | |
| if vm.isLoading { | |
| ProgressView() | |
| .progressViewStyle(.circular) | |
| .scaleEffect(1.4) | |
| .tint(.white) | |
| .padding(24) | |
| .background(.black.opacity(0.4)) | |
| .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) | |
| .accessibilityLabel("Loading video") | |
| } | |
| // Error overlay | |
| if let err = vm.errorMessage { | |
| VStack(spacing: 12) { | |
| Image(systemName: "exclamationmark.triangle.fill") | |
| .font(.system(size: 32)) | |
| .foregroundStyle(.yellow) | |
| Text(err) | |
| .font(.footnote) | |
| .multilineTextAlignment(.center) | |
| .foregroundStyle(.white) | |
| .lineLimit(3) | |
| HStack(spacing: 16) { | |
| Button("Retry") { vm.retryCurrentClip() } | |
| .buttonStyle(.borderedProminent) | |
| Button("Dismiss") { vm.errorMessage = nil } | |
| .buttonStyle(.bordered) | |
| } | |
| } | |
| .padding(20) | |
| .background(.black.opacity(0.65)) | |
| .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) | |
| .padding() | |
| .transition(.opacity.combined(with: .scale)) | |
| .accessibilityElement(children: .combine) | |
| .accessibilityLabel("Video failed to load. Retry or dismiss.") | |
| } | |
| } | |
| clipsSection | |
| } | |
| .padding(.horizontal, 16) | |
| } | |
| } | |
| .background(Color(UIColor.systemBackground)) | |
| .ignoresSafeArea(edges: .bottom) | |
| .onAppear { scheduleAutoHide() } | |
| // Reschedule auto-hide based on playback state | |
| .onChange(of: vm.isPlaying) { playing in | |
| if playing { | |
| scheduleAutoHide() | |
| } else { | |
| // Keep controls visible while paused (industry UX expectation) | |
| cancelAutoHide(show: true) | |
| } | |
| } | |
| // Reshow controls when rate changes (user interaction from sheet) | |
| .onChange(of: vm.rate) { _ in showControlsAndReschedule() } | |
| // Bottom sheet for playback speed options | |
| .sheet(isPresented: $showRateSheet, onDismiss: { scheduleAutoHide() }) { | |
| RateSelectionSheet(current: vm.rate, options: speedOptions) { selected in | |
| provideHaptic() | |
| vm.applyRate(selected) | |
| // Dismiss and schedule hide after user picks a speed | |
| showRateSheet = false | |
| } | |
| .presentationDetentsIfAvailable() | |
| } | |
| } | |
| private var topBar: some View { | |
| HStack(spacing: 12) { | |
| Button(action: { /* Handle back navigation */ }) { | |
| Image(systemName: "chevron.left") | |
| .font(.system(size: 18, weight: .semibold)) | |
| } | |
| .buttonStyle(.plain) | |
| Text("Ezcam") | |
| .font(.headline) | |
| Spacer() | |
| } | |
| .padding(.horizontal, 16) | |
| .padding(.vertical, 12) | |
| } | |
| private var playerOverlay: some View { | |
| VStack(spacing: 8) { | |
| // Bottom Info Row: time and speed chip | |
| HStack { | |
| Text("\(vm.formattedTime(vm.current)) / \(vm.formattedTime(vm.duration))") | |
| .font(.caption) | |
| .foregroundStyle(.white) | |
| .padding(.horizontal, 8) | |
| .padding(.vertical, 4) | |
| .background(.black.opacity(0.35)) | |
| .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) | |
| Spacer() | |
| Button(action: { | |
| // Reveal controls and show sheet (prevent auto-hide while sheet open) | |
| cancelAutoHide(show: true) | |
| showRateSheet = true | |
| }) { | |
| HStack(spacing: 4) { | |
| Text(String(format: "%.1fx", vm.rate)) | |
| .font(.caption.bold()) | |
| Image(systemName: "chevron.up.chevron.down") | |
| .font(.caption2) | |
| } | |
| .foregroundStyle(.white) | |
| .padding(.horizontal, 10) | |
| .padding(.vertical, 6) | |
| .background(.black.opacity(0.45)) | |
| .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) | |
| .accessibilityLabel("Playback speed. Current \(String(format: "%.1f", vm.rate)) X. Double tap to change.") | |
| } | |
| .buttonStyle(.plain) | |
| } | |
| // Seek Slider | |
| SeekSlider(progress: vm.duration > 0 ? vm.current / vm.duration : 0, | |
| onEditingChanged: { editing in | |
| if editing { | |
| // User started scrubbing – keep controls visible | |
| isScrubbing = true | |
| cancelAutoHide(show: true) | |
| } else { | |
| // User finished scrubbing | |
| isScrubbing = false | |
| scheduleAutoHide() | |
| } | |
| }, | |
| onCommit: { p in | |
| vm.seekProgress(to: p) | |
| showControlsAndReschedule() | |
| }) | |
| } | |
| .padding(10) | |
| .background( | |
| LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.55)], | |
| startPoint: .top, endPoint: .bottom) | |
| .ignoresSafeArea() | |
| ) | |
| } | |
| private var clipsSection: some View { | |
| VStack(alignment: .leading, spacing: 12) { | |
| HStack { | |
| Text("\(vm.clips.count) Klip Total") | |
| .font(.subheadline.weight(.semibold)) | |
| .foregroundStyle(Color.primary.opacity(0.8)) | |
| Spacer() | |
| Button { | |
| withAnimation { vm.showGrid.toggle() } | |
| } label: { | |
| Image(systemName: "xmark.circle.fill") | |
| .font(.title3) | |
| .foregroundStyle(.secondary) | |
| } | |
| .buttonStyle(.plain) | |
| } | |
| if vm.showGrid { | |
| ScrollView { | |
| LazyVGrid(columns: columns, spacing: 14) { | |
| ForEach(vm.clips) { clip in | |
| ClipTile(clip: clip, | |
| isSelected: vm.selectedClip == clip, | |
| isPlaying: vm.isPlaying && vm.selectedClip == clip) { | |
| showControlsAndReschedule() | |
| if vm.selectedClip == clip { | |
| // If same clip tapped and not loading, toggle | |
| if !vm.isLoading && vm.errorMessage == nil { | |
| vm.togglePlayPause() | |
| } | |
| } else { | |
| vm.selectClip(clip) | |
| } | |
| } | |
| } | |
| } | |
| .padding(.bottom, 16) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - Subviews | |
| private struct SeekSlider: View { | |
| @State private var dragProgress: Double = 0 | |
| @State private var isDragging = false | |
| let progress: Double | |
| let onEditingChanged: (Bool) -> Void | |
| let onCommit: (Double) -> Void | |
| var body: some View { | |
| Slider(value: Binding( | |
| get: { isDragging ? dragProgress : progress }, | |
| set: { newVal in | |
| isDragging = true | |
| dragProgress = newVal | |
| onEditingChanged(true) | |
| } | |
| ), in: 0...1, onEditingChanged: { editing in | |
| if !editing { | |
| isDragging = false | |
| onEditingChanged(false) | |
| onCommit(dragProgress) | |
| } | |
| }) | |
| .tint(.white) | |
| .accessibilityLabel("Video progress slider") | |
| .accessibilityValue(String(format: "%d percent", Int((isDragging ? dragProgress : progress) * 100))) | |
| } | |
| } | |
| private struct ClipTile: View { | |
| let clip: PlaybackViewModel.Clip | |
| let isSelected: Bool | |
| let isPlaying: Bool | |
| let action: () -> Void | |
| var body: some View { | |
| Button(action: action) { | |
| ZStack(alignment: .bottomLeading) { | |
| RoundedRectangle(cornerRadius: 12, style: .continuous) | |
| .fill(LinearGradient(colors: [Color.gray.opacity(0.45), Color.gray.opacity(0.2)], | |
| startPoint: .top, endPoint: .bottom)) | |
| .frame(height: 78) | |
| .overlay( | |
| Image(systemName: isSelected && isPlaying ? "pause.fill" : "play.fill") | |
| .font(.title2) | |
| .foregroundStyle(.white) | |
| .opacity(0.9) | |
| ) | |
| Text(clip.timeLabel) | |
| .font(.caption2.weight(.semibold)) | |
| .foregroundStyle(.white) | |
| .padding(6) | |
| .background(.black.opacity(0.35)) | |
| .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) | |
| .padding(8) | |
| } | |
| } | |
| .buttonStyle(.plain) | |
| .overlay( | |
| RoundedRectangle(cornerRadius: 12) | |
| .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) | |
| ) | |
| } | |
| } | |
| // MARK: - Rate Selection Bottom Sheet | |
| /// A reusable bottom sheet for selecting playback speed. | |
| private struct RateSelectionSheet: View { | |
| let current: Float | |
| let options: [Float] | |
| let onSelect: (Float) -> Void | |
| var body: some View { | |
| VStack(alignment: .leading, spacing: 12) { | |
| Text("Playback Speed") | |
| .font(.headline) | |
| .padding(.top, 8) | |
| Text(String(format: "Current: %.1fx", current)) | |
| .font(.subheadline.weight(.semibold)) | |
| .foregroundStyle(.secondary) | |
| .accessibilityLabel(String(format: "Current speed %.1f X", current)) | |
| Divider() | |
| ScrollView { | |
| LazyVStack(alignment: .leading, spacing: 4) { | |
| ForEach(options, id: \.self) { speed in | |
| Button(action: { onSelect(speed) }) { | |
| HStack { | |
| Text(String(format: speed.truncatingRemainder(dividingBy: 1) == 0 ? "%.0fx" : "%.1fx", speed)) | |
| .font(.body) | |
| Spacer() | |
| if speed == current { Image(systemName: "checkmark").font(.body.bold()) } | |
| } | |
| .padding(.vertical, 10) | |
| .contentShape(Rectangle()) | |
| } | |
| .buttonStyle(.plain) | |
| .accessibilityLabel(String(format: "Speed %.1f X", speed)) | |
| .accessibilityHint(speed == current ? "Selected" : "Double tap to switch to this speed") | |
| Divider() | |
| } | |
| } | |
| } | |
| } | |
| .padding(.horizontal, 20) | |
| .padding(.bottom, 12) | |
| .presentationDragIndicator(.visible) | |
| } | |
| } | |
| // MARK: - Helpers & Extensions | |
| private extension PlaybackScreen { | |
| /// Show controls immediately and schedule them to auto-hide. | |
| func showControlsAndReschedule() { | |
| withAnimation(.easeInOut(duration: 0.3)) { controlsVisible = true } | |
| scheduleAutoHide() | |
| } | |
| /// Cancel any pending hide operation. Optionally force show. | |
| func cancelAutoHide(show: Bool = false) { | |
| hideWorkItem?.cancel() | |
| hideWorkItem = nil | |
| if show { | |
| withAnimation(.easeInOut(duration: 0.3)) { controlsVisible = true } | |
| } | |
| } | |
| /// Debounced hide after 3 seconds of inactivity while playing and not scrubbing. | |
| func scheduleAutoHide() { | |
| cancelAutoHide() // cancel previous | |
| guard vm.isPlaying, !isScrubbing else { return } | |
| // NOTE: PlaybackScreen is a struct (value type); 'weak self' is invalid here. | |
| // Capturing value semantics is fine; the work item mutates state on main queue. | |
| let work = DispatchWorkItem { | |
| withAnimation(.easeInOut(duration: 0.3)) { controlsVisible = false } | |
| } | |
| hideWorkItem = work | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work) | |
| } | |
| /// Provide light haptic feedback when selection changes, if supported. | |
| func provideHaptic() { | |
| #if os(iOS) | |
| let generator = UIImpactFeedbackGenerator(style: .light) | |
| generator.prepare() | |
| generator.impactOccurred() | |
| #endif | |
| } | |
| } | |
| // Conditional modifier for iOS 16+ presentation detents (keeps backwards compatibility if needed) | |
| private extension View { | |
| @ViewBuilder | |
| func presentationDetentsIfAvailable() -> some View { | |
| #if os(iOS) | |
| if #available(iOS 16.0, *) { | |
| self.presentationDetents([.fraction(0.45), .medium]) | |
| } else { self } | |
| #else | |
| self | |
| #endif | |
| } | |
| } | |
| // | |
| // AVFoundationPlaybackBackend.swift | |
| // Coba14July2025 | |
| // | |
| // Bridges existing demo `VideoPlayerUIView` to the generic `PlaybackBackend` protocol. | |
| // This keeps previous behavior while allowing a Tuya backend swap. | |
| // | |
| import Foundation | |
| import AVFoundation | |
| import UIKit | |
| final class AVFoundationPlaybackBackend: PlaybackBackend { | |
| private let playerView: VideoPlayerUIView | |
| weak var delegate: PlaybackBackendDelegate? | |
| private(set) var state: PlaybackState = .idle { didSet { notifyStateChange() } } | |
| private(set) var rate: Float = 1.0 { didSet { notifyRateChange() } } | |
| private var lastDuration: Double = 0 | |
| private var lastCurrent: Double = 0 | |
| init() { | |
| self.playerView = VideoPlayerUIView() | |
| wireCallbacks() | |
| } | |
| var view: UIView { playerView } | |
| func load(url: URL, autoplay: Bool) { | |
| state = .loading | |
| playerView.load(url: url, autoplay: autoplay) | |
| } | |
| func play() { playerView.play() } | |
| func pause() { playerView.pause() } | |
| func seek(seconds: Double) { playerView.seek(to: seconds, completion: nil) } | |
| func setRate(_ rate: Float) { self.rate = rate; playerView.setRate(rate) } | |
| func tearDown() { /* Nothing specific yet */ } | |
| private func wireCallbacks() { | |
| playerView.onTimeUpdate = { [weak self] current, duration in | |
| guard let self else { return } | |
| let cur = current.isNumeric ? CMTimeGetSeconds(current) : 0 | |
| let dur = (duration?.isNumeric ?? false) ? CMTimeGetSeconds(duration!) : 0 | |
| self.lastCurrent = cur | |
| self.lastDuration = dur | |
| self.delegate?.backend(self, didUpdateTime: .init(current: cur, duration: dur)) | |
| } | |
| playerView.onStatusUpdate = { [weak self] playing in | |
| guard let self else { return } | |
| self.state = playing ? .playing : (self.state == .ended ? .ended : .paused) | |
| } | |
| playerView.onLoadingChanged = { [weak self] loading in | |
| guard let self else { return } | |
| if loading { self.state = .loading } else if case .loading = self.state { | |
| // Keep paused/playing decision for subsequent callbacks | |
| // default to paused until a play event triggers | |
| // No-op here; next onStatusUpdate will refine. | |
| } | |
| } | |
| playerView.onError = { [weak self] error in | |
| self?.state = .failed(PlaybackFailure(error: error)) | |
| } | |
| } | |
| private func notifyStateChange() { | |
| delegate?.backend(self, didChangeState: state) | |
| } | |
| private func notifyRateChange() { | |
| delegate?.backend(self, didChangeRate: rate) | |
| } | |
| } | |
| // | |
| // PlaybackBackend.swift | |
| // Coba14July2025 | |
| // | |
| // Created by Abstraction Layer Generator on 08/10/25. | |
| // | |
| // This file defines a backend-agnostic abstraction for video playback. | |
| // You can plug in either: | |
| // - AVFoundation based implementation (default demo) OR | |
| // - Tuya SDK based implementation (implement the protocol below) | |
| // | |
| // Steps to integrate Tuya: | |
| // 1. Create a class `TuyaPlaybackBackend` conforming to `PlaybackBackend`. | |
| // 2. Wrap the Tuya UIView / player instance inside and forward calls. | |
| // 3. Emit delegate callbacks for time updates, state changes, errors, etc. | |
| // 4. Inject your backend into `PlaybackViewModel` (see `PlaybackScreen`). | |
| // | |
| // By keeping the protocol intentionally small, switching sources is a single line change. | |
| // | |
| import Foundation | |
| import CoreMedia | |
| import SwiftUI | |
| // MARK: - Playback State Models | |
| /// Equatable representation of a failure (domain + code + message) so `PlaybackState` can be Equatable. | |
| struct PlaybackFailure: Equatable { | |
| let domain: String | |
| let code: Int | |
| let message: String | |
| init(error: Error) { | |
| let ns = error as NSError | |
| self.domain = ns.domain | |
| self.code = ns.code | |
| self.message = ns.localizedDescription | |
| } | |
| } | |
| /// High-level playback status used by the UI layer. | |
| enum PlaybackState: Equatable { | |
| case idle | |
| case loading | |
| case playing | |
| case paused | |
| case ended | |
| case failed(PlaybackFailure) | |
| } | |
| /// Discrete events from a backend. | |
| struct PlaybackTimeInfo: Equatable { | |
| let current: Double | |
| let duration: Double | |
| } | |
| // MARK: - Backend Delegate | |
| protocol PlaybackBackendDelegate: AnyObject { | |
| func backend(_ backend: PlaybackBackend, didUpdateTime info: PlaybackTimeInfo) | |
| func backend(_ backend: PlaybackBackend, didChangeState state: PlaybackState) | |
| func backend(_ backend: PlaybackBackend, didChangeRate rate: Float) | |
| } | |
| // MARK: - Backend Protocol | |
| /// A minimal surface that both AVFoundation and Tuya implementations can satisfy. | |
| protocol PlaybackBackend: AnyObject { | |
| /// UIView (or NSView) to embed in SwiftUI. Ownership retained by backend. | |
| var view: UIView { get } | |
| /// Current playback rate (1.0 = normal speed). | |
| var rate: Float { get } | |
| /// Delegate receives time, state, and rate events. | |
| var delegate: PlaybackBackendDelegate? { get set } | |
| /// Load a video / clip URL. Autoplay if requested. | |
| func load(url: URL, autoplay: Bool) | |
| func play() | |
| func pause() | |
| func seek(seconds: Double) | |
| func setRate(_ rate: Float) | |
| /// Optional: cleanup if host wants to dispose early. | |
| func tearDown() | |
| } | |
| extension PlaybackBackend { | |
| func tearDown() { /* default no-op */ } | |
| } | |
| // | |
| // TuyaPlaybackBackend.swift | |
| // Coba14July2025 | |
| // | |
| // Placeholder implementation illustrating how to integrate Tuya SDK. | |
| // Replace stubs with real Tuya player code. Keep protocol surface stable. | |
| // | |
| // IMPORTANT: Wrap Tuya imports with `#if canImport(TuyaSmartVideoSDK)` or the correct | |
| // module name provided by the SDK to keep the project compiling without the dependency. | |
| // | |
| // Example (adjust module names): | |
| // #if canImport(TuyaSmartCamera) | |
| // import TuyaSmartCamera | |
| // #endif | |
| // | |
| import Foundation | |
| import UIKit | |
| final class TuyaPlaybackBackend: PlaybackBackend { | |
| weak var delegate: PlaybackBackendDelegate? | |
| private(set) var rate: Float = 1.0 | |
| // Replace with the actual Tuya camera/player view instance. | |
| // For now we just use a placeholder UIView with a label. | |
| private let container = UIView() | |
| init() { | |
| container.backgroundColor = .black | |
| let label = UILabel() | |
| label.text = "Tuya Player Placeholder" | |
| label.textColor = .white | |
| label.font = .systemFont(ofSize: 12, weight: .medium) | |
| label.translatesAutoresizingMaskIntoConstraints = false | |
| container.addSubview(label) | |
| NSLayoutConstraint.activate([ | |
| label.centerXAnchor.constraint(equalTo: container.centerXAnchor), | |
| label.centerYAnchor.constraint(equalTo: container.centerYAnchor) | |
| ]) | |
| } | |
| var view: UIView { container } | |
| func load(url: URL, autoplay: Bool) { | |
| // Tuya may not load by arbitrary mp4 URL; you would map the URL or ignore. | |
| // Emit loading -> paused/playing sequence. | |
| delegate?.backend(self, didChangeState: .loading) | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in | |
| guard let self else { return } | |
| if autoplay { self.play() } else { self.delegate?.backend(self, didChangeState: .paused) } | |
| self.delegate?.backend(self, didUpdateTime: .init(current: 0, duration: 0)) | |
| } | |
| } | |
| func play() { delegate?.backend(self, didChangeState: .playing) } | |
| func pause() { delegate?.backend(self, didChangeState: .paused) } | |
| func seek(seconds: Double) { /* Map to Tuya seek API */ } | |
| func setRate(_ rate: Float) { self.rate = rate; delegate?.backend(self, didChangeRate: rate) } | |
| func tearDown() { /* Release Tuya resources if needed */ } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Result: