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,314 @@
|
|
|
1
|
+
import { WebPlayer } from '../WebPlayer';
|
|
2
|
+
import { VideoSource } from '@unified-video/core';
|
|
3
|
+
|
|
4
|
+
describe('WebPlayer', () => {
|
|
5
|
+
let player: WebPlayer;
|
|
6
|
+
let container: HTMLDivElement;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
container = document.createElement('div');
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
player = new WebPlayer();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await player.destroy();
|
|
16
|
+
document.body.removeChild(container);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('initialization', () => {
|
|
20
|
+
it('should initialize with container element', async () => {
|
|
21
|
+
await expect(player.initialize(container)).resolves.not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should initialize with container selector', async () => {
|
|
25
|
+
container.id = 'test-container';
|
|
26
|
+
await expect(player.initialize('#test-container')).resolves.not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should throw error if container not found', async () => {
|
|
30
|
+
await expect(player.initialize('#non-existent')).rejects.toThrow(
|
|
31
|
+
'Container element not found'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should create video element inside container', async () => {
|
|
36
|
+
await player.initialize(container);
|
|
37
|
+
const video = container.querySelector('video');
|
|
38
|
+
expect(video).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should apply config options to video element', async () => {
|
|
42
|
+
await player.initialize(container, {
|
|
43
|
+
autoPlay: true,
|
|
44
|
+
muted: true,
|
|
45
|
+
controls: false
|
|
46
|
+
});
|
|
47
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
48
|
+
expect(video.autoplay).toBe(true);
|
|
49
|
+
expect(video.muted).toBe(true);
|
|
50
|
+
expect(video.controls).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('media loading', () => {
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await player.initialize(container);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should detect MP4 format', async () => {
|
|
60
|
+
const source: VideoSource = {
|
|
61
|
+
url: 'https://example.com/video.mp4'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const spy = jest.spyOn(player as any, 'loadNative');
|
|
65
|
+
await player.load(source);
|
|
66
|
+
expect(spy).toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should detect HLS format', async () => {
|
|
70
|
+
const source: VideoSource = {
|
|
71
|
+
url: 'https://example.com/video.m3u8'
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const spy = jest.spyOn(player as any, 'loadHLS');
|
|
75
|
+
await player.load(source);
|
|
76
|
+
expect(spy).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should detect DASH format', async () => {
|
|
80
|
+
const source: VideoSource = {
|
|
81
|
+
url: 'https://example.com/video.mpd'
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const spy = jest.spyOn(player as any, 'loadDASH');
|
|
85
|
+
await player.load(source);
|
|
86
|
+
expect(spy).toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should use explicit type over detection', async () => {
|
|
90
|
+
const source: VideoSource = {
|
|
91
|
+
url: 'https://example.com/stream',
|
|
92
|
+
type: 'hls'
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const spy = jest.spyOn(player as any, 'loadHLS');
|
|
96
|
+
await player.load(source);
|
|
97
|
+
expect(spy).toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should load subtitles when provided', async () => {
|
|
101
|
+
const source: VideoSource = {
|
|
102
|
+
url: 'https://example.com/video.mp4',
|
|
103
|
+
subtitles: [
|
|
104
|
+
{
|
|
105
|
+
url: 'https://example.com/subs.vtt',
|
|
106
|
+
language: 'en',
|
|
107
|
+
label: 'English',
|
|
108
|
+
kind: 'subtitles'
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await player.load(source);
|
|
114
|
+
const tracks = container.querySelectorAll('track');
|
|
115
|
+
expect(tracks.length).toBe(1);
|
|
116
|
+
expect(tracks[0].getAttribute('srclang')).toBe('en');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('playback controls', () => {
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
await player.initialize(container);
|
|
123
|
+
await player.load({ url: 'test.mp4' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should play video', async () => {
|
|
127
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
128
|
+
const playSpy = jest.spyOn(video, 'play').mockResolvedValue();
|
|
129
|
+
|
|
130
|
+
await player.play();
|
|
131
|
+
expect(playSpy).toHaveBeenCalled();
|
|
132
|
+
expect(player.isPlaying()).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should pause video', () => {
|
|
136
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
137
|
+
const pauseSpy = jest.spyOn(video, 'pause');
|
|
138
|
+
|
|
139
|
+
player.pause();
|
|
140
|
+
expect(pauseSpy).toHaveBeenCalled();
|
|
141
|
+
expect(player.isPaused()).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should seek to position', () => {
|
|
145
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
146
|
+
player.seek(10);
|
|
147
|
+
expect(video.currentTime).toBe(10);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should stop playback', () => {
|
|
151
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
152
|
+
player.stop();
|
|
153
|
+
expect(video.currentTime).toBe(0);
|
|
154
|
+
expect(player.isEnded()).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('volume controls', () => {
|
|
159
|
+
beforeEach(async () => {
|
|
160
|
+
await player.initialize(container);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should set volume', () => {
|
|
164
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
165
|
+
player.setVolume(0.5);
|
|
166
|
+
expect(video.volume).toBe(0.5);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should clamp volume between 0 and 1', () => {
|
|
170
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
171
|
+
|
|
172
|
+
player.setVolume(-1);
|
|
173
|
+
expect(video.volume).toBe(0);
|
|
174
|
+
|
|
175
|
+
player.setVolume(2);
|
|
176
|
+
expect(video.volume).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should mute video', () => {
|
|
180
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
181
|
+
player.mute();
|
|
182
|
+
expect(video.muted).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should unmute video', () => {
|
|
186
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
187
|
+
player.mute();
|
|
188
|
+
player.unmute();
|
|
189
|
+
expect(video.muted).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should toggle mute state', () => {
|
|
193
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
194
|
+
const initialMuted = video.muted;
|
|
195
|
+
|
|
196
|
+
player.toggleMute();
|
|
197
|
+
expect(video.muted).toBe(!initialMuted);
|
|
198
|
+
|
|
199
|
+
player.toggleMute();
|
|
200
|
+
expect(video.muted).toBe(initialMuted);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('event handling', () => {
|
|
205
|
+
beforeEach(async () => {
|
|
206
|
+
await player.initialize(container);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should emit play event', async () => {
|
|
210
|
+
const callback = jest.fn();
|
|
211
|
+
player.on('onPlay', callback);
|
|
212
|
+
|
|
213
|
+
await player.play();
|
|
214
|
+
expect(callback).toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should emit pause event', () => {
|
|
218
|
+
const callback = jest.fn();
|
|
219
|
+
player.on('onPause', callback);
|
|
220
|
+
|
|
221
|
+
player.pause();
|
|
222
|
+
expect(callback).toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should emit timeupdate event', () => {
|
|
226
|
+
const callback = jest.fn();
|
|
227
|
+
player.on('onTimeUpdate', callback);
|
|
228
|
+
|
|
229
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
230
|
+
video.dispatchEvent(new Event('timeupdate'));
|
|
231
|
+
|
|
232
|
+
expect(callback).toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should remove event listener', () => {
|
|
236
|
+
const callback = jest.fn();
|
|
237
|
+
player.on('onPlay', callback);
|
|
238
|
+
player.off('onPlay', callback);
|
|
239
|
+
|
|
240
|
+
player.play();
|
|
241
|
+
expect(callback).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should handle one-time event', async () => {
|
|
245
|
+
const callback = jest.fn();
|
|
246
|
+
player.once('onPlay', callback);
|
|
247
|
+
|
|
248
|
+
await player.play();
|
|
249
|
+
await player.play();
|
|
250
|
+
|
|
251
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('state management', () => {
|
|
256
|
+
beforeEach(async () => {
|
|
257
|
+
await player.initialize(container);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should return current player state', () => {
|
|
261
|
+
const state = player.getState();
|
|
262
|
+
|
|
263
|
+
expect(state).toHaveProperty('isPlaying');
|
|
264
|
+
expect(state).toHaveProperty('isPaused');
|
|
265
|
+
expect(state).toHaveProperty('currentTime');
|
|
266
|
+
expect(state).toHaveProperty('duration');
|
|
267
|
+
expect(state).toHaveProperty('volume');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should update playback rate', () => {
|
|
271
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
272
|
+
|
|
273
|
+
player.setPlaybackRate(1.5);
|
|
274
|
+
expect(video.playbackRate).toBe(1.5);
|
|
275
|
+
expect(player.getPlaybackRate()).toBe(1.5);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should get current time', () => {
|
|
279
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
280
|
+
Object.defineProperty(video, 'currentTime', {
|
|
281
|
+
value: 30,
|
|
282
|
+
configurable: true
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(player.getCurrentTime()).toBe(30);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should get duration', () => {
|
|
289
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
290
|
+
Object.defineProperty(video, 'duration', {
|
|
291
|
+
value: 120,
|
|
292
|
+
configurable: true
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const callback = jest.fn();
|
|
296
|
+
player.on('onLoadedMetadata', callback);
|
|
297
|
+
video.dispatchEvent(new Event('loadedmetadata'));
|
|
298
|
+
|
|
299
|
+
expect(player.getDuration()).toBe(120);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('cleanup', () => {
|
|
304
|
+
it('should destroy player and clean up resources', async () => {
|
|
305
|
+
await player.initialize(container);
|
|
306
|
+
await player.load({ url: 'test.mp4' });
|
|
307
|
+
|
|
308
|
+
await player.destroy();
|
|
309
|
+
|
|
310
|
+
expect(container.innerHTML).toBe('');
|
|
311
|
+
expect(player.getState().isPlaying).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unified-video/web
|
|
3
|
+
* Web implementation of the Unified Video Framework
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Re-export core interfaces for convenience
|
|
7
|
+
export * from '@unified-video/core';
|
|
8
|
+
|
|
9
|
+
// Export web player implementation
|
|
10
|
+
export { WebPlayer } from './WebPlayer';
|
|
11
|
+
export { WebPlayerView } from './react/WebPlayerView';
|
|
12
|
+
|
|
13
|
+
// Version
|
|
14
|
+
export const VERSION = '1.0.0';
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { PaywallConfig } from '@unified-video/core';
|
|
2
|
+
|
|
3
|
+
export type PaywallControllerOptions = {
|
|
4
|
+
getOverlayContainer: () => HTMLElement | null;
|
|
5
|
+
onResume: () => void;
|
|
6
|
+
onShow?: () => void;
|
|
7
|
+
onClose?: () => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export class PaywallController {
|
|
11
|
+
private config: PaywallConfig | null = null;
|
|
12
|
+
private opts: PaywallControllerOptions;
|
|
13
|
+
private overlayEl: HTMLElement | null = null;
|
|
14
|
+
private gatewayStepEl: HTMLElement | null = null;
|
|
15
|
+
private popup: Window | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(config: PaywallConfig | null, opts: PaywallControllerOptions) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
try {
|
|
21
|
+
window.addEventListener('message', this.onMessage, false);
|
|
22
|
+
} catch (_) {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
updateConfig(config: PaywallConfig | null) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
openOverlay() {
|
|
30
|
+
if (!this.config?.enabled) return;
|
|
31
|
+
const root = this.ensureOverlay();
|
|
32
|
+
if (!root) return;
|
|
33
|
+
root.style.display = 'flex';
|
|
34
|
+
root.classList.add('active');
|
|
35
|
+
this.opts.onShow?.();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
closeOverlay() {
|
|
39
|
+
if (this.overlayEl) {
|
|
40
|
+
this.overlayEl.classList.remove('active');
|
|
41
|
+
this.overlayEl.style.display = 'none';
|
|
42
|
+
}
|
|
43
|
+
this.opts.onClose?.();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private ensureOverlay(): HTMLElement | null {
|
|
47
|
+
if (this.overlayEl && document.body.contains(this.overlayEl)) return this.overlayEl;
|
|
48
|
+
|
|
49
|
+
const container = this.opts.getOverlayContainer() || document.body;
|
|
50
|
+
const ov = document.createElement('div');
|
|
51
|
+
ov.className = 'uvf-paywall-overlay';
|
|
52
|
+
ov.setAttribute('role', 'dialog');
|
|
53
|
+
ov.setAttribute('aria-modal', 'true');
|
|
54
|
+
ov.style.cssText = 'position:absolute;inset:0;background:rgba(0,0,0,0.85);z-index:2147483000;display:none;align-items:center;justify-content:center;';
|
|
55
|
+
|
|
56
|
+
const modal = document.createElement('div');
|
|
57
|
+
modal.className = 'uvf-paywall-modal';
|
|
58
|
+
modal.style.cssText = 'width:80vw;height:80vh;max-width:1100px;max-height:800px;background:#0f0f10;border:1px solid rgba(255,255,255,0.15);border-radius:12px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.7)';
|
|
59
|
+
|
|
60
|
+
const header = document.createElement('div');
|
|
61
|
+
header.style.cssText = 'display:flex;gap:16px;align-items:center;padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.1)';
|
|
62
|
+
const hTitle = document.createElement('div');
|
|
63
|
+
hTitle.textContent = (this.config?.branding?.title || 'Continue watching');
|
|
64
|
+
hTitle.style.cssText = 'color:#fff;font-size:18px;font-weight:700';
|
|
65
|
+
const hDesc = document.createElement('div');
|
|
66
|
+
hDesc.textContent = (this.config?.branding?.description || 'Rent to continue watching this video.');
|
|
67
|
+
hDesc.style.cssText = 'color:rgba(255,255,255,0.75);font-size:14px;margin-top:4px';
|
|
68
|
+
const headerTextWrap = document.createElement('div');
|
|
69
|
+
headerTextWrap.appendChild(hTitle); headerTextWrap.appendChild(hDesc);
|
|
70
|
+
|
|
71
|
+
header.appendChild(headerTextWrap);
|
|
72
|
+
|
|
73
|
+
const content = document.createElement('div');
|
|
74
|
+
content.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;padding:20px;';
|
|
75
|
+
|
|
76
|
+
const intro = document.createElement('div');
|
|
77
|
+
intro.style.cssText = 'display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;';
|
|
78
|
+
const msg = document.createElement('div');
|
|
79
|
+
msg.textContent = 'Free preview ended. Rent to continue watching.';
|
|
80
|
+
msg.style.cssText = 'color:#fff;font-size:16px;';
|
|
81
|
+
const rentBtn = document.createElement('button');
|
|
82
|
+
rentBtn.textContent = 'Rent Now';
|
|
83
|
+
rentBtn.className = 'uvf-btn-primary';
|
|
84
|
+
rentBtn.style.cssText = 'background:linear-gradient(135deg,#ff4d4f,#d9363e);color:#fff;border:1px solid rgba(255,77,79,0.6);border-radius:999px;padding:10px 18px;cursor:pointer;';
|
|
85
|
+
rentBtn.addEventListener('click', () => this.showGateways());
|
|
86
|
+
intro.appendChild(msg); intro.appendChild(rentBtn);
|
|
87
|
+
|
|
88
|
+
const step = document.createElement('div');
|
|
89
|
+
step.style.cssText = 'display:none;flex-direction:column;gap:16px;align-items:center;justify-content:center;';
|
|
90
|
+
this.gatewayStepEl = step;
|
|
91
|
+
|
|
92
|
+
content.appendChild(intro);
|
|
93
|
+
content.appendChild(step);
|
|
94
|
+
modal.appendChild(header); modal.appendChild(content);
|
|
95
|
+
ov.appendChild(modal);
|
|
96
|
+
container.appendChild(ov);
|
|
97
|
+
this.overlayEl = ov;
|
|
98
|
+
return ov;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private showGateways() {
|
|
102
|
+
if (!this.config) return;
|
|
103
|
+
this.gatewayStepEl!.innerHTML = '';
|
|
104
|
+
this.gatewayStepEl!.style.display = 'flex';
|
|
105
|
+
|
|
106
|
+
const title = document.createElement('div');
|
|
107
|
+
title.textContent = 'Choose a payment method';
|
|
108
|
+
title.style.cssText = 'color:#fff;font-size:16px;';
|
|
109
|
+
const wrap = document.createElement('div');
|
|
110
|
+
wrap.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center;';
|
|
111
|
+
|
|
112
|
+
for (const g of this.config.gateways) {
|
|
113
|
+
const btn = document.createElement('button');
|
|
114
|
+
btn.textContent = g === 'cashfree' ? 'Cashfree' : 'Stripe';
|
|
115
|
+
btn.style.cssText = 'background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);border-radius:8px;padding:12px 16px;cursor:pointer;min-width:120px;';
|
|
116
|
+
btn.addEventListener('click', () => this.openGateway(g));
|
|
117
|
+
wrap.appendChild(btn);
|
|
118
|
+
}
|
|
119
|
+
this.gatewayStepEl!.appendChild(title);
|
|
120
|
+
this.gatewayStepEl!.appendChild(wrap);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async openGateway(gateway: 'stripe' | 'cashfree') {
|
|
124
|
+
try {
|
|
125
|
+
if (!this.config) return;
|
|
126
|
+
const { apiBase, userId, videoId } = this.config;
|
|
127
|
+
const w = Math.min(window.screen.width - 100, this.config.popup?.width || 1000);
|
|
128
|
+
const h = Math.min(window.screen.height - 100, this.config.popup?.height || 800);
|
|
129
|
+
const left = Math.max(0, Math.round((window.screen.width - w) / 2));
|
|
130
|
+
const top = Math.max(0, Math.round((window.screen.height - h) / 2));
|
|
131
|
+
|
|
132
|
+
if (gateway === 'stripe') {
|
|
133
|
+
const res = await fetch(`${apiBase}/api/rentals/stripe/checkout-session`, {
|
|
134
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
userId, videoId,
|
|
137
|
+
successUrl: window.location.origin + window.location.pathname + '?rental=success&popup=1',
|
|
138
|
+
cancelUrl: window.location.origin + window.location.pathname + '?rental=cancel&popup=1'
|
|
139
|
+
})
|
|
140
|
+
});
|
|
141
|
+
const data = await res.json();
|
|
142
|
+
if (data?.url) {
|
|
143
|
+
try { this.popup && !this.popup.closed && this.popup.close(); } catch (_) {}
|
|
144
|
+
this.popup = window.open(data.url, 'uvfCheckout', `popup=1,width=${w},height=${h},left=${left},top=${top}`);
|
|
145
|
+
this.startPolling();
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (gateway === 'cashfree') {
|
|
151
|
+
const features = `popup=1,width=${w},height=${h},left=${left},top=${top}`;
|
|
152
|
+
// Pre-open a blank popup in direct response to the click to avoid popup blockers
|
|
153
|
+
let pre: Window | null = null;
|
|
154
|
+
try { pre = window.open('', 'uvfCheckout', features); } catch(_) { pre = null; }
|
|
155
|
+
const res = await fetch(`${apiBase}/api/rentals/cashfree/order`, {
|
|
156
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({ userId, videoId, returnUrl: window.location.origin + window.location.pathname })
|
|
158
|
+
});
|
|
159
|
+
const data = await res.json();
|
|
160
|
+
if (data?.paymentLink && data?.orderId) {
|
|
161
|
+
try { this.popup && !this.popup.closed && this.popup.close(); } catch (_) {}
|
|
162
|
+
this.popup = pre && !pre.closed ? pre : window.open('', 'uvfCheckout', features);
|
|
163
|
+
try { if (this.popup) this.popup.location.href = data.paymentLink; } catch(_) {}
|
|
164
|
+
(window as any)._uvf_cfOrderId = data.orderId;
|
|
165
|
+
this.startPolling();
|
|
166
|
+
} else {
|
|
167
|
+
// Close the pre-opened popup if we didn't get a link
|
|
168
|
+
try { pre && !pre.closed && pre.close(); } catch(_) {}
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
// noop
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private startPolling() {
|
|
178
|
+
// basic polling to detect entitlement or popup closed; the host page should also listen to postMessage
|
|
179
|
+
const timer = setInterval(async () => {
|
|
180
|
+
if (!this.config) { clearInterval(timer); return; }
|
|
181
|
+
if (this.popup && this.popup.closed) {
|
|
182
|
+
clearInterval(timer);
|
|
183
|
+
// user cancelled; leave overlay open at gateway selection
|
|
184
|
+
this.showGateways();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}, 3000);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private onMessage = async (ev: MessageEvent) => {
|
|
191
|
+
const d: any = ev?.data || {};
|
|
192
|
+
if (!d || d.type !== 'uvfCheckout') return;
|
|
193
|
+
try { if (this.popup && !this.popup.closed) this.popup.close(); } catch (_) {}
|
|
194
|
+
this.popup = null;
|
|
195
|
+
if (d.status === 'cancel') {
|
|
196
|
+
this.showGateways();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (d.status === 'success') {
|
|
200
|
+
try {
|
|
201
|
+
if (d.sessionId && this.config) {
|
|
202
|
+
await fetch(`${this.config.apiBase}/api/rentals/stripe/confirm`, {
|
|
203
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
204
|
+
body: JSON.stringify({ sessionId: d.sessionId })
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (d.orderId && this.config) {
|
|
208
|
+
await fetch(`${this.config.apiBase}/api/rentals/cashfree/verify?orderId=${encodeURIComponent(d.orderId)}&userId=${encodeURIComponent(this.config.userId)}&videoId=${encodeURIComponent(this.config.videoId)}`);
|
|
209
|
+
}
|
|
210
|
+
} catch (_) {}
|
|
211
|
+
this.closeOverlay();
|
|
212
|
+
this.opts.onResume();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|