@applicaster/quick-brick-player 16.0.0-rc.7 → 16.0.0-rc.70
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/package.json +6 -6
- package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
- package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +38 -48
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +66 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +26 -0
- package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
- package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
- package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
- package/src/Player/AudioLayer/SpeedManager.ts +4 -0
- package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
- package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
- package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
- package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
- package/src/Player/AudioLayer/__tests__/playerActions.test.ts +342 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
- package/src/Player/AudioLayer/playerActions.ts +277 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +134 -0
- package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
- package/src/Player/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +72 -27
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
4
|
+
import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
|
|
5
|
+
import {
|
|
6
|
+
loadFeedEntry,
|
|
7
|
+
retrieveFeedUrl,
|
|
8
|
+
retrievePlayPreviousFeedUrl,
|
|
9
|
+
} from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
|
|
10
|
+
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
11
|
+
|
|
12
|
+
const { log_error, log_info } = createLogger({
|
|
13
|
+
subsystem: "Player",
|
|
14
|
+
category: "usePlaylistNavigation",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const RESTART_THRESHOLD_SECONDS = 3;
|
|
18
|
+
|
|
19
|
+
export type PlaylistNavigation = {
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
hasPrevious: boolean;
|
|
22
|
+
goNext: () => Promise<void>;
|
|
23
|
+
goPrevious: () => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Each entry points at the feed holding its neighbour, so the chain walks
|
|
28
|
+
* itself and needs no client-side queue.
|
|
29
|
+
*/
|
|
30
|
+
export const usePlaylistNavigation = (
|
|
31
|
+
entry: ZappEntry,
|
|
32
|
+
playerState: QuickBrickPlayer.PlayerState
|
|
33
|
+
): PlaylistNavigation => {
|
|
34
|
+
const navigator = useNavigation();
|
|
35
|
+
// Not playerManager.getActivePlayer(): that is always the local player, so
|
|
36
|
+
// seeking would miss the receiver while casting.
|
|
37
|
+
const player = usePlayer();
|
|
38
|
+
|
|
39
|
+
const [nextUrl, setNextUrl] = React.useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
|
|
44
|
+
retrieveFeedUrl(entry)
|
|
45
|
+
.then((url) => {
|
|
46
|
+
if (!cancelled) {
|
|
47
|
+
setNextUrl(url ?? null);
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.catch((error) => {
|
|
51
|
+
log_error(
|
|
52
|
+
"usePlaylistNavigation: could not resolve the next feed url",
|
|
53
|
+
{ error }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!cancelled) {
|
|
57
|
+
setNextUrl(null);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [entry]);
|
|
65
|
+
|
|
66
|
+
const previousUrl = retrievePlayPreviousFeedUrl(entry);
|
|
67
|
+
const currentTime = playerState?.currentTime ?? 0;
|
|
68
|
+
|
|
69
|
+
const canRestart = (playerState?.seekableDuration ?? 0) > 0;
|
|
70
|
+
|
|
71
|
+
const isNavigatingRef = React.useRef(false);
|
|
72
|
+
|
|
73
|
+
const navigateTo = React.useCallback(
|
|
74
|
+
async (feedUrl: string) => {
|
|
75
|
+
// A second tap mid-flight resolves to the same target, since `entry` has
|
|
76
|
+
// not been replaced yet.
|
|
77
|
+
if (isNavigatingRef.current) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isNavigatingRef.current = true;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const target = await loadFeedEntry(feedUrl, entry);
|
|
85
|
+
|
|
86
|
+
log_info(`navigateTo: playing "${target.title}", id: ${target.id}`, {
|
|
87
|
+
feedUrl,
|
|
88
|
+
targetId: target.id,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
navigator.replaceTop(target);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
log_error("navigateTo: could not play the neighbouring entry", {
|
|
94
|
+
error,
|
|
95
|
+
feedUrl,
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
// Released on failure too, so one bad request cannot latch the control.
|
|
99
|
+
isNavigatingRef.current = false;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[entry, navigator]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const goNext = React.useCallback(async () => {
|
|
106
|
+
if (!nextUrl) return;
|
|
107
|
+
|
|
108
|
+
await navigateTo(nextUrl);
|
|
109
|
+
}, [nextUrl, navigateTo]);
|
|
110
|
+
|
|
111
|
+
const goPrevious = React.useCallback(async () => {
|
|
112
|
+
// Restarting belongs to the stream, not the chain — so it stays available
|
|
113
|
+
// at the head, where there is nothing to step back to.
|
|
114
|
+
const shouldRestart =
|
|
115
|
+
canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !previousUrl);
|
|
116
|
+
|
|
117
|
+
if (shouldRestart) {
|
|
118
|
+
player?.seekTo(0);
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!previousUrl) return;
|
|
124
|
+
|
|
125
|
+
await navigateTo(previousUrl);
|
|
126
|
+
}, [previousUrl, currentTime, canRestart, navigateTo, player]);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
hasNext: Boolean(nextUrl),
|
|
130
|
+
hasPrevious: Boolean(previousUrl) || canRestart,
|
|
131
|
+
goNext,
|
|
132
|
+
goPrevious,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
@@ -1,30 +1,19 @@
|
|
|
1
1
|
import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
|
|
2
|
+
import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
2
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
4
|
import {
|
|
4
5
|
SLEEP_TIMER_OFF,
|
|
5
6
|
SleepTimerController,
|
|
6
7
|
SleepTimerState,
|
|
7
8
|
} from "./SleepTimerManager";
|
|
8
|
-
|
|
9
|
-
import { openBottomSheetModal } from "@applicaster/zapp-react-native-utils/modalState";
|
|
10
|
-
|
|
11
|
-
import { SleepTimerModalHeader } from "./components/SleepTimerModalHeader";
|
|
12
|
-
import { getSleepTimerOptions } from "../Const/sleepTimerOptions";
|
|
13
9
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
getBottomSheetModalSelectedItemIcon,
|
|
18
|
-
selectSleepTitle,
|
|
19
|
-
selectSleepOffLabel,
|
|
20
|
-
} from "../Utils";
|
|
10
|
+
PLAYER_ACTION_IDENTIFIERS,
|
|
11
|
+
registerPlayerActions,
|
|
12
|
+
} from "./playerActions";
|
|
21
13
|
|
|
22
|
-
export const useSleepTimerModal = (player
|
|
14
|
+
export const useSleepTimerModal = (player) => {
|
|
23
15
|
const playerState = usePlayerState("audio-player-sleep-timer-modal");
|
|
24
16
|
|
|
25
|
-
const title = configuration(selectSleepTitle);
|
|
26
|
-
const offLabel = configuration(selectSleepOffLabel);
|
|
27
|
-
|
|
28
17
|
const [sleepTimerManager, setSleepTimerManager] =
|
|
29
18
|
useState<SleepTimerController | null>(null);
|
|
30
19
|
|
|
@@ -33,19 +22,22 @@ export const useSleepTimerModal = (player, configuration) => {
|
|
|
33
22
|
);
|
|
34
23
|
|
|
35
24
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
setSleepTimerManager(new SleepTimerController(player));
|
|
38
|
-
} else {
|
|
39
|
-
setSleepTimerManager(null);
|
|
40
|
-
}
|
|
25
|
+
setSleepTimerManager(player ? new SleepTimerController(player) : null);
|
|
41
26
|
}, [player]);
|
|
42
27
|
|
|
43
28
|
useEffect(() => {
|
|
44
29
|
if (playerState?.isLive) {
|
|
45
|
-
|
|
46
|
-
|
|
30
|
+
sleepTimerManager?.setSleepTimer(SLEEP_TIMER_OFF).catch(() => undefined);
|
|
31
|
+
}
|
|
32
|
+
}, [playerState?.isLive, sleepTimerManager]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!sleepTimerManager || playerState.isLive) {
|
|
36
|
+
return;
|
|
47
37
|
}
|
|
48
|
-
|
|
38
|
+
|
|
39
|
+
return registerPlayerActions({ sleepController: sleepTimerManager });
|
|
40
|
+
}, [sleepTimerManager, playerState.isLive]);
|
|
49
41
|
|
|
50
42
|
useEffect(() => {
|
|
51
43
|
if (!playerState.isReadyToPlay) {
|
|
@@ -64,40 +56,11 @@ export const useSleepTimerModal = (player, configuration) => {
|
|
|
64
56
|
};
|
|
65
57
|
}, [sleepTimerManager]);
|
|
66
58
|
|
|
67
|
-
const onSleepSelect = useCallback(
|
|
68
|
-
async (item) => {
|
|
69
|
-
await sleepTimerManager?.setSleepTimer(item);
|
|
70
|
-
},
|
|
71
|
-
[sleepTimerManager]
|
|
72
|
-
);
|
|
73
|
-
|
|
74
59
|
const onSleepButtonPress = useCallback(() => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
openBottomSheetModal({
|
|
81
|
-
modalBottomSheetContentProps: {
|
|
82
|
-
headerComponent: SleepTimerModalHeader,
|
|
83
|
-
items: getSleepTimerOptions(offLabel, timerRemaining),
|
|
84
|
-
onPress: onSleepSelect,
|
|
85
|
-
title,
|
|
86
|
-
summary: timerRemaining,
|
|
87
|
-
itemProps: getBottomSheetModalItemProps,
|
|
88
|
-
iconProps: getBottomSheetModalIconProps,
|
|
89
|
-
labelProps: getBottomSheetModalLabelProps,
|
|
90
|
-
getSelectedItemIcon: getBottomSheetModalSelectedItemIcon,
|
|
91
|
-
},
|
|
92
|
-
options: {
|
|
93
|
-
supportedOrientations: [
|
|
94
|
-
"portrait",
|
|
95
|
-
"landscape",
|
|
96
|
-
"portrait-upside-down",
|
|
97
|
-
],
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
}, [sleepTimerState.timerDate, sleepTimerState.timer, title, onSleepSelect]);
|
|
60
|
+
uiActionsRegistry
|
|
61
|
+
.getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
|
|
62
|
+
?.invokeAction(player?.entry);
|
|
63
|
+
}, [player]);
|
|
101
64
|
|
|
102
65
|
return useMemo(
|
|
103
66
|
() => ({
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import VideoPlayer from "../index";
|
|
2
|
+
import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pins where `getNativeProps()`'s `metadata` object comes from: the player
|
|
6
|
+
* manager's content channel, looked up by `props.playerId`.
|
|
7
|
+
*
|
|
8
|
+
* This deliberately does NOT depend on tree position. An earlier version read
|
|
9
|
+
* the values from `PlayerContainerContext` via `static contextType`, which
|
|
10
|
+
* made native's props depend on `Player` being rendered inside
|
|
11
|
+
* `PlayerContainerContextProvider` - safe only as long as an exhaustive
|
|
12
|
+
* render-site search stayed true. Reading from the manager removes that whole
|
|
13
|
+
* class of fragility, so the tests below assert the value's source rather than
|
|
14
|
+
* a render-site invariant.
|
|
15
|
+
*
|
|
16
|
+
* Lookup is by id rather than `getActivePlayer()` because these props are
|
|
17
|
+
* handed to this component's own native view: they must describe that player
|
|
18
|
+
* even while a cast session is running.
|
|
19
|
+
*
|
|
20
|
+
* The instance is constructed directly (bypassing `render()`/mount) so this
|
|
21
|
+
* exercises the real `getNativeProps()` without paying for a full render of
|
|
22
|
+
* the ~37KB `VideoPlayer` class tree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
|
|
26
|
+
...jest.requireActual("@applicaster/zapp-react-native-utils/appUtils"),
|
|
27
|
+
playerManager: {
|
|
28
|
+
getPlayerWithId: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const entry = {
|
|
33
|
+
id: "1",
|
|
34
|
+
title: "Entry Title",
|
|
35
|
+
summary: "Entry Summary",
|
|
36
|
+
} as unknown as ZappEntry;
|
|
37
|
+
|
|
38
|
+
const baseProps = {
|
|
39
|
+
entry,
|
|
40
|
+
pluginConfiguration: {},
|
|
41
|
+
docked: false,
|
|
42
|
+
fullscreen: false,
|
|
43
|
+
inline: false,
|
|
44
|
+
isModal: false,
|
|
45
|
+
isTabletPortrait: false,
|
|
46
|
+
muted: false,
|
|
47
|
+
playerId: "player-1",
|
|
48
|
+
PlayerComponent: "QuickBrickDefaultPlayerView",
|
|
49
|
+
} as unknown as ConstructorParameters<typeof VideoPlayer>[0];
|
|
50
|
+
|
|
51
|
+
const publishContent = (
|
|
52
|
+
content: {
|
|
53
|
+
title: Option<string | number>;
|
|
54
|
+
summary: Option<string | number>;
|
|
55
|
+
} | null
|
|
56
|
+
) => {
|
|
57
|
+
(playerManager.getPlayerWithId as jest.Mock).mockReturnValue(
|
|
58
|
+
content ? { getContent: () => content } : null
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("Player's getNativeProps() reading Title/Subtitle from the manager", () => {
|
|
63
|
+
it("sends the title/summary the player published", () => {
|
|
64
|
+
publishContent({
|
|
65
|
+
title: "Resolved Title",
|
|
66
|
+
summary: "Resolved Summary",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
70
|
+
|
|
71
|
+
expect(nativeProps.metadata.title).toBe("Resolved Title");
|
|
72
|
+
expect(nativeProps.metadata.subtitle).toBe("Resolved Summary");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("looks the player up by its own id, not the active player", () => {
|
|
76
|
+
publishContent({ title: "Resolved Title", summary: "Resolved Summary" });
|
|
77
|
+
|
|
78
|
+
new VideoPlayer(baseProps).getNativeProps();
|
|
79
|
+
|
|
80
|
+
expect(playerManager.getPlayerWithId).toHaveBeenCalledWith("player-1");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("sends undefined when the player published no text", () => {
|
|
84
|
+
publishContent({ title: null, summary: null });
|
|
85
|
+
|
|
86
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
87
|
+
|
|
88
|
+
expect(nativeProps.metadata.title).toBeUndefined();
|
|
89
|
+
expect(nativeProps.metadata.subtitle).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("sends undefined rather than crashing when no player is registered", () => {
|
|
93
|
+
publishContent(null);
|
|
94
|
+
|
|
95
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
96
|
+
|
|
97
|
+
expect(nativeProps.metadata.title).toBeUndefined();
|
|
98
|
+
expect(nativeProps.metadata.subtitle).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not fall back to entry.title/entry.summary itself - the resolver owns that fallback", () => {
|
|
102
|
+
// `entry.title`/`entry.summary` are "Entry Title"/"Entry Summary" (see
|
|
103
|
+
// `baseProps`); with nothing published, nativeProps must NOT silently pick
|
|
104
|
+
// those up as a second, redundant fallback layer inside `Player`. The
|
|
105
|
+
// entry-field fallback lives in `resolvePlayerContentText`, reached
|
|
106
|
+
// through the player's own `getContent()`.
|
|
107
|
+
publishContent({ title: null, summary: null });
|
|
108
|
+
|
|
109
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
110
|
+
|
|
111
|
+
expect(nativeProps.metadata.title).not.toBe(entry.title);
|
|
112
|
+
expect(nativeProps.metadata.subtitle).not.toBe(entry.summary);
|
|
113
|
+
expect(nativeProps.entry).toBe(entry);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { toNativeMetadata } from "../index";
|
|
2
|
+
|
|
3
|
+
// `toNativeMetadata` used to be `resolveNativeTitleSubtitle`, which resolved
|
|
4
|
+
// Title/Subtitle from `entry` + `pluginConfiguration` itself (the dot-path /
|
|
5
|
+
// custom-key / fallback logic). That resolution now happens once in
|
|
6
|
+
// `Player.getContent()` (via `resolvePlayerTitleSubtitle`, already covered by
|
|
7
|
+
// `playerUtils/__tests__/resolvePlayerTitleSubtitle.test.ts` and
|
|
8
|
+
// `playerUtils/__tests__/contentDataKeys.test.ts` - custom-key resolution,
|
|
9
|
+
// fallback-when-unresolvable, and entry-non-mutation are all still asserted
|
|
10
|
+
// there). `toNativeMetadata` only adapts the already-resolved
|
|
11
|
+
// `player.getContent()` value into the `metadata` object the native bridge
|
|
12
|
+
// receives, so this file only covers that adaptation: the shape coercion, and
|
|
13
|
+
// the numeric coercion the native bridge specifically needs (it cannot render
|
|
14
|
+
// a number the way `<Text>` can).
|
|
15
|
+
describe("toNativeMetadata", () => {
|
|
16
|
+
it("returns undefined for both fields when title/summary are null (what the player publishes when there is nothing to show)", () => {
|
|
17
|
+
expect(toNativeMetadata({ title: null, summary: null })).toEqual({
|
|
18
|
+
title: undefined,
|
|
19
|
+
subtitle: undefined,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns undefined for both fields when content itself is undefined", () => {
|
|
24
|
+
expect(toNativeMetadata(undefined)).toEqual({
|
|
25
|
+
title: undefined,
|
|
26
|
+
subtitle: undefined,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("passes through string title/summary as-is", () => {
|
|
31
|
+
expect(
|
|
32
|
+
toNativeMetadata({
|
|
33
|
+
title: "Resolved Title",
|
|
34
|
+
summary: "Resolved Summary",
|
|
35
|
+
})
|
|
36
|
+
).toEqual({
|
|
37
|
+
title: "Resolved Title",
|
|
38
|
+
subtitle: "Resolved Summary",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("coerces a numeric title/summary to a string for the native bridge", () => {
|
|
43
|
+
expect(toNativeMetadata({ title: 0, summary: 42 })).toEqual({
|
|
44
|
+
title: "0",
|
|
45
|
+
subtitle: "42",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles title and summary independently (one present, one absent)", () => {
|
|
50
|
+
expect(toNativeMetadata({ title: "Only Title", summary: null })).toEqual({
|
|
51
|
+
title: "Only Title",
|
|
52
|
+
subtitle: undefined,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("passes the cover image url through", () => {
|
|
57
|
+
expect(
|
|
58
|
+
toNativeMetadata({
|
|
59
|
+
title: "A Title",
|
|
60
|
+
summary: "A Summary",
|
|
61
|
+
imageUrl: "https://img.test/base.jpg",
|
|
62
|
+
})
|
|
63
|
+
).toEqual({
|
|
64
|
+
title: "A Title",
|
|
65
|
+
subtitle: "A Summary",
|
|
66
|
+
imageUrl: "https://img.test/base.jpg",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sends undefined rather than an empty string when there is no image", () => {
|
|
71
|
+
// Native treats an empty URI as a load attempt; `undefined` means "none".
|
|
72
|
+
expect(
|
|
73
|
+
toNativeMetadata({ title: "A Title", summary: null, imageUrl: null })
|
|
74
|
+
.imageUrl
|
|
75
|
+
).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/Player/index.tsx
CHANGED
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
import { PlayNextOverlay } from "./PlayNextOverlay";
|
|
32
32
|
import { PlayerImageBackground } from "@applicaster/zapp-react-native-ui-components/Components/PlayerImageBackground";
|
|
33
33
|
|
|
34
|
-
import { GENERAL_EVENT } from "@applicaster/zapp-react-native-utils/analyticsUtils/events";
|
|
35
34
|
import { parseLanguageTracks } from "@applicaster/zapp-react-native-utils/playerUtils/configurationUtils";
|
|
36
35
|
import { DEFAULT_COLORS, SEEK_TIME } from "../utils/const";
|
|
37
36
|
import { logger, getScreenAspectRatio } from "../utils";
|
|
@@ -41,7 +40,6 @@ import { getAllAccessibilityProps } from "@applicaster/zapp-react-native-utils/c
|
|
|
41
40
|
import { PlayNextData } from "@applicaster/zapp-react-native-ui-components/Components/PlayerContainer/PlayerContainer";
|
|
42
41
|
import { getTargetScreenDataFromEntry } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useTargetScreenData";
|
|
43
42
|
import { PlayerAnalyticsManager } from "@applicaster/zapp-react-native-utils/analyticsUtils/PlayerAnalyticsManager";
|
|
44
|
-
import { PlayerAnalyticsTrackerI } from "@applicaster/zapp-react-native-utils/analyticsUtils/playerAnalyticsTracker";
|
|
45
43
|
|
|
46
44
|
import { RNPlayerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/nativePlayerManager";
|
|
47
45
|
import {
|
|
@@ -54,7 +52,7 @@ import { TVPlayerView } from "./PlayerView/TVPlayerView";
|
|
|
54
52
|
import PlayerModal, { VideoPlayerModal } from "./PlayerModal";
|
|
55
53
|
import { DockedControls } from "./AudioLayer/Layout/DockedControls";
|
|
56
54
|
import { FlexImage } from "./AudioLayer/Layout/PlayerImage/FlexImage";
|
|
57
|
-
import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/
|
|
55
|
+
import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
|
|
58
56
|
|
|
59
57
|
const isTvOS = isTvOSPlatform();
|
|
60
58
|
|
|
@@ -71,6 +69,60 @@ const styles = StyleSheet.create({
|
|
|
71
69
|
flexImage: { height: "100%", aspectRatio: 1, flex: -1 },
|
|
72
70
|
});
|
|
73
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Coerces the resolver's `Option<string | number>` result into what the
|
|
74
|
+
* native bridge expects: a string, or `undefined` when there is nothing to
|
|
75
|
+
* send.
|
|
76
|
+
*/
|
|
77
|
+
const toNativeTextProp = (
|
|
78
|
+
value: Option<string | number>
|
|
79
|
+
): string | undefined =>
|
|
80
|
+
value === null || value === undefined ? undefined : String(value);
|
|
81
|
+
|
|
82
|
+
export type NativePlayerMetadata = {
|
|
83
|
+
title: string | undefined;
|
|
84
|
+
subtitle: string | undefined;
|
|
85
|
+
imageUrl: string | undefined;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Adapts the player's resolved Title/Subtitle into the `metadata` object the
|
|
90
|
+
* *native* view receives.
|
|
91
|
+
*
|
|
92
|
+
* The resolution itself (dot-path / key-set logic, entry fallback) does not
|
|
93
|
+
* happen here - the player manager resolves it and publishes it on its content
|
|
94
|
+
* channel (`player.getContent()`). This function only coerces that value's
|
|
95
|
+
* `Option<string | number>` shape into what the native bridge needs: a string,
|
|
96
|
+
* or `undefined` when there is nothing to send.
|
|
97
|
+
*
|
|
98
|
+
* Travels as one `metadata` object rather than two flat props so native has a
|
|
99
|
+
* single place to look for player-facing text, and so adding a field later
|
|
100
|
+
* does not widen the bridge's prop surface again.
|
|
101
|
+
*
|
|
102
|
+
* This intentionally does not touch `entry` - analytics and the downloads
|
|
103
|
+
* feature read `entry.title` / `entry.summary` directly and must keep
|
|
104
|
+
* seeing the original feed values (the downloads feature requires the
|
|
105
|
+
* original entry title). The resolved (possibly customer-configured) text
|
|
106
|
+
* travels in `metadata` instead.
|
|
107
|
+
*
|
|
108
|
+
* Exported as a plain function (rather than inlined as a class method) so
|
|
109
|
+
* it can be unit tested directly without rendering the ~37KB `VideoPlayer`
|
|
110
|
+
* class component.
|
|
111
|
+
*/
|
|
112
|
+
export const toNativeMetadata = (
|
|
113
|
+
content: Option<{
|
|
114
|
+
title: Option<string | number>;
|
|
115
|
+
summary: Option<string | number>;
|
|
116
|
+
imageUrl?: Option<string>;
|
|
117
|
+
}>
|
|
118
|
+
): NativePlayerMetadata => ({
|
|
119
|
+
title: toNativeTextProp(content?.title),
|
|
120
|
+
subtitle: toNativeTextProp(content?.summary),
|
|
121
|
+
// Already a string when present; coerced through the same helper so an
|
|
122
|
+
// empty or nullish value reaches native as `undefined` rather than "".
|
|
123
|
+
imageUrl: toNativeTextProp(content?.imageUrl),
|
|
124
|
+
});
|
|
125
|
+
|
|
74
126
|
type VideoPlayerProps = QuickBrickPlayer.PlayerProps & {
|
|
75
127
|
isCasting?: boolean;
|
|
76
128
|
playNextData?: PlayNextData;
|
|
@@ -164,20 +216,10 @@ export default class VideoPlayer extends React.Component<
|
|
|
164
216
|
nativePlayerIsReady: false,
|
|
165
217
|
};
|
|
166
218
|
|
|
167
|
-
private _tracker: PlayerAnalyticsTrackerI;
|
|
168
|
-
get analyticsTracker() {
|
|
169
|
-
if (!this._tracker) {
|
|
170
|
-
this._tracker = PlayerAnalyticsManager.getInstance().getTracker(
|
|
171
|
-
this.props.playerId
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return this._tracker;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
219
|
constructor(props) {
|
|
179
220
|
super(props);
|
|
180
221
|
this.onVideoLoadCalled = false; // android
|
|
222
|
+
PlayerAnalyticsManager.getInstance();
|
|
181
223
|
|
|
182
224
|
this.takeParentStyle = isWeb()
|
|
183
225
|
? { width: "100%", flex: "1 1 auto", objectFit: "contain" }
|
|
@@ -191,9 +233,6 @@ export default class VideoPlayer extends React.Component<
|
|
|
191
233
|
componentWillUnmount() {
|
|
192
234
|
this.playbackStarted = false;
|
|
193
235
|
|
|
194
|
-
this.analyticsTracker.handleAnalyticEvent(GENERAL_EVENT.session_end);
|
|
195
|
-
this.onPlayerClosed();
|
|
196
|
-
|
|
197
236
|
if (isAndroidPlatform()) {
|
|
198
237
|
this.disablePiP();
|
|
199
238
|
}
|
|
@@ -201,7 +240,6 @@ export default class VideoPlayer extends React.Component<
|
|
|
201
240
|
|
|
202
241
|
componentDidMount() {
|
|
203
242
|
this.playbackStarted = false;
|
|
204
|
-
this.onPlayerPresented();
|
|
205
243
|
}
|
|
206
244
|
|
|
207
245
|
shouldComponentUpdate(nextProps) {
|
|
@@ -445,14 +483,6 @@ export default class VideoPlayer extends React.Component<
|
|
|
445
483
|
});
|
|
446
484
|
};
|
|
447
485
|
|
|
448
|
-
onPlayerPresented = () => {
|
|
449
|
-
this.analyticsTracker?.handleAnalyticEvent(GENERAL_EVENT.player_presented);
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
onPlayerClosed = () => {
|
|
453
|
-
this.analyticsTracker?.handleAnalyticEvent(GENERAL_EVENT.player_closed);
|
|
454
|
-
};
|
|
455
|
-
|
|
456
486
|
onVideoEnd = () => {
|
|
457
487
|
this.setState({ displayOverlay: !this.state.displayOverlay }, () => {
|
|
458
488
|
if (this.state?.displayOverlay) {
|
|
@@ -1096,6 +1126,15 @@ export default class VideoPlayer extends React.Component<
|
|
|
1096
1126
|
const liveCatchUpEnabled =
|
|
1097
1127
|
this.props.pluginConfiguration?.liveCatchUpEnabled;
|
|
1098
1128
|
|
|
1129
|
+
// Look the player up by id rather than using the active player: these
|
|
1130
|
+
// props are handed to this component's own native player view, so they
|
|
1131
|
+
// must describe that player's content even while a cast session is
|
|
1132
|
+
// running (during which `usePlayer` swaps React consumers over to the
|
|
1133
|
+
// casting receiver).
|
|
1134
|
+
const metadata = toNativeMetadata(
|
|
1135
|
+
playerManager.getPlayerWithId(this.props.playerId)?.getContent()
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1099
1138
|
const nativeProps = {
|
|
1100
1139
|
rate: undefined,
|
|
1101
1140
|
entry,
|
|
@@ -1114,6 +1153,7 @@ export default class VideoPlayer extends React.Component<
|
|
|
1114
1153
|
liveCatchUpEnabled,
|
|
1115
1154
|
playerId: this.props.playerId,
|
|
1116
1155
|
pictureInPictureEnabled,
|
|
1156
|
+
metadata,
|
|
1117
1157
|
seekStep:
|
|
1118
1158
|
(this.props.pluginConfiguration?.seek_duration || SEEK_TIME) * 1000,
|
|
1119
1159
|
accessibilityProps: getAllAccessibilityProps(
|
|
@@ -1170,10 +1210,15 @@ export default class VideoPlayer extends React.Component<
|
|
|
1170
1210
|
return null;
|
|
1171
1211
|
}
|
|
1172
1212
|
|
|
1173
|
-
const
|
|
1213
|
+
const isTvPlatform = isTV();
|
|
1214
|
+
|
|
1215
|
+
const TransportControls = isTvPlatform
|
|
1174
1216
|
? TransportControlsTV
|
|
1175
1217
|
: TransportControlsMobile;
|
|
1176
1218
|
|
|
1219
|
+
// The mobile and TV transport controls read Title/Subtitle from the
|
|
1220
|
+
// player manager's content channel (via `usePlayerContent`) instead of
|
|
1221
|
+
// receiving them as props.
|
|
1177
1222
|
const needToShowDefaultControls = !(
|
|
1178
1223
|
(controls && !castAction?.state?.connected) ||
|
|
1179
1224
|
this.getPlayNextData()
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { ModalHeader } from "@applicaster/zapp-react-native-ui-components/Components/ModalComponent/Header";
|
|
3
|
-
import { isNil } from "@applicaster/zapp-react-native-utils/utils";
|
|
4
|
-
|
|
5
|
-
const formatTime = (milliseconds: number) => {
|
|
6
|
-
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
7
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
8
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
9
|
-
const seconds = totalSeconds % 60;
|
|
10
|
-
|
|
11
|
-
if (milliseconds < 3600000) {
|
|
12
|
-
return `${minutes.toString().padStart(2, "0")}:${seconds
|
|
13
|
-
.toString()
|
|
14
|
-
.padStart(2, "0")}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return `${hours.toString().padStart(2, "0")}:${minutes
|
|
18
|
-
.toString()
|
|
19
|
-
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export const SleepTimerModalHeader = ({
|
|
23
|
-
summary,
|
|
24
|
-
...props
|
|
25
|
-
}: {
|
|
26
|
-
summary?: string;
|
|
27
|
-
}) => {
|
|
28
|
-
const sleepTimerDate = +summary;
|
|
29
|
-
|
|
30
|
-
const [timeLeft, setTimeLeft] = React.useState(
|
|
31
|
-
sleepTimerDate !== 0 ? sleepTimerDate - Date.now() : 0
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const showCounter = React.useMemo(() => sleepTimerDate > 0, [sleepTimerDate]);
|
|
35
|
-
|
|
36
|
-
React.useLayoutEffect(() => {
|
|
37
|
-
if (showCounter && sleepTimerDate !== 0) {
|
|
38
|
-
setTimeLeft(sleepTimerDate - Date.now());
|
|
39
|
-
|
|
40
|
-
const intervalId = setInterval(() => {
|
|
41
|
-
setTimeLeft(() => {
|
|
42
|
-
const newTimeLeft = sleepTimerDate - Date.now();
|
|
43
|
-
|
|
44
|
-
if (newTimeLeft <= 0) {
|
|
45
|
-
clearInterval(intervalId);
|
|
46
|
-
|
|
47
|
-
return 0;
|
|
48
|
-
} else {
|
|
49
|
-
return newTimeLeft;
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
}, 1000);
|
|
53
|
-
|
|
54
|
-
return () => clearInterval(intervalId);
|
|
55
|
-
} else {
|
|
56
|
-
setTimeLeft(0);
|
|
57
|
-
}
|
|
58
|
-
}, [sleepTimerDate, showCounter]);
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<ModalHeader
|
|
62
|
-
summary={!isNil(summary) ? formatTime(timeLeft) : summary}
|
|
63
|
-
{...(props as any)}
|
|
64
|
-
/>
|
|
65
|
-
);
|
|
66
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./SleepTimerModalHeader";
|