Skip to content

Commit 65fa568

Browse files
authored
feat: add muteChange event with subscription-based muted state tracking (#85)
* feat: add muteChange event with subscription-based muted state tracking * docs: document muteChange event usage and tracking behavior in README files * chore: update changeset * refactor: avoid pre-ready muteChange emission before player initialization * fix: align onReady muted payload with normalized readyMuted value
1 parent 3cad8a0 commit 65fa568

19 files changed

Lines changed: 319 additions & 28 deletions

.changeset/flat-bats-greet.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"react-native-youtube-bridge": minor
3+
"@react-native-youtube-bridge/core": minor
4+
"@react-native-youtube-bridge/web": minor
5+
---
6+
7+
feat: add muteChange event with subscription-based muted state tracking
8+
9+
- Add `muteChange` event for real-time muted state updates.
10+
- Forward `muteChange` through core/web/webview/react-native bridge layers.
11+
- Enable muted-state tracking only while `muteChange` is subscribed (performance optimization).
12+
- Keep replay mute-preservation behavior intact.
13+
- Update the example app to use `useYouTubeEvent(player, 'muteChange', false)` for muted state.
14+
- Update README docs (EN/KO, root + package) to document `muteChange` usage and tracking behavior.
15+
16+
```tsx
17+
import { YoutubeView, useYouTubeEvent, useYouTubePlayer } from 'react-native-youtube-bridge';
18+
19+
function App() {
20+
const player = useYouTubePlayer(videoIdOrUrl);
21+
22+
// 1. State-based event listening
23+
const isMuted = useYouTubeEvent(player, 'muteChange', false);
24+
25+
// 2. Callback-based event listening
26+
useYouTubeEvent(player, 'muteChange', (muted) => {
27+
console.log('Player is muted:', muted);
28+
});
29+
30+
return <YoutubeView player={player} />;
31+
}
32+
```

README-ko_kr.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ import { YoutubeView, useYouTubeEvent, useYouTubePlayer } from 'react-native-you
7373
function App() {
7474
const player = useYouTubePlayer(videoIdOrUrl);
7575

76+
// State-based event listening
7677
const playbackRate = useYouTubeEvent(player, 'playbackRateChange', 1);
78+
const isMuted = useYouTubeEvent(player, 'muteChange', false);
7779
const progress = useYouTubeEvent(player, 'progress', progressInterval);
7880

81+
// Callback-based event listening
7982
useYouTubeEvent(player, 'ready', (playerInfo) => {
8083
console.log('Player is ready!');
8184
Alert.alert('Alert', 'YouTube player is ready!');
@@ -94,6 +97,9 @@ function App() {
9497
}
9598
```
9699

100+
`muteChange` 이벤트를 구독하면 YouTube 플레이어 기본 UI의 스피커 버튼 또는 `player.mute()` / `player.unMute()` 호출로 변경된 음소거 상태를 실시간으로 받을 수 있습니다.
101+
성능 최적화를 위해 `muteChange`를 구독할 때만 muted tracking이 활성화됩니다.
102+
97103
`useYouTubeEvent` hook은 callback으로 값을 전달받는 방식과 state로 값을 바로 사용할 수 있는 두 가지 방법을 제공합니다.
98104

99105
1. Callback 방식: 의존성에 따라 리렌더링이 필요한 경우 4번째 인자에 dependency array를 주입해주면 됩니다.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ import { YoutubeView, useYouTubeEvent, useYouTubePlayer } from 'react-native-you
7373
function App() {
7474
const player = useYouTubePlayer(videoIdOrUrl);
7575

76+
// State-based event listening
7677
const playbackRate = useYouTubeEvent(player, 'playbackRateChange', 1);
78+
const isMuted = useYouTubeEvent(player, 'muteChange', false);
7779
const progress = useYouTubeEvent(player, 'progress', progressInterval);
7880

81+
// Callback-based event listening
7982
useYouTubeEvent(player, 'ready', (playerInfo) => {
8083
console.log('Player is ready!');
8184
Alert.alert('Alert', 'YouTube player is ready!');
@@ -94,6 +97,9 @@ function App() {
9497
}
9598
```
9699

100+
`muteChange` emits real-time muted state updates from both the YouTube player's built-in mute control and `player.mute()` / `player.unMute()`.
101+
For performance, muted tracking is enabled only while `muteChange` is subscribed.
102+
97103
The `useYouTubeEvent` hook provides two ways to receive values: callback-based and state-based.
98104

99105
1. **Callback method**: If re-rendering is needed based on dependencies, inject a dependency array as the 4th argument.

example/src/App.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ function App() {
2727
const [isPlaying, setIsPlaying] = useState(false);
2828
const [availableRates, setAvailableRates] = useState<number[]>([1]);
2929
const [volume, setVolume] = useState(100);
30-
const [isMuted, setIsMuted] = useState(false);
3130
const [videoId, setVideoId] = useState('AbZH7XWDW_k');
3231
const [progressInterval, setProgressInterval] = useState(1000);
3332
const { oEmbed, isLoading, error } = useYoutubeOEmbed(
@@ -42,6 +41,11 @@ function App() {
4241
muted: true,
4342
});
4443

44+
const isMuted = useYouTubeEvent(player, 'muteChange');
45+
const playbackRate = useYouTubeEvent(player, 'playbackRateChange', 1);
46+
const playbackQuality = useYouTubeEvent(player, 'playbackQualityChange');
47+
const progress = useYouTubeEvent(player, 'progress', progressInterval);
48+
4549
const changePlaybackRate = (rate: number) => {
4650
player.setPlaybackRate(rate);
4751
};
@@ -54,12 +58,10 @@ function App() {
5458
const toggleMute = useCallback(() => {
5559
if (isMuted) {
5660
player.unMute();
57-
setIsMuted(false);
5861
return;
5962
}
6063

6164
player.mute();
62-
setIsMuted(true);
6365
}, [player, isMuted]);
6466

6567
const onPlay = useCallback(() => {
@@ -100,10 +102,6 @@ function App() {
100102
);
101103
};
102104

103-
const playbackRate = useYouTubeEvent(player, 'playbackRateChange', 1);
104-
const playbackQuality = useYouTubeEvent(player, 'playbackQualityChange');
105-
const progress = useYouTubeEvent(player, 'progress', progressInterval);
106-
107105
const currentTime = progress?.currentTime ?? 0;
108106
const duration = progress?.duration ?? 0;
109107
const loadedFraction = progress?.loadedFraction ?? 0;
@@ -123,10 +121,6 @@ function App() {
123121
if (playerInfo?.volume !== undefined) {
124122
setVolume(playerInfo.volume);
125123
}
126-
127-
if (playerInfo?.muted !== undefined) {
128-
setIsMuted(playerInfo.muted);
129-
}
130124
});
131125

132126
useYouTubeEvent(player, 'stateChange', (state) => {

packages/core/src/WebYoutubePlayerController.ts

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@ type PlayerConfig = Omit<YoutubePlayerConfig, 'source'> & {
77
videoId: string;
88
};
99

10+
const MUTED_TRACKING_INTERVAL_MS = 250;
11+
1012
class WebYoutubePlayerController {
1113
private player: YouTubePlayer | null = null;
1214
private progressInterval: NodeJS.Timeout | null = null;
1315
private callbacks: PlayerEvents = {};
1416
private progressIntervalMs = 1000;
1517
private seekTimeout: NodeJS.Timeout | null = null;
18+
private mutedTrackingInterval: NodeJS.Timeout | null = null;
19+
private isMutedSyncing = false;
20+
private lastKnownMuted: boolean | null = null;
21+
private mutedTrackingEnabled = false;
1622
private desiredMuted = false;
1723

1824
static createInstance(): WebYoutubePlayerController {
@@ -83,6 +89,8 @@ class WebYoutubePlayerController {
8389
}
8490

8591
this.desiredMuted = Boolean(config.playerVars?.muted);
92+
this.lastKnownMuted = null;
93+
this.stopMutedTracking();
8694

8795
if (this.player) {
8896
try {
@@ -111,29 +119,38 @@ class WebYoutubePlayerController {
111119
events: {
112120
onReady: (event) => {
113121
const { playerInfo } = event.target;
122+
const readyMuted =
123+
typeof playerInfo.muted === 'boolean' ? playerInfo.muted : this.desiredMuted;
114124

115125
this.callbacks.onReady?.({
116126
availablePlaybackRates: playerInfo.availablePlaybackRates,
117127
availableQualityLevels: playerInfo.availableQualityLevels,
118128
currentTime: playerInfo.currentTime,
119129
duration: playerInfo.duration,
120-
muted: playerInfo.muted,
130+
muted: readyMuted,
121131
playbackQuality: playerInfo.playbackQuality,
122132
playbackRate: playerInfo.playbackRate,
123133
playerState: playerInfo.playerState,
124134
size: playerInfo.size,
125135
volume: playerInfo.volume,
126136
});
127137

138+
this.updateMutedState(readyMuted, true);
128139
this.applyDesiredMutedState();
129140
this.startProgressTracking();
141+
this.startMutedTracking();
130142
},
131143
onStateChange: (event) => {
132144
const state = event.data;
133145
const mutedState = event.target?.playerInfo?.muted;
146+
147+
if (typeof mutedState === 'boolean') {
148+
this.updateMutedState(mutedState, true);
149+
}
150+
134151
this.callbacks.onStateChange?.(state);
135152

136-
this.handleStateChange(state, mutedState);
153+
this.handleStateChange(state);
137154
},
138155
onError: (event) => {
139156
console.error('YouTube player error:', event.data);
@@ -163,11 +180,7 @@ class WebYoutubePlayerController {
163180
});
164181
}
165182

166-
private handleStateChange(state: number, mutedState?: boolean): void {
167-
if (state !== PlayerState.PLAYING && typeof mutedState === 'boolean') {
168-
this.desiredMuted = mutedState;
169-
}
170-
183+
private handleStateChange(state: number): void {
171184
if (state === PlayerState.ENDED) {
172185
this.stopProgressTracking();
173186
this.sendProgress();
@@ -283,12 +296,22 @@ class WebYoutubePlayerController {
283296

284297
mute(): void {
285298
this.desiredMuted = true;
286-
this.player?.mute();
299+
if (!this.player) {
300+
return;
301+
}
302+
303+
this.player.mute();
304+
this.updateMutedState(true, true);
287305
}
288306

289307
unMute(): void {
290308
this.desiredMuted = false;
291-
this.player?.unMute();
309+
if (!this.player) {
310+
return;
311+
}
312+
313+
this.player.unMute();
314+
this.updateMutedState(false, true);
292315
}
293316

294317
async isMuted(): Promise<boolean> {
@@ -370,20 +393,85 @@ class WebYoutubePlayerController {
370393
this.callbacks = { ...this.callbacks, ...newCallbacks };
371394
}
372395

396+
setMutedTrackingEnabled(enabled: boolean): void {
397+
this.mutedTrackingEnabled = enabled;
398+
399+
if (enabled) {
400+
this.lastKnownMuted = null;
401+
this.startMutedTracking();
402+
void this.syncMutedStateFromPlayer();
403+
return;
404+
}
405+
406+
this.stopMutedTracking();
407+
}
408+
409+
private startMutedTracking(): void {
410+
if (!this.mutedTrackingEnabled || !this.player) {
411+
return;
412+
}
413+
414+
this.stopMutedTracking();
415+
this.mutedTrackingInterval = setInterval(() => {
416+
void this.syncMutedStateFromPlayer();
417+
}, MUTED_TRACKING_INTERVAL_MS);
418+
}
419+
420+
private stopMutedTracking(): void {
421+
if (this.mutedTrackingInterval) {
422+
clearInterval(this.mutedTrackingInterval);
423+
this.mutedTrackingInterval = null;
424+
}
425+
this.isMutedSyncing = false;
426+
}
427+
428+
private async syncMutedStateFromPlayer(): Promise<void> {
429+
if (this.isMutedSyncing || !this.player || !this.player.isMuted) {
430+
return;
431+
}
432+
433+
this.isMutedSyncing = true;
434+
435+
try {
436+
const muted = await this.player.isMuted();
437+
this.updateMutedState(Boolean(muted), true);
438+
} catch {
439+
// ignore polling errors while player is transitioning
440+
} finally {
441+
this.isMutedSyncing = false;
442+
}
443+
}
444+
445+
private updateMutedState(muted: boolean, emitEvent = true): void {
446+
this.desiredMuted = muted;
447+
448+
if (this.lastKnownMuted === muted) {
449+
return;
450+
}
451+
452+
this.lastKnownMuted = muted;
453+
454+
if (emitEvent) {
455+
this.callbacks.onMuteChange?.(muted);
456+
}
457+
}
458+
373459
private applyDesiredMutedState(): void {
374460
if (!this.desiredMuted) {
375461
return;
376462
}
377463

378464
try {
379465
this.player?.mute();
466+
this.updateMutedState(true, true);
380467
} catch (error) {
381468
console.warn('Failed to apply muted state:', error);
382469
}
383470
}
384471

385472
destroy(): void {
386473
this.stopProgressTracking();
474+
this.stopMutedTracking();
387475

388476
if (this.seekTimeout) {
389477
clearTimeout(this.seekTimeout);
@@ -398,6 +486,8 @@ class WebYoutubePlayerController {
398486
}
399487
this.player = null;
400488
}
489+
490+
this.lastKnownMuted = null;
401491
}
402492
}
403493

packages/core/src/types/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export type PlayerEvents = {
114114
* This event fires any time the browser blocks autoplay or scripted video playback features, collectively referred to as "autoplay".
115115
*/
116116
onAutoplayBlocked?: () => void;
117+
/**
118+
* This event fires whenever the player's muted state changes.
119+
* @param {boolean} muted - The current muted state.
120+
*/
121+
onMuteChange?: (muted: boolean) => void;
117122
};
118123

119124
/**
@@ -234,6 +239,7 @@ export type YoutubePlayerEvents = {
234239
playbackRateChange: number;
235240
playbackQualityChange: PlaybackQuality;
236241
autoplayBlocked: undefined;
242+
muteChange: boolean;
237243
};
238244

239245
export type EventCallback<Data = any> = (data: Data) => any;

packages/core/src/types/webview.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ interface AutoplayBlockedMessageData {
3434
type: 'autoplayBlocked';
3535
}
3636

37+
interface MuteChangeMessageData {
38+
type: 'muteChange';
39+
muted: boolean;
40+
}
41+
3742
interface CommandResultMessageData {
3843
type: 'commandResult';
3944
id: string;
@@ -48,4 +53,5 @@ export type MessageData =
4853
| PlaybackRateChangeMessageData
4954
| PlaybackQualityChangeMessageData
5055
| AutoplayBlockedMessageData
56+
| MuteChangeMessageData
5157
| CommandResultMessageData;

packages/react-native-youtube-bridge/README-ko_kr.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ import { YoutubeView, useYouTubeEvent, useYouTubePlayer } from 'react-native-you
7373
function App() {
7474
const player = useYouTubePlayer(videoIdOrUrl);
7575

76+
// State-based event listening
7677
const playbackRate = useYouTubeEvent(player, 'playbackRateChange', 1);
78+
const isMuted = useYouTubeEvent(player, 'muteChange', false);
7779
const progress = useYouTubeEvent(player, 'progress', progressInterval);
7880

81+
// Callback-based event listening
7982
useYouTubeEvent(player, 'ready', (playerInfo) => {
8083
console.log('Player is ready!');
8184
Alert.alert('Alert', 'YouTube player is ready!');
@@ -94,6 +97,9 @@ function App() {
9497
}
9598
```
9699

100+
`muteChange` 이벤트를 구독하면 YouTube 플레이어 기본 UI의 스피커 버튼 또는 `player.mute()` / `player.unMute()` 호출로 변경된 음소거 상태를 실시간으로 받을 수 있습니다.
101+
성능 최적화를 위해 `muteChange`를 구독할 때만 muted tracking이 활성화됩니다.
102+
97103
`useYouTubeEvent` hook은 callback으로 값을 전달받는 방식과 state로 값을 바로 사용할 수 있는 두 가지 방법을 제공합니다.
98104

99105
1. Callback 방식: 의존성에 따라 리렌더링이 필요한 경우 4번째 인자에 dependency array를 주입해주면 됩니다.

0 commit comments

Comments
 (0)