@lotics/ui 37.0.0 → 37.1.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
@@ -1519,6 +1519,28 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1519
1519
  the sandboxed app iframe.
1520
1520
  - **`file_preview_types`** — `PreviewLabels` / `FilePreviewProps` / `GalleryLabels` — the
1521
1521
  shared label + prop contracts of the file-preview family; types only.
1522
+ - **`media_player`** — `MediaPlayer`: the ONE audio/video playback surface (`FilePreview`
1523
+ composes it; so does the chat's recording message). Transport is the PLATFORM element's
1524
+ — `controls preload="metadata"` — deliberately, because a hand-rolled scrub bar has to
1525
+ re-earn keyboard access, screen-reader semantics, volume, speed, PiP, fullscreen and
1526
+ captions that `<video>`/`<audio>` already get right. What it owns is what the element
1527
+ does not: `kind` picks the element (never inferred from MIME — a screen recording holds
1528
+ both streams and the CALLER decides whether this surface shows a picture), a REQUIRED
1529
+ `accessibilityLabel`, and `objectFit: contain` so a capture letterboxes rather than
1530
+ cropping evidence. It **fills its parent and carries no size of its own** — a lightbox
1531
+ wants the modal's height, a chat bubble wants a fixed 16:9 box that will not resize when
1532
+ metadata arrives, and a `fill`/`compact` prop to switch between them is exactly the
1533
+ branching the kit forbids. Compose the box. Filling is done OUT OF FLOW (the video sits
1534
+ absolutely inside a positioning wrapper the component owns) because a `<video>` is a
1535
+ replaced element: once metadata loads it reports the source's intrinsic size, and any
1536
+ ancestor sized by its content grows to match — with RN-web's `flexShrink: 0` default,
1537
+ nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
1538
+ the moment it loaded. **Do not add your own fullscreen/expand control**: the native
1539
+ transport already carries fullscreen, picture-in-picture and download, and a second
1540
+ affordance beside them is both redundant and a claim about where it leads. Native renders
1541
+ the `notAvailable` placeholder — playback there needs a native media dep the package does
1542
+ not carry.
1543
+ - **`media_player_types`** — `MediaPlayerProps`; types only.
1522
1544
  - **`file_gallery_modal`** — `FileGalleryModal`: the FULL-SCREEN viewer — toolbar (filename
1523
1545
  , counter, download, optional `onOpenExternal`/`onRemove`, close-✕), prev/next, ESC,
1524
1546
  rotate (the 90° controls FLOAT as a pill on the image); on a phone the actions collapse
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "37.0.0",
3
+ "version": "37.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -39,6 +39,11 @@
39
39
  "default": "./src/file_preview.web.tsx"
40
40
  },
41
41
  "./file_preview_types": "./src/file_preview_types.ts",
42
+ "./media_player": {
43
+ "react-native": "./src/media_player.tsx",
44
+ "default": "./src/media_player.web.tsx"
45
+ },
46
+ "./media_player_types": "./src/media_player_types.ts",
42
47
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
43
48
  "./image_gallery": "./src/image_gallery.tsx",
44
49
  "./rotatable_image": "./src/rotatable_image.tsx",
@@ -17,6 +17,7 @@ import { type FilePreviewProps, type PreviewLabels } from "./file_preview_types"
17
17
  import { useLoticsLocale } from "./locale";
18
18
  import { SpreadsheetView } from "./spreadsheet_view";
19
19
  import { RotatableImage } from "./rotatable_image";
20
+ import { MediaPlayer } from "./media_player.web";
20
21
  import { downloadFileFromUrl } from "./download";
21
22
  import { colors } from "./colors";
22
23
 
@@ -56,13 +57,19 @@ export function FilePreview({ file, labels, onError, rotation, credentials }: Fi
56
57
  return <WordPreview file={file} labels={l} onError={onError} credentials={credentials} />;
57
58
  }
58
59
  if (isVideoMimeType(file.mimeType)) {
59
- return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
60
+ return (
61
+ <div style={videoFillStyle}>
62
+ <MediaPlayer src={file.url} kind="video" accessibilityLabel={file.filename} />
63
+ </div>
64
+ );
60
65
  }
61
66
  if (isAudioMimeType(file.mimeType)) {
62
67
  return (
63
68
  <div style={audioContainerStyle}>
64
69
  <MediaCard mimeType={file.mimeType} filename={file.filename} icon="music" size={160} />
65
- <audio controls preload="metadata" src={file.url} style={audioElementStyle} />
70
+ <div style={audioElementStyle}>
71
+ <MediaPlayer src={file.url} kind="audio" accessibilityLabel={file.filename} />
72
+ </div>
66
73
  </div>
67
74
  );
68
75
  }
@@ -505,12 +512,7 @@ const pdfOverlayStyle: React.CSSProperties = {
505
512
  justifyContent: "center",
506
513
  pointerEvents: "none",
507
514
  };
508
- const mediaElementStyle: React.CSSProperties = {
509
- width: "100%",
510
- height: "100%",
511
- backgroundColor: "rgba(10, 10, 10, 0.8)",
512
- objectFit: "contain",
513
- };
515
+ const videoFillStyle: React.CSSProperties = { width: "100%", height: "100%" };
514
516
  const audioContainerStyle: React.CSSProperties = {
515
517
  display: "flex",
516
518
  flexDirection: "column",
@@ -0,0 +1,36 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
5
+ import { useLoticsLocale } from "./locale";
6
+ import type { MediaPlayerProps } from "./media_player_types";
7
+
8
+ /**
9
+ * Native fallback. Playback needs a native media dependency this package does
10
+ * not carry, so it says so rather than rendering an inert box — the same choice
11
+ * `FilePreview` makes for the formats only its web variant can draw.
12
+ */
13
+ export function MediaPlayer(props: MediaPlayerProps) {
14
+ const { kind, accessibilityLabel, testID } = props;
15
+ const l = useLoticsLocale().gallery;
16
+ return (
17
+ <View testID={testID} accessibilityLabel={accessibilityLabel} style={styles.placeholder}>
18
+ <Icon name={kind === "video" ? "monitor" : "music"} size={20} color={colors.zinc["400"]} />
19
+ <Text size="sm" color="muted">
20
+ {l.notAvailable}
21
+ </Text>
22
+ </View>
23
+ );
24
+ }
25
+
26
+ const styles = StyleSheet.create({
27
+ placeholder: {
28
+ flexDirection: "row",
29
+ alignItems: "center",
30
+ justifyContent: "center",
31
+ gap: 8,
32
+ padding: 16,
33
+ borderRadius: 8,
34
+ backgroundColor: colors.zinc["100"],
35
+ },
36
+ });
@@ -0,0 +1,76 @@
1
+ import { colors } from "./colors";
2
+ import type { MediaPlayerProps } from "./media_player_types";
3
+
4
+ /**
5
+ * Playback surface for one audio or video source.
6
+ *
7
+ * Transport is the PLATFORM's, deliberately. A hand-rolled play/pause/scrub bar
8
+ * would have to re-earn keyboard access, screen-reader semantics, volume, speed,
9
+ * picture-in-picture, fullscreen, download and captions — all of which the
10
+ * native element already does correctly, and which callers should NOT duplicate
11
+ * with a control of their own.
12
+ *
13
+ * It FILLS its parent rather than carrying a size. A lightbox wants the video to
14
+ * take the modal's height; a chat bubble wants a 16:9 box that does not resize
15
+ * when metadata arrives. Those are the caller's boxes, and a `fill`/`compact`
16
+ * prop to switch between them is the branching this codebase forbids — so the
17
+ * caller composes the box and this draws inside it.
18
+ */
19
+ export function MediaPlayer(props: MediaPlayerProps) {
20
+ const { src, kind, accessibilityLabel, testID } = props;
21
+
22
+ if (kind === "audio") {
23
+ return (
24
+ <audio
25
+ data-testid={testID}
26
+ aria-label={accessibilityLabel}
27
+ controls
28
+ preload="metadata"
29
+ src={src}
30
+ style={audioStyle}
31
+ />
32
+ );
33
+ }
34
+
35
+ // A `<video>` is a REPLACED element: once metadata loads it reports the
36
+ // source's intrinsic size as its content size, and any ancestor sized by its
37
+ // content grows to match. React-native-web views default to `flexShrink: 0`,
38
+ // so nothing pushes back — a 1280x720 capture measurably widened a 342px chat
39
+ // column to 367px the moment it loaded, breaking the very "does not resize"
40
+ // promise above. Taking the media OUT OF FLOW is what makes filling safe, and
41
+ // it lives here rather than in each caller because a caller cannot see the
42
+ // intrinsic size that causes it. The wrapper carries the positioning context
43
+ // so the media cannot escape to some unrelated positioned ancestor.
44
+ return (
45
+ <div style={videoWrapperStyle}>
46
+ <video
47
+ data-testid={testID}
48
+ aria-label={accessibilityLabel}
49
+ controls
50
+ preload="metadata"
51
+ src={src}
52
+ style={videoStyle}
53
+ />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ // Inline rather than StyleSheet: these land on DOM elements, not RN views.
59
+ const audioStyle = { width: "100%" } as const;
60
+ const videoWrapperStyle = {
61
+ position: "relative",
62
+ width: "100%",
63
+ height: "100%",
64
+ overflow: "hidden",
65
+ } as const;
66
+ const videoStyle = {
67
+ position: "absolute",
68
+ inset: 0,
69
+ width: "100%",
70
+ height: "100%",
71
+ display: "block",
72
+ // Letterbox rather than crop or stretch: a screen capture is evidence, and a
73
+ // cropped one silently hides part of what was on screen.
74
+ objectFit: "contain",
75
+ backgroundColor: colors.zinc["900"],
76
+ } as const;
@@ -0,0 +1,13 @@
1
+ export interface MediaPlayerProps {
2
+ /** Already-served URL for the media — signing/auth is the host's problem. */
3
+ src: string;
4
+ /**
5
+ * Which element to render. Not inferred from the MIME type on purpose: a
6
+ * screen recording carries both streams in one file, and the caller — not the
7
+ * container format — decides whether this surface is showing a picture.
8
+ */
9
+ kind: "audio" | "video";
10
+ /** Accessible name for the player. Required: an unlabelled one is a blank control. */
11
+ accessibilityLabel: string;
12
+ testID?: string;
13
+ }