@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 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. Native renders
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "40.0.0",
3
+ "version": "40.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
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" },
@@ -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
  }