From bcc084e0834fdad0bd539e01c3eda0e1eaf3032a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 13 Oct 2025 23:46:18 +0200 Subject: [PATCH] Handle media sessions --- .../src/core/VideoPlayer.web.ts | 25 +++- .../src/core/types/VideoConfig.ts | 2 +- .../src/core/web/MediaSession.ts | 140 ++++++++++++++++++ .../src/core/{ => web}/WebEventEmiter.ts | 8 +- 4 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 packages/react-native-video/src/core/web/MediaSession.ts rename packages/react-native-video/src/core/{ => web}/WebEventEmiter.ts (96%) diff --git a/packages/react-native-video/src/core/VideoPlayer.web.ts b/packages/react-native-video/src/core/VideoPlayer.web.ts index d5ad5823..22bf3d3a 100644 --- a/packages/react-native-video/src/core/VideoPlayer.web.ts +++ b/packages/react-native-video/src/core/VideoPlayer.web.ts @@ -11,7 +11,8 @@ import { import type { VideoPlayerBase } from "./types/VideoPlayerBase"; import type { VideoPlayerStatus } from "./types/VideoPlayerStatus"; import { VideoPlayerEvents } from "./VideoPlayerEvents"; -import { WebEventEmiter } from "./WebEventEmiter"; +import { MediaSessionHandler } from "./web/MediaSession"; +import { WebEventEmiter } from "./web/WebEventEmiter"; import videojs from "video.js"; type VideoJsPlayer = ReturnType; @@ -33,6 +34,7 @@ type VideoJsTextTracks = { class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase { protected video: HTMLVideoElement; public player: VideoJsPlayer; + private mediaSession: MediaSessionHandler; constructor(source: VideoSource | VideoConfig | VideoPlayerSource) { const video = document.createElement("video"); @@ -42,6 +44,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase { this.video = video; this.player = player; + this.mediaSession = new MediaSessionHandler(this.player); this.replaceSourceAsync(source); } @@ -153,40 +156,48 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase { return "auto"; } - set mixAudioMode(_: MixAudioMode) { } + set mixAudioMode(_: MixAudioMode) {} // Ignore Silent Switch Mode get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode { return "auto"; } - set ignoreSilentSwitchMode(_: IgnoreSilentSwitchMode) { } + set ignoreSilentSwitchMode(_: IgnoreSilentSwitchMode) {} // Play In Background get playInBackground(): boolean { return true; } - set playInBackground(_: boolean) { } + set playInBackground(_: boolean) {} // Play When Inactive get playWhenInactive(): boolean { return true; } - set playWhenInactive(_: boolean) { } + set playWhenInactive(_: boolean) {} - // Is Playing get isPlaying(): boolean { return !this.player.paused(); } + get showNotificationControls(): boolean { + return this.mediaSession.enabled; + } + + set showNotificationControls(value: boolean) { + if (value) this.mediaSession.enable(); + else this.mediaSession.disable(); + } + async initialize(): Promise { // noop on web } async preload(): Promise { - this.player.load() + this.player.load(); } /** diff --git a/packages/react-native-video/src/core/types/VideoConfig.ts b/packages/react-native-video/src/core/types/VideoConfig.ts index 18c38c1a..60633005 100644 --- a/packages/react-native-video/src/core/types/VideoConfig.ts +++ b/packages/react-native-video/src/core/types/VideoConfig.ts @@ -141,7 +141,7 @@ interface NativeDrmParams extends DrmParams { type?: string; } -interface CustomVideoMetadata { +export interface CustomVideoMetadata { title?: string; subtitle?: string; description?: string; diff --git a/packages/react-native-video/src/core/web/MediaSession.ts b/packages/react-native-video/src/core/web/MediaSession.ts new file mode 100644 index 00000000..14760302 --- /dev/null +++ b/packages/react-native-video/src/core/web/MediaSession.ts @@ -0,0 +1,140 @@ +import type videojs from "video.js"; +import type { CustomVideoMetadata } from "../types/VideoConfig"; + +type VideoJsPlayer = ReturnType; + +const mediaSession = window.navigator.mediaSession; + +export class MediaSessionHandler { + enabled: boolean = false; + + constructor(private player: VideoJsPlayer) {} + + enable() { + this.enabled = true; + + const defaultSkipTime = 15; + + const actionHandlers = [ + [ + "play", + () => { + this.player.play(); + }, + ], + [ + "pause", + () => { + this.player.pause(); + }, + ], + [ + "stop", + () => { + this.player.pause(); + this.player.currentTime(0); + }, + ], + // videojs-contrib-ads + [ + "seekbackward", + (details: MediaSessionActionDetails) => { + if (this.player.usingPlugin("ads") && this.player.ads.inAdBreak()) { + return; + } + this.player.currentTime( + Math.max( + 0, + (this.player.currentTime() ?? 0) - + (details.seekOffset || defaultSkipTime), + ), + ); + }, + ], + [ + "seekforward", + (details: MediaSessionActionDetails) => { + if (this.player.usingPlugin("ads") && this.player.ads.inAdBreak()) { + return; + } + this.player.currentTime( + Math.min( + this.player.duration() ?? 0, + (this.player.currentTime() ?? 0) + + (details.seekOffset || defaultSkipTime), + ), + ); + }, + ], + [ + "seekto", + (details: MediaSessionActionDetails) => { + if (this.player.usingPlugin("ads") && this.player.ads.inAdBreak()) { + return; + } + this.player.currentTime(details.seekTime); + }, + ], + ] as const; + + for (const [action, handler] of actionHandlers) { + try { + mediaSession.setActionHandler(action, handler); + } catch { + this.player.log.debug( + `Couldn't register media session action "${action}".`, + ); + } + } + + const onPlaying = () => { + mediaSession.playbackState = "playing"; + }; + const onPaused = () => { + mediaSession.playbackState = "paused"; + }; + const onTimeUpdate = () => { + const dur = this.player.duration(); + + if (Number.isFinite(dur)) { + mediaSession.setPositionState({ + duration: dur, + playbackRate: this.player.playbackRate(), + position: this.player.currentTime(), + }); + } + }; + + this.player.on("playing", onPlaying); + this.player.on("paused", onPaused); + if ("setPositionState" in mediaSession) { + this.player.on("timeupdate", onTimeUpdate); + } + + this.disable = () => { + this.enabled = false; + this.player.off("playing", onPlaying); + this.player.off("paused", onPaused); + if ("setPositionState" in mediaSession) { + this.player.off("timeupdate", onTimeUpdate); + } + mediaSession.metadata = null; + for (const [action, _] of actionHandlers) { + try { + mediaSession.setActionHandler(action, null); + } catch {} + } + }; + } + + disable() {} + + updateMediaSession(metadata: CustomVideoMetadata) { + mediaSession.metadata = new window.MediaMetadata({ + title: metadata.title, + album: metadata.subtitle, + artist: metadata.artist, + artwork: metadata.imageUri ? [{ src: metadata.imageUri }] : [], + }); + } +} diff --git a/packages/react-native-video/src/core/WebEventEmiter.ts b/packages/react-native-video/src/core/web/WebEventEmiter.ts similarity index 96% rename from packages/react-native-video/src/core/WebEventEmiter.ts rename to packages/react-native-video/src/core/web/WebEventEmiter.ts index e41d31d2..5b319459 100644 --- a/packages/react-native-video/src/core/WebEventEmiter.ts +++ b/packages/react-native-video/src/core/web/WebEventEmiter.ts @@ -8,10 +8,10 @@ import type { onVolumeChangeData, AllPlayerEvents as PlayerEvents, TimedMetadata, -} from "./types/Events"; -import type { TextTrack } from "./types/TextTrack"; -import type { VideoRuntimeError } from "./types/VideoError"; -import type { VideoPlayerStatus } from "./types/VideoPlayerStatus"; +} from "../types/Events"; +import type { TextTrack } from "../types/TextTrack"; +import type { VideoRuntimeError } from "../types/VideoError"; +import type { VideoPlayerStatus } from "../types/VideoPlayerStatus"; type VideoJsPlayer = ReturnType;