@lotics/ui 40.0.0 → 40.2.0
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/docs/catalog.md +12 -1
- package/package.json +1 -1
- package/src/locale.tsx +8 -0
- package/src/media_player.web.tsx +90 -1
- package/src/media_player_types.ts +22 -0
package/docs/catalog.md
CHANGED
|
@@ -1569,7 +1569,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1569
1569
|
nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
|
|
1570
1570
|
the moment it loaded. **Do not add your own fullscreen/expand control**: the native
|
|
1571
1571
|
transport already carries fullscreen, picture-in-picture and download, and a second
|
|
1572
|
-
affordance beside them is both redundant and a claim about where it leads.
|
|
1572
|
+
affordance beside them is both redundant and a claim about where it leads. A source it
|
|
1573
|
+
cannot load is surfaced, never silent — a media element that fails paints an EMPTY BOX and
|
|
1574
|
+
reports nothing, so an expired serving token is indistinguishable from a feature that never
|
|
1575
|
+
produced a file; on error it renders a retryable notice filling the same box instead
|
|
1576
|
+
(`mediaPlayer` locale slice, worded agnostically because the kit does not know whether it is
|
|
1577
|
+
showing a recording, a clip or an attachment). Failure is remembered per SOURCE, so swapping
|
|
1578
|
+
`src` presents the new one rather than inheriting the old one's error, and the retry remounts
|
|
1579
|
+
the element — a re-render alone does not re-request the source. **`onError` reports the failure
|
|
1580
|
+
with the HTTP STATUS**, not just the `MediaError`: a lapsed token, a missing file and an
|
|
1581
|
+
unplayable codec all arrive as code 4, so the code alone cannot say which happened. The player
|
|
1582
|
+
resolves the status by re-requesting the source, and only when an `onError` is supplied — the
|
|
1583
|
+
kit carries no analytics, so the host decides whether the answer is worth a request. Native renders
|
|
1573
1584
|
the `notAvailable` placeholder — playback there needs a native media dep the package does
|
|
1574
1585
|
not carry.
|
|
1575
1586
|
- **`media_player_types`** — `MediaPlayerProps`; types only.
|
package/package.json
CHANGED
package/src/locale.tsx
CHANGED
|
@@ -114,6 +114,12 @@ export interface LoticsLocale {
|
|
|
114
114
|
* (open/download/remove + the confirm), the gallery chrome (close, prev/next,
|
|
115
115
|
* rotate), and the preview captions (not-available / load-failed / password). */
|
|
116
116
|
gallery: GalleryLabels;
|
|
117
|
+
/** `MediaPlayer`: the caption when a source will not load. Its own slice
|
|
118
|
+
* rather than the `gallery` one, whose wording is "preview" — true of
|
|
119
|
+
* `FilePreview` and wrong everywhere else the player is used, and a single
|
|
120
|
+
* string cannot mean both. Agnostic on purpose: the kit does not know
|
|
121
|
+
* whether it is showing a recording, a clip or an attachment. */
|
|
122
|
+
mediaPlayer: { loadFailed: string };
|
|
117
123
|
/** `Avatar`: the fallback name (initials + a11y label) shown when no `name`. */
|
|
118
124
|
avatar: { unknown: string };
|
|
119
125
|
/** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
|
|
@@ -306,6 +312,7 @@ export const en: LoticsLocale = {
|
|
|
306
312
|
selectDateRange: "Select date range", selectDate: "Select date",
|
|
307
313
|
clear: "Clear", done: "Done", placeholder: "All time",
|
|
308
314
|
},
|
|
315
|
+
mediaPlayer: { loadFailed: "Failed to load media" },
|
|
309
316
|
gallery: {
|
|
310
317
|
close: "Close",
|
|
311
318
|
previous: "Previous",
|
|
@@ -494,6 +501,7 @@ export const vi: LoticsLocale = {
|
|
|
494
501
|
download: "Tải xuống",
|
|
495
502
|
passwordProtected: "Tệp có mật khẩu — không xem trước được",
|
|
496
503
|
},
|
|
504
|
+
mediaPlayer: { loadFailed: "Không tải được nội dung" },
|
|
497
505
|
avatar: { unknown: "Không rõ" },
|
|
498
506
|
nav: { back: "Quay lại" },
|
|
499
507
|
chart: { noData: "Không có dữ liệu", total: "Tổng" },
|
package/src/media_player.web.tsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Text } from "./text";
|
|
1
6
|
import { colors } from "./colors";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
2
8
|
import type { MediaPlayerProps } from "./media_player_types";
|
|
3
9
|
|
|
4
10
|
/**
|
|
@@ -17,16 +23,68 @@ import type { MediaPlayerProps } from "./media_player_types";
|
|
|
17
23
|
* caller composes the box and this draws inside it.
|
|
18
24
|
*/
|
|
19
25
|
export function MediaPlayer(props: MediaPlayerProps) {
|
|
20
|
-
const { src, kind, accessibilityLabel, testID } = props;
|
|
26
|
+
const { src, kind, accessibilityLabel, onError, testID } = props;
|
|
27
|
+
const l = useLoticsLocale();
|
|
28
|
+
// Bumped to REMOUNT the element on retry. Clearing the flag alone leaves the
|
|
29
|
+
// failed element in place, and a media element does not re-request its source
|
|
30
|
+
// just because it re-rendered.
|
|
31
|
+
const [attempt, setAttempt] = useState(0);
|
|
32
|
+
// The failed SOURCE, not a boolean: this component keeps its position when a
|
|
33
|
+
// caller swaps `src` (a gallery stepping to the next clip), and a boolean
|
|
34
|
+
// would carry the previous file's failure onto a file that was never tried.
|
|
35
|
+
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
36
|
+
const failed = failedSrc === src;
|
|
37
|
+
|
|
38
|
+
const handleError = (element: HTMLMediaElement) => {
|
|
39
|
+
setFailedSrc(src);
|
|
40
|
+
// Only probe when someone is listening — the host pays a request for the
|
|
41
|
+
// answer, so it is not spent when nothing reads it.
|
|
42
|
+
if (!onError) return;
|
|
43
|
+
const code = element.error?.code ?? null;
|
|
44
|
+
void resolveStatus(src).then((status) => onError({ status, code }));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A media element that cannot load its source renders an EMPTY BOX and says
|
|
49
|
+
* nothing — no pixel changes, no message, nothing to act on. An expired
|
|
50
|
+
* file-serving token was indistinguishable from "the feature never recorded
|
|
51
|
+
* anything", for a recording that was intact on disk the whole time. Whatever
|
|
52
|
+
* the cause, a failed load has to be visible and retryable.
|
|
53
|
+
*/
|
|
54
|
+
if (failed) {
|
|
55
|
+
const notice = (
|
|
56
|
+
<View testID={testID ? `${testID}-error` : undefined} style={styles.error}>
|
|
57
|
+
<Icon name="circle-alert" size={20} color={colors.zinc["400"]} />
|
|
58
|
+
<Text size="sm" color="muted">
|
|
59
|
+
{l.mediaPlayer.loadFailed}
|
|
60
|
+
</Text>
|
|
61
|
+
<Button
|
|
62
|
+
title={l.errorState.retry}
|
|
63
|
+
color="secondary"
|
|
64
|
+
onPress={() => {
|
|
65
|
+
setFailedSrc(null);
|
|
66
|
+
setAttempt((n) => n + 1);
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
// Video fills a box the caller sized, so the notice has to fill it too or
|
|
72
|
+
// the frame half-empties on failure. The wrapper is a flex container for
|
|
73
|
+
// exactly that reason — measured, `flex: 1` inside the default block box
|
|
74
|
+
// left the notice 50px tall in a 206px frame.
|
|
75
|
+
return kind === "video" ? <div style={videoWrapperStyle}>{notice}</div> : notice;
|
|
76
|
+
}
|
|
21
77
|
|
|
22
78
|
if (kind === "audio") {
|
|
23
79
|
return (
|
|
24
80
|
<audio
|
|
81
|
+
key={attempt}
|
|
25
82
|
data-testid={testID}
|
|
26
83
|
aria-label={accessibilityLabel}
|
|
27
84
|
controls
|
|
28
85
|
preload="metadata"
|
|
29
86
|
src={src}
|
|
87
|
+
onError={(e) => handleError(e.currentTarget)}
|
|
30
88
|
style={audioStyle}
|
|
31
89
|
/>
|
|
32
90
|
);
|
|
@@ -44,21 +102,52 @@ export function MediaPlayer(props: MediaPlayerProps) {
|
|
|
44
102
|
return (
|
|
45
103
|
<div style={videoWrapperStyle}>
|
|
46
104
|
<video
|
|
105
|
+
key={attempt}
|
|
47
106
|
data-testid={testID}
|
|
48
107
|
aria-label={accessibilityLabel}
|
|
49
108
|
controls
|
|
50
109
|
preload="metadata"
|
|
51
110
|
src={src}
|
|
111
|
+
onError={(e) => handleError(e.currentTarget)}
|
|
52
112
|
style={videoStyle}
|
|
53
113
|
/>
|
|
54
114
|
</div>
|
|
55
115
|
);
|
|
56
116
|
}
|
|
57
117
|
|
|
118
|
+
const styles = StyleSheet.create({
|
|
119
|
+
error: {
|
|
120
|
+
flex: 1,
|
|
121
|
+
alignItems: "center",
|
|
122
|
+
justifyContent: "center",
|
|
123
|
+
gap: 8,
|
|
124
|
+
padding: 16,
|
|
125
|
+
borderRadius: 8,
|
|
126
|
+
backgroundColor: colors.zinc["100"],
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The status behind a failed load. A media element never exposes it — every
|
|
132
|
+
* transport failure arrives as the same opaque `MediaError` — so the only way to
|
|
133
|
+
* tell an expired token from a broken file is to ask again and read the code.
|
|
134
|
+
* Null means even that could not be determined (offline, or a source this
|
|
135
|
+
* origin may not read), which is itself the useful answer.
|
|
136
|
+
*/
|
|
137
|
+
async function resolveStatus(src: string): Promise<number | null> {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(src, { method: "HEAD", credentials: "include" });
|
|
140
|
+
return res.status;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
58
146
|
// Inline rather than StyleSheet: these land on DOM elements, not RN views.
|
|
59
147
|
const audioStyle = { width: "100%" } as const;
|
|
60
148
|
const videoWrapperStyle = {
|
|
61
149
|
position: "relative",
|
|
150
|
+
display: "flex",
|
|
62
151
|
width: "100%",
|
|
63
152
|
height: "100%",
|
|
64
153
|
overflow: "hidden",
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a source would not play.
|
|
3
|
+
*
|
|
4
|
+
* The media element's own `MediaError` is nearly useless on its own: a 401, a
|
|
5
|
+
* 404 and a genuinely unplayable codec all surface as code 4
|
|
6
|
+
* (`SRC_NOT_SUPPORTED`). The STATUS is what separates "the session lapsed" from
|
|
7
|
+
* "this file is broken", so the player resolves it before reporting.
|
|
8
|
+
*/
|
|
9
|
+
export interface MediaLoadFailure {
|
|
10
|
+
/** HTTP status of the source, or null when it could not be determined. */
|
|
11
|
+
status: number | null;
|
|
12
|
+
/** `MediaError.code` — 1 aborted, 2 network, 3 decode, 4 unsupported. */
|
|
13
|
+
code: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
export interface MediaPlayerProps {
|
|
2
17
|
/** Already-served URL for the media — signing/auth is the host's problem. */
|
|
3
18
|
src: string;
|
|
@@ -9,5 +24,12 @@ export interface MediaPlayerProps {
|
|
|
9
24
|
kind: "audio" | "video";
|
|
10
25
|
/** Accessible name for the player. Required: an unlabelled one is a blank control. */
|
|
11
26
|
accessibilityLabel: string;
|
|
27
|
+
/**
|
|
28
|
+
* Called once when a source fails to load. The player already SHOWS the
|
|
29
|
+
* failure; this is for the host to report it, because the kit carries no
|
|
30
|
+
* analytics of its own. Omit it and no status probe is performed — the work
|
|
31
|
+
* only happens when someone is listening.
|
|
32
|
+
*/
|
|
33
|
+
onError?: (failure: MediaLoadFailure) => void;
|
|
12
34
|
testID?: string;
|
|
13
35
|
}
|