Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit f5aef7e

Browse files
authored
Merge pull request #25 from NuclearPlayer/feat/youtube-streaming
Yt streaming
2 parents b551ec4 + f6f1484 commit f5aef7e

File tree

89 files changed

+3038
-676
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

89 files changed

+3038
-676
lines changed
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import {
2+
Children,
3+
cloneElement,
4+
isValidElement,
5+
useCallback,
6+
useEffect,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from 'react';
11+
12+
import { useAudioContext } from './hooks/useAudioContext';
13+
import { useAudioElementSource } from './hooks/useAudioElementSource';
14+
import { AudioSource, SoundProps } from './types';
15+
16+
const DEFAULT_CROSSFADE_MS = 0;
17+
18+
export const CrossfadeSound: React.FC<
19+
SoundProps & {
20+
crossfadeMs?: number;
21+
}
22+
> = ({
23+
src,
24+
status,
25+
seek,
26+
crossfadeMs = DEFAULT_CROSSFADE_MS,
27+
preload = 'auto',
28+
crossOrigin = '',
29+
onTimeUpdate,
30+
onEnd,
31+
onLoadStart,
32+
onError,
33+
children,
34+
}) => {
35+
const audioRefA = useRef<HTMLAudioElement | null>(null);
36+
const audioRefB = useRef<HTMLAudioElement | null>(null);
37+
const [activeIndex, setActiveIndex] = useState<number>(0);
38+
39+
const context = useAudioContext();
40+
const { source: sourceA, gain: gainA } = useAudioElementSource(
41+
audioRefA,
42+
context,
43+
);
44+
const { source: sourceB, gain: gainB } = useAudioElementSource(
45+
audioRefB,
46+
context,
47+
);
48+
const isReady = !!sourceA && !!gainA && !!sourceB && !!gainB;
49+
50+
const prevSrc = useRef<AudioSource>(src);
51+
52+
const adapters = useMemo(
53+
() =>
54+
[
55+
{
56+
id: 0,
57+
ref: audioRefA,
58+
source: sourceA,
59+
gain: gainA,
60+
},
61+
{
62+
id: 1,
63+
ref: audioRefB,
64+
source: sourceB,
65+
gain: gainB,
66+
},
67+
] as const,
68+
[sourceA, sourceB, gainA, gainB],
69+
);
70+
71+
const current = adapters[activeIndex];
72+
const next = adapters[1 - activeIndex];
73+
74+
useEffect(() => {
75+
if (!isReady) {
76+
return;
77+
}
78+
const audio = current.ref.current;
79+
const inactiveAudio = next.ref.current;
80+
if (!audio) {
81+
return;
82+
}
83+
switch (status) {
84+
case 'playing': {
85+
context?.resume();
86+
audio.play();
87+
break;
88+
}
89+
case 'paused': {
90+
audio.pause();
91+
inactiveAudio?.pause();
92+
break;
93+
}
94+
case 'stopped': {
95+
audio.pause();
96+
audio.currentTime = 0;
97+
inactiveAudio?.pause();
98+
if (inactiveAudio) {
99+
inactiveAudio.currentTime = 0;
100+
}
101+
break;
102+
}
103+
}
104+
}, [status, isReady, activeIndex, context, next.ref]);
105+
106+
const lastSeekRef = useRef<number | undefined>(undefined);
107+
useEffect(() => {
108+
if (!isReady) {
109+
return;
110+
}
111+
const audio = current.ref.current;
112+
if (!audio || seek == null) {
113+
return;
114+
}
115+
116+
const currentTime = audio.currentTime;
117+
const seekDelta = Math.abs(seek - currentTime);
118+
119+
if (lastSeekRef.current !== seek && seekDelta > 0.5) {
120+
audio.currentTime = seek;
121+
}
122+
lastSeekRef.current = seek;
123+
}, [seek, isReady, activeIndex]);
124+
125+
useEffect(() => {
126+
if (!isReady) {
127+
return;
128+
}
129+
if (src === prevSrc.current) {
130+
return;
131+
}
132+
const nextIndex = 1 - activeIndex;
133+
if (crossfadeMs === 0) {
134+
setActiveIndex(nextIndex);
135+
prevSrc.current = src;
136+
return;
137+
}
138+
const currentGain = current.gain;
139+
const nextGain = next.gain;
140+
const currentAudio = current.ref.current;
141+
const nextAudio = next.ref.current;
142+
if (!currentGain || !nextGain || !nextAudio || !context) {
143+
return;
144+
}
145+
nextGain.gain.setValueAtTime(0, context.currentTime);
146+
nextAudio.load();
147+
nextAudio.play();
148+
nextGain.gain.linearRampToValueAtTime(
149+
1,
150+
context.currentTime + crossfadeMs / 1000,
151+
);
152+
currentGain.gain.linearRampToValueAtTime(
153+
0,
154+
context.currentTime + crossfadeMs / 1000,
155+
);
156+
setTimeout(() => {
157+
setActiveIndex(nextIndex);
158+
if (currentAudio) {
159+
currentAudio.pause();
160+
}
161+
prevSrc.current = src;
162+
}, crossfadeMs);
163+
}, [
164+
src,
165+
crossfadeMs,
166+
isReady,
167+
activeIndex,
168+
current.gain,
169+
next.gain,
170+
context,
171+
]);
172+
173+
const handleTimeUpdate = useCallback(
174+
(e: React.SyntheticEvent<HTMLAudioElement>) => {
175+
if (onTimeUpdate) {
176+
const el = e.currentTarget;
177+
onTimeUpdate({ position: el.currentTime, duration: el.duration });
178+
}
179+
},
180+
[onTimeUpdate],
181+
);
182+
183+
const handleError = useCallback(
184+
(e: React.SyntheticEvent<HTMLAudioElement>) => {
185+
if (onError) {
186+
const el = e.currentTarget as HTMLAudioElement & {
187+
error: MediaError | null;
188+
};
189+
onError(new Error(el.error?.message || 'Unknown audio error'));
190+
}
191+
},
192+
[onError],
193+
);
194+
195+
return (
196+
<>
197+
{[adapters[0], adapters[1]].map((adapter) => (
198+
<audio
199+
key={adapter.id}
200+
ref={adapter.ref}
201+
hidden
202+
preload={preload}
203+
crossOrigin={crossOrigin}
204+
data-is-active={activeIndex === adapter.id}
205+
onTimeUpdate={handleTimeUpdate}
206+
onEnded={onEnd}
207+
onLoadStart={onLoadStart}
208+
onError={handleError}
209+
>
210+
<source src={activeIndex === adapter.id ? prevSrc.current : src} />
211+
</audio>
212+
))}
213+
{isReady &&
214+
context &&
215+
children &&
216+
Children.map(children, (child, idx) =>
217+
isValidElement(child)
218+
? cloneElement(
219+
child as React.ReactElement<Record<string, unknown>>,
220+
{
221+
audioContext: context,
222+
previousNode:
223+
idx === 0 ? (current.source ?? undefined) : undefined,
224+
},
225+
)
226+
: child,
227+
)}
228+
</>
229+
);
230+
};

0 commit comments

Comments
 (0)