unified-video-framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +253 -0
- package/ANDROID_TV_IMPLEMENTATION.md +313 -0
- package/COMPLETION_STATUS.md +165 -0
- package/CONTRIBUTING.md +376 -0
- package/FINAL_STATUS_REPORT.md +170 -0
- package/FRAMEWORK_REVIEW.md +247 -0
- package/IMPROVEMENTS_SUMMARY.md +168 -0
- package/LICENSE +21 -0
- package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
- package/PAYWALL_RENTAL_FLOW.md +499 -0
- package/PLATFORM_SETUP_GUIDE.md +1636 -0
- package/README.md +315 -0
- package/RUN_LOCALLY.md +151 -0
- package/apps/demo/cast-sender-min.html +173 -0
- package/apps/demo/custom-player.html +883 -0
- package/apps/demo/demo.html +990 -0
- package/apps/demo/enhanced-player.html +3556 -0
- package/apps/demo/index.html +159 -0
- package/apps/rental-api/.env.example +24 -0
- package/apps/rental-api/README.md +23 -0
- package/apps/rental-api/migrations/001_init.sql +35 -0
- package/apps/rental-api/migrations/002_videos.sql +10 -0
- package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
- package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
- package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
- package/apps/rental-api/package-lock.json +2045 -0
- package/apps/rental-api/package.json +33 -0
- package/apps/rental-api/scripts/run-migration.js +42 -0
- package/apps/rental-api/scripts/update-video-currency.js +21 -0
- package/apps/rental-api/scripts/update-video-price.js +19 -0
- package/apps/rental-api/src/config.ts +14 -0
- package/apps/rental-api/src/db.ts +10 -0
- package/apps/rental-api/src/routes/cashfree.ts +167 -0
- package/apps/rental-api/src/routes/pesapal.ts +92 -0
- package/apps/rental-api/src/routes/rentals.ts +242 -0
- package/apps/rental-api/src/routes/webhooks.ts +73 -0
- package/apps/rental-api/src/server.ts +41 -0
- package/apps/rental-api/src/services/entitlements.ts +45 -0
- package/apps/rental-api/src/services/payments.ts +22 -0
- package/apps/rental-api/tsconfig.json +17 -0
- package/check-urls.ps1 +74 -0
- package/comparison-report.md +181 -0
- package/docs/PAYWALL.md +95 -0
- package/docs/PLAYER_UI_VISIBILITY.md +431 -0
- package/docs/README.md +7 -0
- package/docs/SYSTEM_ARCHITECTURE.md +612 -0
- package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
- package/examples/android/JavaSampleApp/MainActivity.java +641 -0
- package/examples/android/JavaSampleApp/activity_main.xml +226 -0
- package/examples/android/SampleApp/MainActivity.kt +430 -0
- package/examples/ios/SampleApp/ViewController.swift +337 -0
- package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
- package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
- package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
- package/jest.config.js +33 -0
- package/jitpack.yml +5 -0
- package/lerna.json +35 -0
- package/package.json +69 -0
- package/packages/PLATFORM_STATUS.md +163 -0
- package/packages/android/build.gradle +135 -0
- package/packages/android/src/main/AndroidManifest.xml +36 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
- package/packages/core/package.json +34 -0
- package/packages/core/src/BasePlayer.ts +250 -0
- package/packages/core/src/VideoPlayer.ts +237 -0
- package/packages/core/src/VideoPlayerFactory.ts +145 -0
- package/packages/core/src/index.ts +20 -0
- package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
- package/packages/core/src/interfaces.ts +240 -0
- package/packages/core/src/utils/EventEmitter.ts +66 -0
- package/packages/core/src/utils/PlatformDetector.ts +300 -0
- package/packages/core/tsconfig.json +20 -0
- package/packages/enact/package.json +51 -0
- package/packages/enact/src/VideoPlayer.js +365 -0
- package/packages/enact/src/adapters/TizenAdapter.js +354 -0
- package/packages/enact/src/index.js +82 -0
- package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
- package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
- package/packages/ios/GETTING_STARTED.md +100 -0
- package/packages/ios/Package.swift +35 -0
- package/packages/ios/README.md +84 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
- package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
- package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
- package/packages/ios/build_framework.sh +55 -0
- package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
- package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
- package/packages/react-native/package.json +51 -0
- package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
- package/packages/react-native/src/VideoPlayer.tsx +224 -0
- package/packages/react-native/src/index.ts +28 -0
- package/packages/react-native/src/utils/EventEmitter.ts +66 -0
- package/packages/react-native/tsconfig.json +31 -0
- package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
- package/packages/roku/package.json +44 -0
- package/packages/roku/source/VideoPlayer.brs +231 -0
- package/packages/roku/source/main.brs +28 -0
- package/packages/web/GETTING_STARTED.md +292 -0
- package/packages/web/jest.config.js +28 -0
- package/packages/web/jest.setup.ts +110 -0
- package/packages/web/package.json +50 -0
- package/packages/web/src/SecureVideoPlayer.ts +1164 -0
- package/packages/web/src/WebPlayer.ts +3110 -0
- package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
- package/packages/web/src/index.ts +14 -0
- package/packages/web/src/paywall/PaywallController.ts +215 -0
- package/packages/web/src/react/WebPlayerView.tsx +177 -0
- package/packages/web/tsconfig.json +23 -0
- package/packages/web/webpack.config.js +45 -0
- package/server.js +131 -0
- package/server.py +84 -0
- package/test-urls.ps1 +97 -0
- package/test-video-urls.ps1 +87 -0
- package/tsconfig.json +39 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
//
|
|
2
|
+
// UnifiedVideoPlayerView.swift
|
|
3
|
+
// UnifiedVideoPlayer
|
|
4
|
+
//
|
|
5
|
+
// SwiftUI implementation of the Unified Video Framework
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import SwiftUI
|
|
9
|
+
import AVKit
|
|
10
|
+
import AVFoundation
|
|
11
|
+
import Combine
|
|
12
|
+
import UIKit
|
|
13
|
+
|
|
14
|
+
// MARK: - SwiftUI Video Player View
|
|
15
|
+
@available(iOS 14.0, *)
|
|
16
|
+
public struct UnifiedVideoPlayerView: UIViewControllerRepresentable {
|
|
17
|
+
@Binding var player: UnifiedVideoPlayerModel
|
|
18
|
+
var showsControls: Bool = true
|
|
19
|
+
|
|
20
|
+
public init(player: Binding<UnifiedVideoPlayerModel>, showsControls: Bool = true) {
|
|
21
|
+
self._player = player
|
|
22
|
+
self.showsControls = showsControls
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public func makeUIViewController(context: Context) -> AVPlayerViewController {
|
|
26
|
+
let controller = AVPlayerViewController()
|
|
27
|
+
controller.player = player.avPlayer
|
|
28
|
+
controller.showsPlaybackControls = showsControls
|
|
29
|
+
return controller
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
|
33
|
+
uiViewController.player = player.avPlayer
|
|
34
|
+
uiViewController.showsPlaybackControls = showsControls
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MARK: - Custom SwiftUI Video Player with Controls
|
|
39
|
+
@available(iOS 14.0, *)
|
|
40
|
+
public struct CustomUnifiedVideoPlayer: View {
|
|
41
|
+
@StateObject private var playerModel: UnifiedVideoPlayerModel
|
|
42
|
+
@State private var showControls: Bool = true
|
|
43
|
+
@State private var hideControlsTimer: Timer?
|
|
44
|
+
@State private var pipController: AVPictureInPictureController? = nil
|
|
45
|
+
|
|
46
|
+
// Configuration
|
|
47
|
+
private let configuration: PlayerConfiguration
|
|
48
|
+
private var accent: Color { Color(hex: configuration.themeColorHex ?? "") ?? .white }
|
|
49
|
+
private var uiAccent: UIColor { UIColor(hex: configuration.themeColorHex ?? "") ?? .white }
|
|
50
|
+
|
|
51
|
+
public init(url: String, configuration: PlayerConfiguration = PlayerConfiguration()) {
|
|
52
|
+
self.configuration = configuration
|
|
53
|
+
self._playerModel = StateObject(wrappedValue: UnifiedVideoPlayerModel(url: url, configuration: configuration))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public init(source: MediaSource, configuration: PlayerConfiguration = PlayerConfiguration()) {
|
|
57
|
+
self.configuration = configuration
|
|
58
|
+
self._playerModel = StateObject(wrappedValue: UnifiedVideoPlayerModel(source: source, configuration: configuration))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public var body: some View {
|
|
62
|
+
ZStack {
|
|
63
|
+
// Video Player Layer
|
|
64
|
+
VideoPlayerLayer(player: playerModel.avPlayer, pipController: $pipController)
|
|
65
|
+
.onTapGesture {
|
|
66
|
+
withAnimation(.easeInOut(duration: 0.3)) {
|
|
67
|
+
showControls.toggle()
|
|
68
|
+
resetHideControlsTimer()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Custom Controls Overlay
|
|
73
|
+
if showControls && configuration.controls {
|
|
74
|
+
VideoControlsOverlay(
|
|
75
|
+
playerModel: playerModel,
|
|
76
|
+
accent: accent,
|
|
77
|
+
uiAccent: uiAccent,
|
|
78
|
+
pipController: pipController,
|
|
79
|
+
onTogglePiP: {
|
|
80
|
+
if let c = pipController {
|
|
81
|
+
if c.isPictureInPictureActive { c.stopPictureInPicture() }
|
|
82
|
+
else { c.startPictureInPicture() }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
.transition(.opacity)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Loading Indicator
|
|
90
|
+
if playerModel.isBuffering {
|
|
91
|
+
ProgressView()
|
|
92
|
+
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
93
|
+
.scaleEffect(1.5)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Error Message
|
|
97
|
+
if let error = playerModel.error {
|
|
98
|
+
VStack {
|
|
99
|
+
Image(systemName: "exclamationmark.triangle")
|
|
100
|
+
.font(.largeTitle)
|
|
101
|
+
.foregroundColor(.white)
|
|
102
|
+
Text(error.localizedDescription)
|
|
103
|
+
.foregroundColor(.white)
|
|
104
|
+
.padding()
|
|
105
|
+
}
|
|
106
|
+
.background(Color.black.opacity(0.7))
|
|
107
|
+
.cornerRadius(10)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
.background(Color.black)
|
|
111
|
+
.onAppear {
|
|
112
|
+
playerModel.initialize()
|
|
113
|
+
resetHideControlsTimer()
|
|
114
|
+
}
|
|
115
|
+
.onDisappear {
|
|
116
|
+
playerModel.cleanup()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func resetHideControlsTimer() {
|
|
121
|
+
hideControlsTimer?.invalidate()
|
|
122
|
+
hideControlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
|
|
123
|
+
withAnimation {
|
|
124
|
+
showControls = false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// MARK: - Video Player Layer
|
|
131
|
+
@available(iOS 14.0, *)
|
|
132
|
+
struct VideoPlayerLayer: UIViewRepresentable {
|
|
133
|
+
let player: AVPlayer?
|
|
134
|
+
@Binding var pipController: AVPictureInPictureController?
|
|
135
|
+
|
|
136
|
+
func makeUIView(context: Context) -> PlayerUIView {
|
|
137
|
+
let view = PlayerUIView(player: player)
|
|
138
|
+
if AVPictureInPictureController.isPictureInPictureSupported(), pipController == nil, let layer = view.playerLayer {
|
|
139
|
+
pipController = AVPictureInPictureController(playerLayer: layer)
|
|
140
|
+
}
|
|
141
|
+
return view
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func updateUIView(_ uiView: PlayerUIView, context: Context) {
|
|
145
|
+
uiView.updatePlayer(player)
|
|
146
|
+
if AVPictureInPictureController.isPictureInPictureSupported(), pipController == nil, let layer = uiView.playerLayer {
|
|
147
|
+
pipController = AVPictureInPictureController(playerLayer: layer)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class PlayerUIView: UIView {
|
|
152
|
+
var playerLayer: AVPlayerLayer?
|
|
153
|
+
|
|
154
|
+
init(player: AVPlayer?) {
|
|
155
|
+
super.init(frame: .zero)
|
|
156
|
+
setupPlayerLayer(with: player)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
required init?(coder: NSCoder) {
|
|
160
|
+
super.init(coder: coder)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override func layoutSubviews() {
|
|
164
|
+
super.layoutSubviews()
|
|
165
|
+
playerLayer?.frame = bounds
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func setupPlayerLayer(with player: AVPlayer?) {
|
|
169
|
+
playerLayer?.removeFromSuperlayer()
|
|
170
|
+
|
|
171
|
+
guard let player = player else { return }
|
|
172
|
+
|
|
173
|
+
let newPlayerLayer = AVPlayerLayer(player: player)
|
|
174
|
+
newPlayerLayer.videoGravity = .resizeAspect
|
|
175
|
+
newPlayerLayer.frame = bounds
|
|
176
|
+
layer.addSublayer(newPlayerLayer)
|
|
177
|
+
playerLayer = newPlayerLayer
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func updatePlayer(_ player: AVPlayer?) {
|
|
181
|
+
playerLayer?.player = player
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - AirPlay Route Picker (SwiftUI wrapper)
|
|
187
|
+
@available(iOS 13.0, *)
|
|
188
|
+
struct AirPlayRoutePicker: UIViewRepresentable {
|
|
189
|
+
let tint: UIColor
|
|
190
|
+
func makeUIView(context: Context) -> AVRoutePickerView {
|
|
191
|
+
let v = AVRoutePickerView(frame: .zero)
|
|
192
|
+
v.tintColor = tint
|
|
193
|
+
if #available(iOS 13.0, *) { v.activeTintColor = tint }
|
|
194
|
+
return v
|
|
195
|
+
}
|
|
196
|
+
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {
|
|
197
|
+
uiView.tintColor = tint
|
|
198
|
+
if #available(iOS 13.0, *) { uiView.activeTintColor = tint }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// MARK: - Video Controls Overlay
|
|
203
|
+
@available(iOS 14.0, *)
|
|
204
|
+
struct VideoControlsOverlay: View {
|
|
205
|
+
@ObservedObject var playerModel: UnifiedVideoPlayerModel
|
|
206
|
+
let accent: Color
|
|
207
|
+
let uiAccent: UIColor
|
|
208
|
+
let pipController: AVPictureInPictureController?
|
|
209
|
+
let onTogglePiP: () -> Void
|
|
210
|
+
@State private var isDraggingSlider: Bool = false
|
|
211
|
+
|
|
212
|
+
var body: some View {
|
|
213
|
+
VStack {
|
|
214
|
+
// Top Bar
|
|
215
|
+
HStack {
|
|
216
|
+
if let title = playerModel.title {
|
|
217
|
+
Text(title)
|
|
218
|
+
.font(.headline)
|
|
219
|
+
.foregroundColor(.white)
|
|
220
|
+
}
|
|
221
|
+
Spacer()
|
|
222
|
+
|
|
223
|
+
// AirPlay + PiP
|
|
224
|
+
HStack(spacing: 16) {
|
|
225
|
+
if #available(iOS 13.0, *) {
|
|
226
|
+
AirPlayRoutePicker(tint: uiAccent)
|
|
227
|
+
.frame(width: 28, height: 28)
|
|
228
|
+
}
|
|
229
|
+
if AVPictureInPictureController.isPictureInPictureSupported() && pipController != nil {
|
|
230
|
+
Button(action: onTogglePiP) {
|
|
231
|
+
if #available(iOS 15.0, *) {
|
|
232
|
+
Image(systemName: (pipController?.isPictureInPictureActive ?? false) ? "pip.exit" : "pip.enter")
|
|
233
|
+
.foregroundColor(accent)
|
|
234
|
+
.font(.title2)
|
|
235
|
+
} else {
|
|
236
|
+
Image(systemName: "rectangle")
|
|
237
|
+
.foregroundColor(accent)
|
|
238
|
+
.font(.title2)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Settings Menu
|
|
245
|
+
Menu {
|
|
246
|
+
// Quality Selection
|
|
247
|
+
Menu("Quality") {
|
|
248
|
+
ForEach(["Auto", "1080p", "720p", "480p", "360p"], id: \.self) { quality in
|
|
249
|
+
Button(quality) {
|
|
250
|
+
playerModel.setVideoQuality(quality)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Playback Speed
|
|
256
|
+
Menu("Speed") {
|
|
257
|
+
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { speed in
|
|
258
|
+
Button("\(speed)x") {
|
|
259
|
+
playerModel.setPlaybackRate(Float(speed))
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Subtitles
|
|
265
|
+
if !playerModel.availableSubtitles.isEmpty {
|
|
266
|
+
Menu("Subtitles") {
|
|
267
|
+
Button("Off") {
|
|
268
|
+
playerModel.selectSubtitle(nil)
|
|
269
|
+
}
|
|
270
|
+
ForEach(playerModel.availableSubtitles, id: \.language) { subtitle in
|
|
271
|
+
Button(subtitle.label) {
|
|
272
|
+
playerModel.selectSubtitle(subtitle)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} label: {
|
|
278
|
+
Image(systemName: "ellipsis")
|
|
279
|
+
.foregroundColor(accent)
|
|
280
|
+
.font(.title2)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
.padding()
|
|
284
|
+
.background(LinearGradient(
|
|
285
|
+
gradient: Gradient(colors: [Color.black.opacity(0.7), Color.clear]),
|
|
286
|
+
startPoint: .top,
|
|
287
|
+
endPoint: .bottom
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
Spacer()
|
|
291
|
+
|
|
292
|
+
// Center Play/Pause Button
|
|
293
|
+
if !playerModel.isPlaying {
|
|
294
|
+
Button(action: {
|
|
295
|
+
playerModel.play()
|
|
296
|
+
}) {
|
|
297
|
+
Image(systemName: "play.circle.fill")
|
|
298
|
+
.font(.system(size: 70))
|
|
299
|
+
.foregroundColor(accent.opacity(0.9))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
Spacer()
|
|
304
|
+
|
|
305
|
+
// Bottom Controls
|
|
306
|
+
VStack(spacing: 10) {
|
|
307
|
+
// Progress Bar
|
|
308
|
+
HStack {
|
|
309
|
+
Text(formatTime(playerModel.currentTime))
|
|
310
|
+
.font(.caption)
|
|
311
|
+
.foregroundColor(.white)
|
|
312
|
+
|
|
313
|
+
Slider(
|
|
314
|
+
value: Binding(
|
|
315
|
+
get: { isDraggingSlider ? playerModel.seekTime : playerModel.currentTime },
|
|
316
|
+
set: { playerModel.seekTime = $0 }
|
|
317
|
+
),
|
|
318
|
+
in: 0...max(playerModel.duration, 1),
|
|
319
|
+
onEditingChanged: { editing in
|
|
320
|
+
isDraggingSlider = editing
|
|
321
|
+
if !editing {
|
|
322
|
+
playerModel.seek(to: playerModel.seekTime)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
.accentColor(accent)
|
|
327
|
+
|
|
328
|
+
Text(formatTime(playerModel.duration))
|
|
329
|
+
.font(.caption)
|
|
330
|
+
.foregroundColor(.white)
|
|
331
|
+
}
|
|
332
|
+
.padding(.horizontal)
|
|
333
|
+
|
|
334
|
+
// Control Buttons
|
|
335
|
+
HStack(spacing: 30) {
|
|
336
|
+
// Play/Pause
|
|
337
|
+
Button(action: {
|
|
338
|
+
playerModel.togglePlayPause()
|
|
339
|
+
}) {
|
|
340
|
+
Image(systemName: playerModel.isPlaying ? "pause.fill" : "play.fill")
|
|
341
|
+
.font(.title)
|
|
342
|
+
.foregroundColor(accent)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Skip Backward
|
|
346
|
+
Button(action: {
|
|
347
|
+
playerModel.seekBackward()
|
|
348
|
+
}) {
|
|
349
|
+
Image(systemName: "gobackward.10")
|
|
350
|
+
.font(.title2)
|
|
351
|
+
.foregroundColor(accent)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Skip Forward
|
|
355
|
+
Button(action: {
|
|
356
|
+
playerModel.seekForward()
|
|
357
|
+
}) {
|
|
358
|
+
Image(systemName: "goforward.10")
|
|
359
|
+
.font(.title2)
|
|
360
|
+
.foregroundColor(accent)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
Spacer()
|
|
364
|
+
|
|
365
|
+
// Volume
|
|
366
|
+
HStack {
|
|
367
|
+
Image(systemName: playerModel.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
|
|
368
|
+
.foregroundColor(accent)
|
|
369
|
+
.onTapGesture {
|
|
370
|
+
playerModel.toggleMute()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
Slider(value: $playerModel.volume, in: 0...1)
|
|
374
|
+
.frame(width: 80)
|
|
375
|
+
.accentColor(accent)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Fullscreen
|
|
379
|
+
Button(action: {
|
|
380
|
+
playerModel.toggleFullscreen()
|
|
381
|
+
}) {
|
|
382
|
+
Image(systemName: playerModel.isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
|
|
383
|
+
.font(.title2)
|
|
384
|
+
.foregroundColor(accent)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
.padding(.horizontal)
|
|
388
|
+
}
|
|
389
|
+
.padding(.vertical)
|
|
390
|
+
.background(LinearGradient(
|
|
391
|
+
gradient: Gradient(colors: [Color.clear, Color.black.opacity(0.7)]),
|
|
392
|
+
startPoint: .top,
|
|
393
|
+
endPoint: .bottom
|
|
394
|
+
))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private func formatTime(_ time: Double) -> String {
|
|
399
|
+
let minutes = Int(time) / 60
|
|
400
|
+
let seconds = Int(time) % 60
|
|
401
|
+
return String(format: "%02d:%02d", minutes, seconds)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// MARK: - Player Model (ObservableObject)
|
|
406
|
+
@available(iOS 14.0, *)
|
|
407
|
+
public class UnifiedVideoPlayerModel: ObservableObject {
|
|
408
|
+
// Published Properties
|
|
409
|
+
@Published public var isPlaying: Bool = false
|
|
410
|
+
@Published public var isBuffering: Bool = false
|
|
411
|
+
@Published public var currentTime: Double = 0
|
|
412
|
+
@Published public var duration: Double = 0
|
|
413
|
+
@Published public var seekTime: Double = 0
|
|
414
|
+
@Published public var volume: Float = 1.0 {
|
|
415
|
+
didSet { avPlayer?.volume = volume }
|
|
416
|
+
}
|
|
417
|
+
@Published public var isMuted: Bool = false {
|
|
418
|
+
didSet { avPlayer?.isMuted = isMuted }
|
|
419
|
+
}
|
|
420
|
+
@Published public var error: Error?
|
|
421
|
+
@Published public var isFullscreen: Bool = false
|
|
422
|
+
@Published public var title: String?
|
|
423
|
+
@Published public var availableSubtitles: [SubtitleTrack] = []
|
|
424
|
+
@Published public var currentVideoQuality: String = "Auto"
|
|
425
|
+
|
|
426
|
+
// Player properties
|
|
427
|
+
public private(set) var avPlayer: AVPlayer?
|
|
428
|
+
private var playerObserver: Any?
|
|
429
|
+
private var timeObserver: Any?
|
|
430
|
+
private var cancellables = Set<AnyCancellable>()
|
|
431
|
+
private let configuration: PlayerConfiguration
|
|
432
|
+
private var mediaSource: MediaSource?
|
|
433
|
+
|
|
434
|
+
// Events
|
|
435
|
+
public var onReady: (() -> Void)?
|
|
436
|
+
public var onEnded: (() -> Void)?
|
|
437
|
+
public var onError: ((Error) -> Void)?
|
|
438
|
+
|
|
439
|
+
// MARK: - Initialization
|
|
440
|
+
|
|
441
|
+
public init(url: String, configuration: PlayerConfiguration = PlayerConfiguration()) {
|
|
442
|
+
self.configuration = configuration
|
|
443
|
+
self.mediaSource = MediaSource(url: url)
|
|
444
|
+
setupPlayer()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
public init(source: MediaSource, configuration: PlayerConfiguration = PlayerConfiguration()) {
|
|
448
|
+
self.configuration = configuration
|
|
449
|
+
self.mediaSource = source
|
|
450
|
+
setupPlayer()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// MARK: - Player Setup
|
|
454
|
+
|
|
455
|
+
private func setupPlayer() {
|
|
456
|
+
guard let source = mediaSource,
|
|
457
|
+
let url = URL(string: source.url) else { return }
|
|
458
|
+
|
|
459
|
+
let playerItem = AVPlayerItem(url: url)
|
|
460
|
+
avPlayer = AVPlayer(playerItem: playerItem)
|
|
461
|
+
|
|
462
|
+
// Apply configuration
|
|
463
|
+
avPlayer?.volume = configuration.volume
|
|
464
|
+
avPlayer?.isMuted = configuration.muted
|
|
465
|
+
|
|
466
|
+
// Setup observers
|
|
467
|
+
setupObservers()
|
|
468
|
+
|
|
469
|
+
// Load metadata
|
|
470
|
+
if let metadata = source.metadata {
|
|
471
|
+
title = metadata["title"] as? String
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Load subtitles if available
|
|
475
|
+
if let subtitles = source.subtitles {
|
|
476
|
+
availableSubtitles = subtitles
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
public func initialize() {
|
|
481
|
+
if configuration.autoPlay {
|
|
482
|
+
play()
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// MARK: - Playback Control
|
|
487
|
+
|
|
488
|
+
public func play() {
|
|
489
|
+
avPlayer?.play()
|
|
490
|
+
isPlaying = true
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
public func pause() {
|
|
494
|
+
avPlayer?.pause()
|
|
495
|
+
isPlaying = false
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
public func togglePlayPause() {
|
|
499
|
+
if isPlaying {
|
|
500
|
+
pause()
|
|
501
|
+
} else {
|
|
502
|
+
play()
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
public func stop() {
|
|
507
|
+
avPlayer?.pause()
|
|
508
|
+
avPlayer?.seek(to: .zero)
|
|
509
|
+
isPlaying = false
|
|
510
|
+
currentTime = 0
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
public func seek(to time: Double) {
|
|
514
|
+
let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
|
|
515
|
+
avPlayer?.seek(to: cmTime)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
public func seekForward(seconds: Double = 10) {
|
|
519
|
+
let newTime = currentTime + seconds
|
|
520
|
+
seek(to: min(newTime, duration))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
public func seekBackward(seconds: Double = 10) {
|
|
524
|
+
let newTime = currentTime - seconds
|
|
525
|
+
seek(to: max(newTime, 0))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// MARK: - Configuration
|
|
529
|
+
|
|
530
|
+
public func setPlaybackRate(_ rate: Float) {
|
|
531
|
+
avPlayer?.rate = rate
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
public func setVideoQuality(_ quality: String) {
|
|
535
|
+
currentVideoQuality = quality
|
|
536
|
+
// Implementation would handle quality switching
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
public func selectSubtitle(_ subtitle: SubtitleTrack?) {
|
|
540
|
+
// Implementation would handle subtitle selection
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
public func toggleMute() {
|
|
544
|
+
isMuted.toggle()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
public func toggleFullscreen() {
|
|
548
|
+
isFullscreen.toggle()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// MARK: - Observers
|
|
552
|
+
|
|
553
|
+
private func setupObservers() {
|
|
554
|
+
// Time observer
|
|
555
|
+
let interval = CMTime(seconds: 0.1, preferredTimescale: 1000)
|
|
556
|
+
timeObserver = avPlayer?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
|
557
|
+
self?.currentTime = time.seconds
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Status observer
|
|
561
|
+
avPlayer?.publisher(for: \.status)
|
|
562
|
+
.sink { [weak self] status in
|
|
563
|
+
if status == .readyToPlay {
|
|
564
|
+
self?.updateDuration()
|
|
565
|
+
self?.onReady?()
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
.store(in: &cancellables)
|
|
569
|
+
|
|
570
|
+
// Rate observer
|
|
571
|
+
avPlayer?.publisher(for: \.rate)
|
|
572
|
+
.sink { [weak self] rate in
|
|
573
|
+
self?.isPlaying = rate > 0
|
|
574
|
+
}
|
|
575
|
+
.store(in: &cancellables)
|
|
576
|
+
|
|
577
|
+
// Buffering observer
|
|
578
|
+
avPlayer?.currentItem?.publisher(for: \.isPlaybackBufferEmpty)
|
|
579
|
+
.sink { [weak self] isEmpty in
|
|
580
|
+
self?.isBuffering = isEmpty
|
|
581
|
+
}
|
|
582
|
+
.store(in: &cancellables)
|
|
583
|
+
|
|
584
|
+
// End observer
|
|
585
|
+
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime)
|
|
586
|
+
.sink { [weak self] _ in
|
|
587
|
+
self?.handlePlaybackEnded()
|
|
588
|
+
}
|
|
589
|
+
.store(in: &cancellables)
|
|
590
|
+
|
|
591
|
+
// Error observer
|
|
592
|
+
avPlayer?.currentItem?.publisher(for: \.error)
|
|
593
|
+
.compactMap { $0 }
|
|
594
|
+
.sink { [weak self] error in
|
|
595
|
+
self?.error = error
|
|
596
|
+
self?.onError?(error)
|
|
597
|
+
}
|
|
598
|
+
.store(in: &cancellables)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private func updateDuration() {
|
|
602
|
+
if let duration = avPlayer?.currentItem?.duration {
|
|
603
|
+
self.duration = duration.seconds
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private func handlePlaybackEnded() {
|
|
608
|
+
if configuration.loop {
|
|
609
|
+
seek(to: 0)
|
|
610
|
+
play()
|
|
611
|
+
} else {
|
|
612
|
+
isPlaying = false
|
|
613
|
+
onEnded?()
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// MARK: - Cleanup
|
|
618
|
+
|
|
619
|
+
public func cleanup() {
|
|
620
|
+
avPlayer?.pause()
|
|
621
|
+
if let observer = timeObserver {
|
|
622
|
+
avPlayer?.removeTimeObserver(observer)
|
|
623
|
+
}
|
|
624
|
+
cancellables.removeAll()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
deinit {
|
|
628
|
+
cleanup()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// MARK: - SwiftUI Preview Provider
|
|
633
|
+
@available(iOS 14.0, *)
|
|
634
|
+
struct UnifiedVideoPlayerView_Previews: PreviewProvider {
|
|
635
|
+
static var previews: some View {
|
|
636
|
+
CustomUnifiedVideoPlayer(
|
|
637
|
+
url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
public extension UIColor {
|
|
5
|
+
// Supports #RRGGBB and #AARRGGBB (with or without #)
|
|
6
|
+
convenience init?(hex: String) {
|
|
7
|
+
var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
8
|
+
if cleaned.hasPrefix("#") { cleaned.removeFirst() }
|
|
9
|
+
guard cleaned.count == 6 || cleaned.count == 8 else { return nil }
|
|
10
|
+
|
|
11
|
+
var rgba: UInt64 = 0
|
|
12
|
+
guard Scanner(string: cleaned).scanHexInt64(&rgba) else { return nil }
|
|
13
|
+
|
|
14
|
+
let a, r, g, b: CGFloat
|
|
15
|
+
if cleaned.count == 8 {
|
|
16
|
+
a = CGFloat((rgba & 0xFF000000) >> 24) / 255.0
|
|
17
|
+
r = CGFloat((rgba & 0x00FF0000) >> 16) / 255.0
|
|
18
|
+
g = CGFloat((rgba & 0x0000FF00) >> 8) / 255.0
|
|
19
|
+
b = CGFloat(rgba & 0x000000FF) / 255.0
|
|
20
|
+
} else {
|
|
21
|
+
a = 1.0
|
|
22
|
+
r = CGFloat((rgba & 0xFF0000) >> 16) / 255.0
|
|
23
|
+
g = CGFloat((rgba & 0x00FF00) >> 8) / 255.0
|
|
24
|
+
b = CGFloat(rgba & 0x0000FF) / 255.0
|
|
25
|
+
}
|
|
26
|
+
self.init(red: r, green: g, blue: b, alpha: a)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public extension Color {
|
|
31
|
+
init?(hex: String) {
|
|
32
|
+
guard let ui = UIColor(hex: hex) else { return nil }
|
|
33
|
+
self = Color(ui)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'UnifiedVideoPlayer'
|
|
3
|
+
s.version = '1.0.0'
|
|
4
|
+
s.summary = 'Unified Video Player SDK for iOS'
|
|
5
|
+
s.description = <<-DESC
|
|
6
|
+
A powerful, unified video player SDK for iOS that provides a consistent API for video playback
|
|
7
|
+
with support for HLS, DASH, DRM, subtitles, and more.
|
|
8
|
+
DESC
|
|
9
|
+
|
|
10
|
+
s.homepage = 'https://github.com/yourcompany/unified-video-ios'
|
|
11
|
+
s.license = { :type => 'MIT', :file => 'LICENSE' }
|
|
12
|
+
s.author = { 'Your Company' => 'contact@yourcompany.com' }
|
|
13
|
+
s.source = { :git => 'https://github.com/yourcompany/unified-video-ios.git', :tag => s.version.to_s }
|
|
14
|
+
|
|
15
|
+
s.ios.deployment_target = '13.0'
|
|
16
|
+
s.tvos.deployment_target = '13.0'
|
|
17
|
+
s.swift_version = '5.0'
|
|
18
|
+
|
|
19
|
+
s.source_files = 'Sources/UnifiedVideoPlayer/**/*.{swift,h,m}'
|
|
20
|
+
s.public_header_files = 'Sources/UnifiedVideoPlayer/**/*.h'
|
|
21
|
+
|
|
22
|
+
s.frameworks = 'UIKit', 'AVFoundation', 'AVKit', 'Foundation'
|
|
23
|
+
|
|
24
|
+
s.resource_bundles = {
|
|
25
|
+
'UnifiedVideoPlayer' => ['Sources/UnifiedVideoPlayer/Resources/**/*']
|
|
26
|
+
}
|
|
27
|
+
end
|