@applicaster/quick-brick-player 16.0.0-rc.71 → 16.0.0-rc.73

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/quick-brick-player",
3
- "version": "16.0.0-rc.71",
3
+ "version": "16.0.0-rc.73",
4
4
  "description": "Quick Brick Player",
5
5
  "main": "./src/index.ts",
6
6
  "types": "index.d.ts",
@@ -35,11 +35,11 @@
35
35
  },
36
36
  "homepage": "https://github.com/applicaster/quickbrick#readme",
37
37
  "dependencies": {
38
- "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.60",
38
+ "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.73",
39
39
  "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.69",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.71",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.71",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.71",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.73",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.73",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.73",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -1,14 +1,16 @@
1
1
  import {
2
+ controlsRowActionIdentifiers,
2
3
  nextTrackButton,
3
4
  previousTrackButton,
4
5
  usesPlaylistControls,
5
6
  } from "../utils";
7
+ import { PLAYER_ACTION_IDENTIFIERS } from "../../../playerActions";
6
8
 
7
- describe("usesPlaylistControls", () => {
8
- const configuredAs = (layout?: unknown): GetValue =>
9
- ((key: string) =>
10
- key === "player_controls_layout" ? layout : null) as unknown as GetValue;
9
+ const configuredAs = (layout?: unknown): GetValue =>
10
+ ((key: string) =>
11
+ key === "player_controls_layout" ? layout : null) as unknown as GetValue;
11
12
 
13
+ describe("usesPlaylistControls", () => {
12
14
  it("opts in only for the playlist controls value", () => {
13
15
  expect(usesPlaylistControls(configuredAs("playlist_controls"))).toBe(true);
14
16
  });
@@ -29,6 +31,25 @@ describe("usesPlaylistControls", () => {
29
31
  });
30
32
  });
31
33
 
34
+ describe("controlsRowActionIdentifiers", () => {
35
+ it("claims playback speed and sleep timer on the default layout", () => {
36
+ expect(
37
+ controlsRowActionIdentifiers(
38
+ configuredAs("playback_speed_and_sleep_timer")
39
+ )
40
+ ).toEqual([
41
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
42
+ PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
43
+ ]);
44
+ });
45
+
46
+ it("claims nothing when the row shows playlist controls", () => {
47
+ expect(
48
+ controlsRowActionIdentifiers(configuredAs("playlist_controls"))
49
+ ).toEqual([]);
50
+ });
51
+ });
52
+
32
53
  describe("track control buttons", () => {
33
54
  it("builds a previous button using the previous_track icon", () => {
34
55
  const onPress = jest.fn();
@@ -1,3 +1,5 @@
1
+ import { PLAYER_ACTION_IDENTIFIERS } from "../../playerActions";
2
+
1
3
  const playbackButtonMargins = (
2
4
  isTablet: boolean,
3
5
  isLiveShowFullControls: boolean
@@ -109,6 +111,18 @@ export const sleepButton = (props: ButtonsProps): ControlsButton =>
109
111
  export const usesPlaylistControls = (value: GetValue): boolean =>
110
112
  value("player_controls_layout") === "playlist_controls";
111
113
 
114
+ /**
115
+ * Player actions this row renders as its own buttons. Consumers of the shared
116
+ * player action list have to filter these out to avoid showing them twice.
117
+ */
118
+ export const controlsRowActionIdentifiers = (value: GetValue): string[] =>
119
+ usesPlaylistControls(value)
120
+ ? []
121
+ : [
122
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
123
+ PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
124
+ ];
125
+
112
126
  type TrackButtonProps = ButtonsProps & { disabled?: boolean };
113
127
 
114
128
  const trackButtonByName = ({
@@ -0,0 +1,257 @@
1
+ import React from "react";
2
+ import { render, waitFor } from "@testing-library/react-native";
3
+
4
+ import { TransportInfo } from "../index";
5
+ import { resolvePlayerActions } from "@applicaster/zapp-react-native-utils/playerUtils";
6
+ import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
7
+ import { TransportInfo as MobileTransportInfo } from "@applicaster/quick-brick-mobile-transport-controls";
8
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
9
+ import { PLAYER_ACTION_IDENTIFIERS } from "../../../playerActions";
10
+
11
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
12
+ useIsTablet: () => false,
13
+ }));
14
+
15
+ jest.mock("react-native-safe-area-context", () => ({
16
+ useSafeAreaFrame: () => ({ height: 800, width: 400 }),
17
+ }));
18
+
19
+ jest.mock(
20
+ "@applicaster/zapp-react-native-ui-components/Hooks/useEntryActionsExpander",
21
+ () => ({ useEntryActionsExpander: () => () => [] })
22
+ );
23
+
24
+ jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
25
+ usePlugins: jest.fn(() => []),
26
+ }));
27
+
28
+ jest.mock("@applicaster/zapp-react-native-utils/playerUtils", () => {
29
+ const actual = jest.requireActual(
30
+ "@applicaster/zapp-react-native-utils/playerUtils"
31
+ );
32
+
33
+ return {
34
+ ...actual,
35
+ resolvePlayerActions: jest.fn(),
36
+ };
37
+ });
38
+
39
+ jest.mock("@applicaster/quick-brick-mobile-transport-controls", () => ({
40
+ TransportInfo: jest.fn(() => null),
41
+ }));
42
+
43
+ const MobileTransportInfoMock = MobileTransportInfo as unknown as jest.Mock;
44
+ const resolvePlayerActionsMock = resolvePlayerActions as jest.Mock;
45
+ const usePluginsMock = usePlugins as jest.Mock;
46
+
47
+ const action = (identifier: string) =>
48
+ ({ identifier, action: { invokeAction: jest.fn() } }) as any;
49
+
50
+ const valueForLayout = (layout?: string): GetValue =>
51
+ ((key: string) =>
52
+ key === "player_controls_layout" ? layout : null) as unknown as GetValue;
53
+
54
+ const entry: ZappEntry = {
55
+ id: "audio-1",
56
+ title: "Track",
57
+ type: { value: "audio" },
58
+ };
59
+
60
+ const layoutState = { inline: false, docked: false, isModal: true };
61
+
62
+ describe("Audio TransportInfo overflow wiring", () => {
63
+ let getActionsSpy: jest.SpyInstance;
64
+
65
+ beforeEach(() => {
66
+ jest.clearAllMocks();
67
+
68
+ resolvePlayerActionsMock.mockReturnValue([
69
+ action("favorites"),
70
+ action("offline-content-button"),
71
+ action("native_share_action"),
72
+ ]);
73
+
74
+ getActionsSpy = jest
75
+ .spyOn(uiActionsRegistry, "getActions")
76
+ .mockImplementation((identifier: string) => {
77
+ if (
78
+ identifier === PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED ||
79
+ identifier === PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER
80
+ ) {
81
+ return [action(identifier)];
82
+ }
83
+
84
+ return [];
85
+ });
86
+ });
87
+
88
+ afterEach(() => {
89
+ getActionsSpy.mockRestore();
90
+ });
91
+
92
+ it("passes resolved actions as descriptors and enables overflow when the plugin is present", () => {
93
+ usePluginsMock.mockReturnValue([
94
+ { identifier: "open-modal-bottom-sheet-cell-action" },
95
+ ]);
96
+
97
+ render(
98
+ <TransportInfo
99
+ entry={entry}
100
+ layoutState={layoutState}
101
+ value={valueForLayout("playback_speed_and_sleep_timer")}
102
+ configuration={{ player_action_buttons: "a,b,c" }}
103
+ />
104
+ );
105
+
106
+ expect(resolvePlayerActionsMock).toHaveBeenCalledWith({
107
+ configuration: { player_action_buttons: "a,b,c" },
108
+ entry,
109
+ expandEntryActions: expect.any(Function),
110
+ });
111
+
112
+ const props = MobileTransportInfoMock.mock.calls[0][0];
113
+
114
+ expect(props.actionButtonDescriptors).toEqual([
115
+ {
116
+ kind: "action",
117
+ action: expect.objectContaining({ identifier: "favorites" }),
118
+ },
119
+ {
120
+ kind: "action",
121
+ action: expect.objectContaining({
122
+ identifier: "offline-content-button",
123
+ }),
124
+ },
125
+ {
126
+ kind: "action",
127
+ action: expect.objectContaining({ identifier: "native_share_action" }),
128
+ },
129
+ ]);
130
+
131
+ expect(props.actionButtons).toBeUndefined();
132
+
133
+ // Controls row renders speed and sleep timer itself in this layout.
134
+ expect(getActionsSpy).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it("appends playback speed and sleep timer when playlist controls layout is active", async () => {
138
+ usePluginsMock.mockReturnValue([
139
+ { identifier: "open-modal-bottom-sheet-cell-action" },
140
+ ]);
141
+
142
+ resolvePlayerActionsMock.mockReturnValue([
143
+ action("favorites"),
144
+ action("offline-content-button"),
145
+ ]);
146
+
147
+ // The audio player registers these in a later effect than our first paint.
148
+ let registered = false;
149
+
150
+ getActionsSpy.mockImplementation((identifier: string) => {
151
+ if (
152
+ !registered ||
153
+ (identifier !== PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED &&
154
+ identifier !== PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
155
+ ) {
156
+ return [];
157
+ }
158
+
159
+ return [action(identifier)];
160
+ });
161
+
162
+ render(
163
+ <TransportInfo
164
+ entry={entry}
165
+ layoutState={layoutState}
166
+ value={valueForLayout("playlist_controls")}
167
+ configuration={{ player_action_buttons: "favorites,offline" }}
168
+ />
169
+ );
170
+
171
+ expect(
172
+ MobileTransportInfoMock.mock.calls[0][0].actionButtonDescriptors
173
+ ).toEqual([
174
+ {
175
+ kind: "action",
176
+ action: expect.objectContaining({ identifier: "favorites" }),
177
+ },
178
+ {
179
+ kind: "action",
180
+ action: expect.objectContaining({
181
+ identifier: "offline-content-button",
182
+ }),
183
+ },
184
+ ]);
185
+
186
+ // Registering for real is what tells subscribers to re-read the registry.
187
+ registered = true;
188
+
189
+ const unregister = uiActionsRegistry.registerAction(
190
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
191
+ { invokeAction: jest.fn() }
192
+ );
193
+
194
+ await waitFor(() => {
195
+ const latest =
196
+ MobileTransportInfoMock.mock.calls[
197
+ MobileTransportInfoMock.mock.calls.length - 1
198
+ ][0];
199
+
200
+ expect(latest.actionButtonDescriptors).toEqual([
201
+ {
202
+ kind: "action",
203
+ action: expect.objectContaining({ identifier: "favorites" }),
204
+ },
205
+ {
206
+ kind: "action",
207
+ action: expect.objectContaining({
208
+ identifier: "offline-content-button",
209
+ }),
210
+ },
211
+ {
212
+ kind: "action",
213
+ action: expect.objectContaining({
214
+ identifier: PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
215
+ }),
216
+ },
217
+ {
218
+ kind: "action",
219
+ action: expect.objectContaining({
220
+ identifier: PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
221
+ }),
222
+ },
223
+ ]);
224
+ });
225
+
226
+ unregister();
227
+ });
228
+
229
+ it("does not duplicate playback speed or sleep timer if already configured", () => {
230
+ resolvePlayerActionsMock.mockReturnValue([
231
+ action("favorites"),
232
+ action(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED),
233
+ action(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER),
234
+ ]);
235
+
236
+ render(
237
+ <TransportInfo
238
+ entry={entry}
239
+ layoutState={layoutState}
240
+ value={valueForLayout("playlist_controls")}
241
+ configuration={{}}
242
+ />
243
+ );
244
+
245
+ const identifiers =
246
+ MobileTransportInfoMock.mock.calls[0][0].actionButtonDescriptors.map(
247
+ (descriptor: { action: { identifier: string } }) =>
248
+ descriptor.action.identifier
249
+ );
250
+
251
+ expect(identifiers).toEqual([
252
+ "favorites",
253
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
254
+ PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
255
+ ]);
256
+ });
257
+ });
@@ -3,7 +3,8 @@ import { LayoutChangeEvent, StyleSheet, View } from "react-native";
3
3
 
4
4
  import { TransportInfo as MobileTransportInfo } from "@applicaster/quick-brick-mobile-transport-controls";
5
5
  import { useIsTablet } from "@applicaster/zapp-react-native-utils/reactHooks";
6
- import { useLanguageData } from "@applicaster/zapp-react-native-utils/reactHooks/player";
6
+ import { useRegistryRevision } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
7
+ import { useEntryActionsExpander } from "@applicaster/zapp-react-native-ui-components/Hooks/useEntryActionsExpander";
7
8
 
8
9
  import { toPositiveNumberWithDefaultZero } from "@applicaster/zapp-react-native-utils/numberUtils";
9
10
  import { noop } from "@applicaster/zapp-react-native-utils/functionUtils";
@@ -12,7 +13,22 @@ import { NAVBAR_HEIGHT, TABLET_LANDSCAPE } from "../const";
12
13
  import { withDebug } from "../debug";
13
14
  import { useSafeAreaFrame } from "react-native-safe-area-context";
14
15
  import { isSmallTabletHeight } from "../utils";
15
- import { getPlayerActionButtons } from "@applicaster/zapp-react-native-utils/playerUtils";
16
+ import {
17
+ ActionButtonDescriptor,
18
+ resolvePlayerActions,
19
+ } from "@applicaster/zapp-react-native-utils/playerUtils";
20
+ import {
21
+ RegisteredAction,
22
+ uiActionsRegistry,
23
+ } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
24
+
25
+ import { PLAYER_ACTION_IDENTIFIERS } from "../../playerActions";
26
+ import { controlsRowActionIdentifiers } from "../Controls/utils";
27
+
28
+ /** Player-owned actions that join the shared action list of the overlay. */
29
+ const AUDIO_PLAYER_ACTION_IDENTIFIERS: string[] = Object.values(
30
+ PLAYER_ACTION_IDENTIFIERS
31
+ );
16
32
 
17
33
  const styles = StyleSheet.create({
18
34
  container: {
@@ -52,7 +68,8 @@ export const TransportInfo = ({
52
68
  }: Props) => {
53
69
  const isTablet = useIsTablet();
54
70
  const safeAreaFrame = useSafeAreaFrame();
55
- const languageData = useLanguageData("audio-player");
71
+ const registryRevision = useRegistryRevision();
72
+ const expandEntryActions = useEntryActionsExpander();
56
73
 
57
74
  const [marginBottom, setMarginBottom] = React.useState(0);
58
75
 
@@ -81,11 +98,56 @@ export const TransportInfo = ({
81
98
  [onLayout]
82
99
  );
83
100
 
84
- const actionButtons = React.useMemo(
85
- () => getPlayerActionButtons(configuration),
86
- [configuration]
101
+ // Joined so the memo below is not invalidated on every render.
102
+ const controlsRowIdentifiersKey =
103
+ controlsRowActionIdentifiers(value).join(",");
104
+
105
+ const overlayActionIdentifiers = React.useMemo(() => {
106
+ const renderedByControlsRow = controlsRowIdentifiersKey.split(",");
107
+
108
+ return AUDIO_PLAYER_ACTION_IDENTIFIERS.filter(
109
+ (identifier) => !renderedByControlsRow.includes(identifier)
110
+ );
111
+ }, [controlsRowIdentifiersKey]);
112
+
113
+ const readOwnPlayerActions = React.useCallback(
114
+ (): RegisteredAction[] =>
115
+ overlayActionIdentifiers.flatMap((identifier) =>
116
+ uiActionsRegistry.getActions(identifier, entry)
117
+ ),
118
+ [entry, overlayActionIdentifiers]
119
+ );
120
+
121
+ // Playback speed and the sleep timer register in a later effect than our
122
+ // first render - speed waits for SpeedController state - so re-read whenever
123
+ // the registry changes instead of guessing when they will be there.
124
+ const playerActions = React.useMemo(
125
+ () => readOwnPlayerActions(),
126
+ // eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
127
+ [readOwnPlayerActions, registryRevision]
87
128
  );
88
129
 
130
+ const actionButtonDescriptors = React.useMemo<
131
+ ActionButtonDescriptor[]
132
+ >(() => {
133
+ const content = resolvePlayerActions({
134
+ configuration,
135
+ entry,
136
+ expandEntryActions,
137
+ });
138
+
139
+ const seen = new Set(content.map((action) => action.identifier));
140
+
141
+ const extras = playerActions.filter(
142
+ (action) => action?.identifier && !seen.has(action.identifier)
143
+ );
144
+
145
+ return [...content, ...extras].map((action) => ({
146
+ kind: "action" as const,
147
+ action,
148
+ }));
149
+ }, [configuration, entry, playerActions, expandEntryActions]);
150
+
89
151
  return (
90
152
  <View
91
153
  style={[
@@ -106,9 +168,8 @@ export const TransportInfo = ({
106
168
  content={entry}
107
169
  value={value}
108
170
  visible
109
- languageData={languageData}
110
171
  layoutState={layoutState}
111
- actionButtons={actionButtons}
172
+ actionButtonDescriptors={actionButtonDescriptors}
112
173
  />
113
174
  </View>
114
175
  </View>
@@ -78,6 +78,8 @@ describe("playerActions", () => {
78
78
  playback_sleep_off_label: "Off",
79
79
  sleep_timer: "sleep_timer",
80
80
  sleep_timer_active: "sleep_timer_active",
81
+ speed_1: "speed_1_asset_uri",
82
+ speed_1_5: "speed_1_5_asset_uri",
81
83
  }),
82
84
  setPlaybackRate: jest.fn(),
83
85
  startSleepTimer: jest.fn(),
@@ -325,6 +327,31 @@ describe("playerActions", () => {
325
327
  });
326
328
  });
327
329
 
330
+ it("initialEntryState for playback-speed uses the current speed icon asset", async () => {
331
+ const action = uiActionsRegistry.getAction(
332
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED
333
+ );
334
+
335
+ expect(action.initialEntryState({} as ZappEntry)).toMatchObject({
336
+ label: "Playback Speed",
337
+ asset: "speed_1_asset_uri",
338
+ });
339
+
340
+ await speedController.setPlaybackSpeed({
341
+ asset: "speed_1_5",
342
+ value: 1.5,
343
+ });
344
+
345
+ const observed = await firstValueFrom(
346
+ action.observeEntryState({} as ZappEntry)
347
+ );
348
+
349
+ expect(observed).toMatchObject({
350
+ label: "Playback Speed",
351
+ asset: "speed_1_5_asset_uri",
352
+ });
353
+ });
354
+
328
355
  it("registers UI action identifiers that open the matching sheets", async () => {
329
356
  await uiActionsRegistry
330
357
  .getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
@@ -75,6 +75,22 @@ function sleepTimerEntryState(
75
75
  };
76
76
  }
77
77
 
78
+ function playbackSpeedEntryState(speed: {
79
+ asset?: string;
80
+ value?: number;
81
+ }): CellActionEntryState {
82
+ const config = getPlayerConfig();
83
+ const assetKey = speed?.asset || formatSpeed(speed?.value ?? 1);
84
+
85
+ return {
86
+ state: 0,
87
+ label: config.playback_speed_title || "Playback Speed",
88
+ // Manifest keys are speed_0_8 / speed_1 / … (same icons as the transport row).
89
+ // There is no playback_speed uploader.
90
+ asset: config[assetKey] || config.speed_1 || "",
91
+ };
92
+ }
93
+
78
94
  export async function openPlaybackSpeedAction(
79
95
  speedController: SpeedController
80
96
  ): Promise<ActionResult> {
@@ -220,11 +236,12 @@ export function registerPlayerActions(options: {
220
236
  uiActionsRegistry.registerAction(
221
237
  PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
222
238
  {
223
- initialEntryState: () => ({
224
- state: 0,
225
- label: getPlayerConfig().playback_speed_title || "Playback Speed",
226
- asset: getPlayerConfig().playback_speed || "",
227
- }),
239
+ initialEntryState: () =>
240
+ playbackSpeedEntryState(speedController.currentSpeed),
241
+ observeEntryState: () =>
242
+ speedController.playbackSpeed$.pipe(
243
+ map((speed) => playbackSpeedEntryState(speed))
244
+ ),
228
245
  invokeAction: async () => {
229
246
  await actionExecutor.handleAction({
230
247
  type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,