@cinecrew/cinecrew-player 0.1.3 → 0.1.5
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/README.md +56 -46
- package/package.json +1 -2
- package/src/native/InlineLivePlayer.js +95 -33
- package/src/native/MediaPlayerView.js +75 -73
- package/src/native/index.js +4 -5
- package/src/native/media/LiveChatDrawer.js +16 -25
- package/src/native/media/WebVideoPlayer.js +13 -0
- package/src/native/media/player/AudioOnlyView.js +3 -40
- package/src/native/media/player/PlayerBottomBar.js +4 -12
- package/src/native/media/player/playerConstants.js +3 -3
- package/src/native/media/web/useWebAc3AudioPlayback.web.js +9 -3
- package/src/native/media/web/useWebHlsPlayback.web.js +1 -0
- package/src/native/media/web/useWebVideoAspectRatio.js +7 -2
- package/src/utils/invokePlayerAction.js +24 -0
- package/src/utils/progressBarTime.js +46 -0
- package/src/utils/sourceUtils.js +0 -25
- package/src/utils/webRecording.js +194 -0
- package/src/web/index.js +523 -156
- package/src/web/styles.css +142 -23
- package/types/index.d.ts +21 -2
- package/src/native/media/YouTubeVideoPlayer.native.js +0 -120
- package/src/native/media/YouTubeVideoPlayer.web.js +0 -223
- package/src/utils/youtubeHtml.js +0 -34
package/src/web/styles.css
CHANGED
|
@@ -28,8 +28,7 @@
|
|
|
28
28
|
background: #000;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
.cinecrew-player__video
|
|
32
|
-
.cinecrew-player__youtube {
|
|
31
|
+
.cinecrew-player__video {
|
|
33
32
|
transition: width 180ms ease, height 180ms ease, top 180ms ease, left 180ms ease, transform 180ms ease;
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -40,10 +39,9 @@
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
.cinecrew-player__title {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
right: 20px;
|
|
42
|
+
flex: 1 1 auto;
|
|
43
|
+
min-width: 0;
|
|
44
|
+
text-align: center;
|
|
47
45
|
z-index: 2;
|
|
48
46
|
overflow: hidden;
|
|
49
47
|
font-weight: 700;
|
|
@@ -52,6 +50,18 @@
|
|
|
52
50
|
text-shadow: 0 1px 4px #000;
|
|
53
51
|
}
|
|
54
52
|
|
|
53
|
+
.cinecrew-player__inline-title {
|
|
54
|
+
flex: 0 1 auto;
|
|
55
|
+
min-width: 0;
|
|
56
|
+
max-width: min(52vw, 420px);
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
color: var(--cinecrew-text, #fff);
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
text-overflow: ellipsis;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
text-shadow: 0 1px 4px #000;
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
.cinecrew-player__controls {
|
|
56
66
|
position: absolute;
|
|
57
67
|
inset: 0;
|
|
@@ -70,6 +80,14 @@
|
|
|
70
80
|
opacity: 1;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
.cinecrew-player__controls.is-recording { opacity: 1; }
|
|
84
|
+
.cinecrew-player__controls.is-recording > .cinecrew-player__top-controls,
|
|
85
|
+
.cinecrew-player__controls.is-recording > .cinecrew-player__center-controls,
|
|
86
|
+
.cinecrew-player__controls.is-recording > .cinecrew-player__bottom-controls {
|
|
87
|
+
visibility: hidden;
|
|
88
|
+
pointer-events: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
.cinecrew-player__top-controls,
|
|
74
92
|
.cinecrew-player__bottom-controls,
|
|
75
93
|
.cinecrew-player__center-controls,
|
|
@@ -103,8 +121,11 @@
|
|
|
103
121
|
|
|
104
122
|
.cinecrew-player__top-right-actions {
|
|
105
123
|
justify-content: flex-end;
|
|
124
|
+
flex: 0 0 auto;
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
.cinecrew-player__top-left-actions { flex: 0 0 auto; }
|
|
128
|
+
|
|
108
129
|
.cinecrew-player__bottom-controls {
|
|
109
130
|
width: 100%;
|
|
110
131
|
flex-direction: column;
|
|
@@ -128,6 +149,57 @@
|
|
|
128
149
|
justify-content: center;
|
|
129
150
|
}
|
|
130
151
|
|
|
152
|
+
.cinecrew-player__recording-overlay {
|
|
153
|
+
position: absolute;
|
|
154
|
+
top: 10px;
|
|
155
|
+
left: 50%;
|
|
156
|
+
z-index: 5;
|
|
157
|
+
display: flex;
|
|
158
|
+
min-width: 0;
|
|
159
|
+
max-width: calc(100% - 24px);
|
|
160
|
+
box-sizing: border-box;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: center;
|
|
163
|
+
gap: 8px;
|
|
164
|
+
flex-wrap: wrap;
|
|
165
|
+
padding: 7px 10px;
|
|
166
|
+
border: 1px solid rgba(255,255,255,.22);
|
|
167
|
+
border-radius: 12px;
|
|
168
|
+
background: rgba(0, 0, 0, .68);
|
|
169
|
+
box-shadow: 0 6px 18px rgba(0,0,0,.28);
|
|
170
|
+
transform: translateX(-50%);
|
|
171
|
+
backdrop-filter: blur(8px);
|
|
172
|
+
font-size: .88em;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.cinecrew-player__recording-overlay.is-error { border-color: var(--cinecrew-accent); }
|
|
176
|
+
.cinecrew-player__recording-indicator { color: #ff4b63; font-size: .78em; font-weight: 800; letter-spacing: .08em; }
|
|
177
|
+
.cinecrew-player__recording-timer { font-variant-numeric: tabular-nums; font-size: 1.05em; }
|
|
178
|
+
.cinecrew-player__recording-actions { display: flex; gap: 6px; }
|
|
179
|
+
.cinecrew-player__recording-actions button,
|
|
180
|
+
.cinecrew-player__recording-overlay > button {
|
|
181
|
+
border: 1px solid rgba(255,255,255,.2);
|
|
182
|
+
border-radius: 999px;
|
|
183
|
+
padding: 6px 9px;
|
|
184
|
+
color: inherit;
|
|
185
|
+
background: rgba(255,255,255,.1);
|
|
186
|
+
cursor: pointer;
|
|
187
|
+
font: inherit;
|
|
188
|
+
}
|
|
189
|
+
.cinecrew-player__recording-download {
|
|
190
|
+
display: inline-flex;
|
|
191
|
+
align-items: center;
|
|
192
|
+
border: 1px solid rgba(255,255,255,.2);
|
|
193
|
+
border-radius: 999px;
|
|
194
|
+
padding: 6px 9px;
|
|
195
|
+
color: inherit;
|
|
196
|
+
background: rgba(255,255,255,.1);
|
|
197
|
+
text-decoration: none;
|
|
198
|
+
font: inherit;
|
|
199
|
+
}
|
|
200
|
+
.cinecrew-player__recording-actions button:last-child { border-color: #ff4b63; background: rgba(255,75,99,.2); }
|
|
201
|
+
.cinecrew-player__recording-ready { color: #70e3b1; }
|
|
202
|
+
|
|
131
203
|
.cinecrew-player__seek {
|
|
132
204
|
flex: 0 0 auto;
|
|
133
205
|
width: 100%;
|
|
@@ -431,7 +503,8 @@
|
|
|
431
503
|
grid-row: span 2;
|
|
432
504
|
place-items: center;
|
|
433
505
|
border-radius: 9px;
|
|
434
|
-
|
|
506
|
+
border: 1px solid color-mix(in srgb, var(--diagnostic-accent, #fff) 32%, transparent);
|
|
507
|
+
background: color-mix(in srgb, var(--diagnostic-accent, #fff) 14%, transparent);
|
|
435
508
|
}
|
|
436
509
|
|
|
437
510
|
.cinecrew-player__diagnostic-icon .cinecrew-player__icon { width: 18px; height: 18px; }
|
|
@@ -479,6 +552,16 @@
|
|
|
479
552
|
gap: 8px;
|
|
480
553
|
min-height: 0;
|
|
481
554
|
overflow: auto;
|
|
555
|
+
overscroll-behavior: contain;
|
|
556
|
+
scrollbar-width: thin;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.cinecrew-player__older-loading {
|
|
560
|
+
flex: 0 0 auto;
|
|
561
|
+
align-self: center;
|
|
562
|
+
padding: 6px 10px;
|
|
563
|
+
color: var(--cinecrew-accent);
|
|
564
|
+
font-size: .86em;
|
|
482
565
|
}
|
|
483
566
|
|
|
484
567
|
.cinecrew-player__chat-message,
|
|
@@ -527,6 +610,7 @@
|
|
|
527
610
|
|
|
528
611
|
.cinecrew-player__chat-composer {
|
|
529
612
|
position: relative;
|
|
613
|
+
container-type: inline-size;
|
|
530
614
|
display: flex;
|
|
531
615
|
align-items: center;
|
|
532
616
|
gap: 7px;
|
|
@@ -602,8 +686,9 @@
|
|
|
602
686
|
|
|
603
687
|
.cinecrew-player__emoji-picker {
|
|
604
688
|
position: absolute;
|
|
605
|
-
|
|
606
|
-
|
|
689
|
+
left: 0;
|
|
690
|
+
right: auto;
|
|
691
|
+
bottom: 100%;
|
|
607
692
|
z-index: 2;
|
|
608
693
|
display: flex;
|
|
609
694
|
flex-direction: column;
|
|
@@ -617,7 +702,31 @@
|
|
|
617
702
|
box-shadow: 0 10px 30px rgba(0, 0, 0, .42);
|
|
618
703
|
}
|
|
619
704
|
|
|
620
|
-
|
|
705
|
+
@supports (width: 1cqw) {
|
|
706
|
+
.cinecrew-player__emoji-picker { width: min(340px, 100cqw); }
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
.cinecrew-player__emoji-anchor {
|
|
710
|
+
position: relative;
|
|
711
|
+
display: inline-flex;
|
|
712
|
+
flex: 0 0 34px;
|
|
713
|
+
width: 34px;
|
|
714
|
+
height: 34px;
|
|
715
|
+
align-self: center;
|
|
716
|
+
align-items: center;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.cinecrew-player__emoji-anchor > .cinecrew-player__emoji-toggle {
|
|
720
|
+
box-sizing: border-box;
|
|
721
|
+
width: 34px;
|
|
722
|
+
min-width: 34px;
|
|
723
|
+
height: 34px;
|
|
724
|
+
min-height: 34px;
|
|
725
|
+
padding: 0;
|
|
726
|
+
line-height: 1;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-search {
|
|
621
730
|
flex: 0 0 auto;
|
|
622
731
|
width: 100%;
|
|
623
732
|
box-sizing: border-box;
|
|
@@ -631,7 +740,7 @@
|
|
|
631
740
|
font-size: 13px;
|
|
632
741
|
}
|
|
633
742
|
|
|
634
|
-
.cinecrew-
|
|
743
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-search:focus {
|
|
635
744
|
border-color: var(--cinecrew-accent);
|
|
636
745
|
}
|
|
637
746
|
|
|
@@ -652,7 +761,7 @@
|
|
|
652
761
|
font: inherit;
|
|
653
762
|
}
|
|
654
763
|
|
|
655
|
-
.cinecrew-
|
|
764
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-categories button {
|
|
656
765
|
display: grid;
|
|
657
766
|
min-width: 0;
|
|
658
767
|
min-height: 34px;
|
|
@@ -664,8 +773,8 @@
|
|
|
664
773
|
font-size: 19px;
|
|
665
774
|
}
|
|
666
775
|
|
|
667
|
-
.cinecrew-
|
|
668
|
-
.cinecrew-
|
|
776
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-categories button:hover,
|
|
777
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-categories button.is-active {
|
|
669
778
|
border-color: var(--cinecrew-accent);
|
|
670
779
|
background: color-mix(in srgb, var(--cinecrew-accent) 14%, transparent);
|
|
671
780
|
}
|
|
@@ -682,24 +791,27 @@
|
|
|
682
791
|
scrollbar-width: thin;
|
|
683
792
|
}
|
|
684
793
|
|
|
685
|
-
.cinecrew-
|
|
794
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-grid button {
|
|
686
795
|
display: grid;
|
|
687
796
|
width: 100%;
|
|
688
797
|
min-width: 0;
|
|
689
|
-
min-height:
|
|
798
|
+
min-height: 38px;
|
|
690
799
|
place-items: center;
|
|
691
800
|
border: 0;
|
|
692
|
-
border-radius:
|
|
801
|
+
border-radius: 50%;
|
|
693
802
|
padding: 0;
|
|
694
803
|
background: transparent;
|
|
695
|
-
font-size:
|
|
804
|
+
font-size: 23px;
|
|
805
|
+
line-height: 1;
|
|
806
|
+
transition: background-color .12s ease, transform .12s ease;
|
|
696
807
|
}
|
|
697
808
|
|
|
698
|
-
.cinecrew-
|
|
809
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-grid button:hover {
|
|
699
810
|
background: rgba(255, 255, 255, .12);
|
|
811
|
+
transform: scale(1.12);
|
|
700
812
|
}
|
|
701
813
|
|
|
702
|
-
.cinecrew-
|
|
814
|
+
.cinecrew-player__emoji-picker .cinecrew-player__emoji-grid .cinecrew-player__emoji-load-more {
|
|
703
815
|
grid-column: 1 / -1;
|
|
704
816
|
min-height: 34px;
|
|
705
817
|
border: 1px solid rgba(255, 255, 255, .14);
|
|
@@ -752,6 +864,12 @@
|
|
|
752
864
|
background: rgba(255,255,255,.1);
|
|
753
865
|
}
|
|
754
866
|
|
|
867
|
+
.cinecrew-player__menu .cinecrew-player__menu-option.is-selected {
|
|
868
|
+
color: var(--cinecrew-accent);
|
|
869
|
+
background: color-mix(in srgb, var(--cinecrew-accent) 18%, transparent);
|
|
870
|
+
font-weight: 700;
|
|
871
|
+
}
|
|
872
|
+
|
|
755
873
|
.cinecrew-player__panel {
|
|
756
874
|
position: absolute;
|
|
757
875
|
inset: 0 0 0 auto;
|
|
@@ -811,9 +929,11 @@
|
|
|
811
929
|
|
|
812
930
|
@media (max-width: 640px) {
|
|
813
931
|
.cinecrew-player__controls { padding: 10px; }
|
|
814
|
-
.cinecrew-player__top-controls { align-items:
|
|
815
|
-
.cinecrew-
|
|
932
|
+
.cinecrew-player__top-controls { align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
933
|
+
.cinecrew-player__title { order: 3; flex: 0 0 100%; box-sizing: border-box; padding: 0 4px; text-align: center; }
|
|
934
|
+
.cinecrew-player__top-left-actions { order: 1; gap: 4px; }
|
|
816
935
|
.cinecrew-player__top-right-actions {
|
|
936
|
+
order: 2;
|
|
817
937
|
max-width: calc(100% - 128px);
|
|
818
938
|
flex-wrap: wrap;
|
|
819
939
|
gap: 4px;
|
|
@@ -825,7 +945,6 @@
|
|
|
825
945
|
min-height: 34px;
|
|
826
946
|
padding: 6px 8px;
|
|
827
947
|
}
|
|
828
|
-
.cinecrew-player__title { top: 96px; left: 12px; right: 12px; }
|
|
829
948
|
.cinecrew-player__bottom-actions { align-items: flex-end; gap: 6px; }
|
|
830
949
|
.cinecrew-player__bottom-right-actions { max-width: 100%; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
|
|
831
950
|
.cinecrew-player__panel { width: min(380px, 92%); }
|
package/types/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type * as React from 'react';
|
|
2
2
|
|
|
3
3
|
export type AspectRatio = 'FIT' | 'FILL' | 'STRETCH' | 'FILL_SCREEN' | string;
|
|
4
|
+
export interface AspectRatioOption { value: AspectRatio; label?: string }
|
|
4
5
|
export type PlayerMediaType = 'live' | 'channel' | 'movie' | 'series' | string;
|
|
5
6
|
|
|
6
7
|
export interface PlayerSource {
|
|
@@ -57,6 +58,8 @@ export interface PlayerApi {
|
|
|
57
58
|
seekBy(seconds: number): void;
|
|
58
59
|
back(): void;
|
|
59
60
|
setPlaybackRate(rate: number): void;
|
|
61
|
+
setPanel(panel: 'chat' | 'epg' | 'diagnostics' | null): void;
|
|
62
|
+
closePanel(): void;
|
|
60
63
|
getVideoElement(): unknown | null;
|
|
61
64
|
getAudioTracks(): AudioTrack[];
|
|
62
65
|
enterFullscreen?(): void | Promise<void>;
|
|
@@ -77,9 +80,11 @@ export interface PlayerActionContext {
|
|
|
77
80
|
video?: unknown | null;
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/** App callback for a player action. Back is app-owned and has no built-in behavior. */
|
|
80
84
|
export type PlayerAction = (payload?: Record<string, unknown>, context?: PlayerActionContext) => unknown;
|
|
81
85
|
|
|
82
86
|
export interface PlayerActions {
|
|
87
|
+
/** User-owned navigation event. The player does not close or navigate on its own. */
|
|
83
88
|
onBack?: PlayerAction;
|
|
84
89
|
onPlayPause?: PlayerAction;
|
|
85
90
|
onSeek?: PlayerAction;
|
|
@@ -173,6 +178,7 @@ export interface PlayerIntegrations {
|
|
|
173
178
|
render?: (context: { title: string; source: PlayerSource; onClose: () => void }) => React.ReactNode;
|
|
174
179
|
};
|
|
175
180
|
recording?: {
|
|
181
|
+
/** Starts host-managed recording for the active direct media source. */
|
|
176
182
|
start?: (args: { getVideoElement: () => unknown | null; streamUrl: string; title?: string }) => Promise<unknown>;
|
|
177
183
|
pause?: () => Promise<unknown>;
|
|
178
184
|
resume?: () => Promise<unknown>;
|
|
@@ -205,13 +211,20 @@ export interface CineCrewPlayerProps {
|
|
|
205
211
|
volume?: number;
|
|
206
212
|
playbackRate?: number;
|
|
207
213
|
controls?: PlayerControls;
|
|
214
|
+
/** Show the playback progress/seek bar. When false, onProgressBarChange is not called. Defaults to true. */
|
|
215
|
+
showProgressBar?: boolean;
|
|
208
216
|
/** Web/Electron chat and EPG drawer behavior. Overlay keeps the video full-size; resize shrinks it to make room. */
|
|
209
217
|
drawerMode?: PlayerDrawerMode;
|
|
210
218
|
/** Web CSS or React Native view-style overrides for the chat, EPG, and diagnostics drawer. */
|
|
211
219
|
drawerStyle?: React.CSSProperties | import('react-native').ViewStyle;
|
|
212
|
-
/** Number of chat messages fetched per page
|
|
220
|
+
/** Number of chat messages fetched per page. Older pages load automatically when scrolling to the top. Defaults to 50. */
|
|
213
221
|
messagePageSize?: number;
|
|
222
|
+
/** Available aspect-ratio choices. Items may be values or labeled { value, label } options. */
|
|
223
|
+
aspectRatios?: Array<AspectRatio | AspectRatioOption>;
|
|
224
|
+
/** Initial aspect-ratio choice. Defaults to FIT. */
|
|
225
|
+
defaultAspectRatio?: AspectRatio;
|
|
214
226
|
features?: { diagnostics?: boolean; [key: string]: boolean | undefined };
|
|
227
|
+
/** App callbacks invoked after each corresponding built-in action. */
|
|
215
228
|
actions?: PlayerActions;
|
|
216
229
|
integrations?: PlayerIntegrations;
|
|
217
230
|
theme?: PlayerTheme;
|
|
@@ -234,8 +247,11 @@ export interface CineCrewPlayerProps {
|
|
|
234
247
|
playlist?: Array<Record<string, unknown>>;
|
|
235
248
|
shuffle?: boolean;
|
|
236
249
|
onClose?: () => void;
|
|
237
|
-
|
|
250
|
+
/** User-owned back event. The player does not close or navigate on its own. */
|
|
251
|
+
onBack?: PlayerAction;
|
|
238
252
|
onFullscreen?: (state: { isFullscreen: boolean }) => void;
|
|
253
|
+
/** Invoked after the player applies an aspect ratio selection; actions.onAspectRatioChange takes precedence when both are supplied. */
|
|
254
|
+
onAspectRatioChange?: PlayerAction;
|
|
239
255
|
onPlayerHostRef?: (node: unknown | null) => void;
|
|
240
256
|
onInlinePreviewWheel?: (deltaY: number) => void;
|
|
241
257
|
inlinePreview?: boolean;
|
|
@@ -245,6 +261,8 @@ export interface CineCrewPlayerProps {
|
|
|
245
261
|
liveChatNonce?: number;
|
|
246
262
|
onReady?: (playerOrEvent: unknown) => void;
|
|
247
263
|
onProgress?: (event: unknown) => void;
|
|
264
|
+
/** Called once for each elapsed playback second and after a completed seek/restart with a zero-padded HH:MM:SS position. */
|
|
265
|
+
onProgressBarChange?: (time: string) => void;
|
|
248
266
|
onPlaying?: (event: unknown) => void;
|
|
249
267
|
onBuffering?: (buffering: boolean) => void;
|
|
250
268
|
onError?: (error: Error | Record<string, unknown>) => void;
|
|
@@ -266,6 +284,7 @@ export interface InlineLivePlayerProps {
|
|
|
266
284
|
paused?: boolean;
|
|
267
285
|
isActive?: boolean;
|
|
268
286
|
onActivate?: () => void;
|
|
287
|
+
/** @deprecated Inline fullscreen is self-contained. Use actions.onFullscreen to observe its state. */
|
|
269
288
|
onFullscreen?: () => void;
|
|
270
289
|
controls?: Pick<PlayerControls, 'playPause' | 'mute' | 'fullscreen'>;
|
|
271
290
|
actions?: Pick<PlayerActions, 'onPlayPause' | 'onMute' | 'onFullscreen'>;
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
|
|
2
|
-
import { StyleSheet, View } from 'react-native';
|
|
3
|
-
import { WebView } from 'react-native-webview';
|
|
4
|
-
import { buildYouTubePlayerHtml } from '../../utils/youtubeHtml';
|
|
5
|
-
|
|
6
|
-
const stateName = (value) => ({ 0: 'ended', 1: 'playing', 2: 'paused', 3: 'buffering', 5: 'cued' })[value] || 'unstarted';
|
|
7
|
-
|
|
8
|
-
export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
|
|
9
|
-
videoId,
|
|
10
|
-
paused = false,
|
|
11
|
-
muted = false,
|
|
12
|
-
volume = 1,
|
|
13
|
-
playbackRate = 1,
|
|
14
|
-
onReady,
|
|
15
|
-
onProgress,
|
|
16
|
-
onPlaying,
|
|
17
|
-
onBuffering,
|
|
18
|
-
onStateChange,
|
|
19
|
-
onError,
|
|
20
|
-
onEnded,
|
|
21
|
-
}, ref) {
|
|
22
|
-
const webViewRef = useRef(null);
|
|
23
|
-
const handlersRef = useRef({});
|
|
24
|
-
const latestPropsRef = useRef({});
|
|
25
|
-
const readyRef = useRef(false);
|
|
26
|
-
handlersRef.current = { onReady, onProgress, onPlaying, onBuffering, onStateChange, onError, onEnded };
|
|
27
|
-
latestPropsRef.current = { paused, muted, volume, playbackRate };
|
|
28
|
-
|
|
29
|
-
const sendCommand = useCallback((name, value) => {
|
|
30
|
-
const serialized = JSON.stringify(value ?? null);
|
|
31
|
-
webViewRef.current?.injectJavaScript(
|
|
32
|
-
`window.cinecrewPlayerCommand && window.cinecrewPlayerCommand(${JSON.stringify(name)}, ${serialized}); true;`
|
|
33
|
-
);
|
|
34
|
-
}, []);
|
|
35
|
-
|
|
36
|
-
const handleMessage = useCallback((event) => {
|
|
37
|
-
let message;
|
|
38
|
-
try { message = JSON.parse(event.nativeEvent.data); } catch { return; }
|
|
39
|
-
const handlers = handlersRef.current;
|
|
40
|
-
if (message.type === 'ready') {
|
|
41
|
-
readyRef.current = true;
|
|
42
|
-
handlers.onBuffering?.(false);
|
|
43
|
-
handlers.onReady?.({ youtube: true });
|
|
44
|
-
const latest = latestPropsRef.current;
|
|
45
|
-
sendCommand(latest.paused ? 'pause' : 'play');
|
|
46
|
-
sendCommand(latest.muted ? 'mute' : 'unmute');
|
|
47
|
-
sendCommand('volume', Math.round(Math.max(0, Math.min(1, latest.volume)) * 100));
|
|
48
|
-
sendCommand('rate', latest.playbackRate);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (message.type === 'state') {
|
|
52
|
-
const state = stateName(Number(message.data?.state));
|
|
53
|
-
handlers.onStateChange?.(state);
|
|
54
|
-
handlers.onBuffering?.(state === 'buffering');
|
|
55
|
-
if (state === 'playing') handlers.onPlaying?.({ state, youtube: true });
|
|
56
|
-
if (state === 'ended') handlers.onEnded?.();
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (message.type === 'progress') {
|
|
60
|
-
const currentTime = Number(message.data?.currentTime) || 0;
|
|
61
|
-
const duration = Number(message.data?.duration) || 0;
|
|
62
|
-
handlers.onProgress?.({ currentTime: currentTime * 1000, duration: duration * 1000, target: currentTime });
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (message.type === 'error') {
|
|
66
|
-
handlers.onError?.({ code: message.data?.code, message: `YouTube playback failed (${message.data?.code}).` });
|
|
67
|
-
}
|
|
68
|
-
}, [sendCommand]);
|
|
69
|
-
|
|
70
|
-
useEffect(() => { if (readyRef.current) sendCommand(paused ? 'pause' : 'play'); }, [paused, sendCommand]);
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (!readyRef.current) return;
|
|
73
|
-
sendCommand(muted ? 'mute' : 'unmute');
|
|
74
|
-
sendCommand('volume', Math.round(Math.max(0, Math.min(1, volume)) * 100));
|
|
75
|
-
}, [muted, volume, sendCommand]);
|
|
76
|
-
useEffect(() => { if (readyRef.current) sendCommand('rate', playbackRate); }, [playbackRate, sendCommand]);
|
|
77
|
-
|
|
78
|
-
const validVideoId = /^[\w-]{11}$/.test(String(videoId || ''));
|
|
79
|
-
const html = useMemo(
|
|
80
|
-
() => validVideoId ? buildYouTubePlayerHtml(videoId, !paused) : '<!doctype html><html><body style="background:#000"></body></html>',
|
|
81
|
-
[validVideoId, videoId],
|
|
82
|
-
);
|
|
83
|
-
const webViewSource = useMemo(() => ({ html, baseUrl: 'https://www.youtube.com' }), [html]);
|
|
84
|
-
|
|
85
|
-
useEffect(() => {
|
|
86
|
-
if (!validVideoId) handlersRef.current.onError?.(new Error('A valid YouTube video ID is required.'));
|
|
87
|
-
}, [validVideoId]);
|
|
88
|
-
|
|
89
|
-
useImperativeHandle(ref, () => ({
|
|
90
|
-
play: () => sendCommand('play'),
|
|
91
|
-
resume: () => sendCommand('play'),
|
|
92
|
-
pause: () => sendCommand('pause'),
|
|
93
|
-
seek: (ratio) => sendCommand('seekRatio', Math.max(0, Math.min(1, Number(ratio) || 0))),
|
|
94
|
-
seekTo: (seconds) => sendCommand('seek', Math.max(0, Number(seconds) || 0)),
|
|
95
|
-
getVideoElement: () => null,
|
|
96
|
-
}), [sendCommand]);
|
|
97
|
-
|
|
98
|
-
return React.createElement(View, { style: styles.container }, React.createElement(WebView, {
|
|
99
|
-
ref: webViewRef,
|
|
100
|
-
source: webViewSource,
|
|
101
|
-
originWhitelist: ['*'],
|
|
102
|
-
javaScriptEnabled: true,
|
|
103
|
-
domStorageEnabled: true,
|
|
104
|
-
allowsInlineMediaPlayback: true,
|
|
105
|
-
mediaPlaybackRequiresUserAction: false,
|
|
106
|
-
allowsFullscreenVideo: false,
|
|
107
|
-
scrollEnabled: false,
|
|
108
|
-
bounces: false,
|
|
109
|
-
onMessage: handleMessage,
|
|
110
|
-
onError: (event) => handlersRef.current.onError?.(event.nativeEvent || event),
|
|
111
|
-
style: styles.webView,
|
|
112
|
-
}));
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const styles = StyleSheet.create({
|
|
116
|
-
container: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
|
|
117
|
-
webView: { flex: 1, backgroundColor: '#000' },
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
export default YouTubeVideoPlayer;
|