Skip to content

Instantly share code, notes, and snippets.

@luthviar
Last active October 8, 2025 08:47
Show Gist options
  • Select an option

  • Save luthviar/98cb06d5453acf79c391b7b4849daffe to your computer and use it in GitHub Desktop.

Select an option

Save luthviar/98cb06d5453acf79c391b7b4849daffe to your computer and use it in GitHub Desktop.
VideoClipsPlayerScreen.swift
//
// 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 */ }
}
@luthviar
Copy link
Author

luthviar commented Oct 8, 2025

Result:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment