Skip to content

Commit 784b325

Browse files
brainbicycleclaude
andauthored
feat: support video headers in editorial (#12853)
* first pass at video header * feat: overlay article hero text on video with gradient and shadows Update ArticleHero component to match web implementation for video heroes: - Position text overlay absolutely on top of video using LinearGradient - Add gradient background (transparent to rgba(0,0,0,0.6)) for readability - Style text in white with subtle shadows (15px blur, 0.25 opacity) - Maintain existing behavior for image heroes (text below image) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * needs adjustment, style tweaks, hardcoded height * account for safe area * fix: implement FULLSCREEN layout video height calculation Updates article hero video to match web implementation where video height is calculated as max(50vh - navHeight, 360px) instead of using aspect ratio. This ensures consistent behavior with the web FULLSCREEN layout, where the video fills a height-constrained container with object-fit: cover. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * test: add global LinearGradient mock and fix ArticleHero tests - Added react-native-linear-gradient mock to setupJest.tsx so it's available globally for all tests - Updated ArticleHero tests to explicitly set media: null in test data to ensure image rendering path is tested instead of video path - Removed local LinearGradient mock from ArticleHero tests since it's now global 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * make media explicitly null to avoid video codepath * make media explicitly null to avoid video codepath * tweaks to flags on android * minor cleanup * feat: optimize video loading and add video test - Changed video preload from "metadata" to "auto" for faster loading - Enabled WebView caching (cacheEnabled: true, cacheMode: "LOAD_DEFAULT") - Added mixedContentMode="always" for better Android network performance - Added Android-specific optimizations (javaScriptEnabled, domStorageEnabled, allowFileAccess) - Added testID to ArticleHeroVideo component for testing - Added test for video rendering - Fixed WebView mock to export both default and named WebView export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 9c90e97 commit 784b325

File tree

6 files changed

+214
-8
lines changed

6 files changed

+214
-8
lines changed

src/app/Scenes/Article/Components/ArticleHero.tsx

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,104 @@
11
import { Flex, Image, Spacer, Text, useScreenDimensions } from "@artsy/palette-mobile"
22
import { ArticleHero_article$key } from "__generated__/ArticleHero_article.graphql"
3+
import { ArticleHeroVideo } from "app/Scenes/Article/Components/ArticleHeroVideo"
34
import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag"
45
import { DateTime } from "luxon"
6+
import LinearGradient from "react-native-linear-gradient"
57
import { useFragment, graphql } from "react-relay"
68

79
interface ArticleHeroProps {
810
article: ArticleHero_article$key
911
}
1012

1113
export const ArticleHero: React.FC<ArticleHeroProps> = ({ article }) => {
12-
const { width } = useScreenDimensions()
14+
const { width, height: screenHeight, safeAreaInsets } = useScreenDimensions()
1315
const data = useFragment(ArticleHeroFragment, article)
1416
const showBlurhash = useFeatureFlag("ARShowBlurhashImagePlaceholder")
1517

18+
const hasVideo = !!data.hero?.media
19+
const hasImage = !!data.hero?.image?.url
20+
21+
if (hasVideo) {
22+
// Calculate height similar to web: max(50vh - navHeight, 360px)
23+
const navHeight = 50 + safeAreaInsets.top
24+
const videoHeight = Math.max(screenHeight * 0.5 - navHeight, 360)
25+
26+
return (
27+
<Flex style={{ marginTop: safeAreaInsets.top }}>
28+
<Flex width={width} height={videoHeight} position="relative">
29+
<ArticleHeroVideo videoUrl={data.hero.media} width={width} height={videoHeight} />
30+
<LinearGradient
31+
colors={["rgba(0,0,0,0)", "rgba(0,0,0,0.6)"]}
32+
start={{ x: 0, y: 0 }}
33+
end={{ x: 0, y: 1 }}
34+
style={{
35+
position: "absolute",
36+
bottom: 0,
37+
left: 0,
38+
right: 0,
39+
}}
40+
>
41+
<Flex px={2} pb={2} pt={4}>
42+
<Text variant="sm" color="white" fontWeight="bold">
43+
{data.vertical}
44+
</Text>
45+
46+
<Text
47+
variant="xl"
48+
color="white"
49+
style={{
50+
textShadowColor: "rgba(0,0,0,0.25)",
51+
textShadowRadius: 15,
52+
}}
53+
>
54+
{data.title}
55+
</Text>
56+
57+
<Text
58+
variant="md"
59+
color="white"
60+
style={{
61+
textShadowColor: "rgba(0,0,0,0.25)",
62+
textShadowRadius: 15,
63+
}}
64+
>
65+
{data.byline}
66+
</Text>
67+
68+
{!!data.publishedAt && (
69+
<Text
70+
color="white"
71+
variant="xs"
72+
mt={0.5}
73+
style={{
74+
textShadowColor: "rgba(0,0,0,0.25)",
75+
textShadowRadius: 15,
76+
}}
77+
>
78+
{DateTime.fromISO(data.publishedAt).toFormat("MMM d, yyyy")}
79+
</Text>
80+
)}
81+
</Flex>
82+
</LinearGradient>
83+
</Flex>
84+
</Flex>
85+
)
86+
}
87+
88+
// Render image with text below
1689
return (
1790
<>
18-
{!!data.hero?.image?.url && (
91+
{hasImage ? (
1992
<>
2093
<Image
2194
width={width}
2295
src={data.hero.image.url}
2396
aspectRatio={data.hero.image.aspectRatio}
2497
blurhash={showBlurhash ? data.hero.image.blurhash : undefined}
2598
/>
26-
2799
<Spacer y={2} />
28100
</>
29-
)}
101+
) : null}
30102

31103
<Flex mx={2}>
32104
<Text variant="xs" color="mono100">
@@ -60,6 +132,7 @@ const ArticleHeroFragment = graphql`
60132
publishedAt
61133
hero {
62134
... on ArticleFeatureSection {
135+
media
63136
image {
64137
aspectRatio
65138
blurhash
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Flex, useColor } from "@artsy/palette-mobile"
2+
import { useMemo } from "react"
3+
import { Platform } from "react-native"
4+
import { WebView } from "react-native-webview"
5+
6+
interface ArticleHeroVideoProps {
7+
videoUrl: string
8+
width: number
9+
height: number
10+
}
11+
12+
export const ArticleHeroVideo: React.FC<ArticleHeroVideoProps> = ({ videoUrl, width, height }) => {
13+
const color = useColor()
14+
const backgroundColor = color("mono30")
15+
16+
// Memoize HTML to prevent re-renders
17+
const html = useMemo(
18+
() => `
19+
<!DOCTYPE html>
20+
<html>
21+
<head>
22+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
23+
<style>
24+
* {
25+
margin: 0;
26+
padding: 0;
27+
box-sizing: border-box;
28+
}
29+
body {
30+
background-color: ${backgroundColor};
31+
overflow: hidden;
32+
position: fixed;
33+
width: 100%;
34+
height: 100%;
35+
}
36+
video {
37+
width: 100%;
38+
height: 100%;
39+
object-fit: cover;
40+
display: block;
41+
position: absolute;
42+
top: 0;
43+
left: 0;
44+
}
45+
</style>
46+
</head>
47+
<body>
48+
<video
49+
autoplay
50+
loop
51+
muted
52+
playsinline
53+
preload="auto"
54+
webkit-playsinline
55+
>
56+
<source src="${videoUrl}" type="video/mp4">
57+
</video>
58+
</body>
59+
</html>
60+
`,
61+
[videoUrl, backgroundColor]
62+
)
63+
64+
return (
65+
<Flex width={width} height={height} backgroundColor="mono30" testID="ArticleHeroVideo">
66+
<WebView
67+
source={{ html }}
68+
style={{ flex: 1, backgroundColor }}
69+
allowsInlineMediaPlayback
70+
mediaPlaybackRequiresUserAction={false}
71+
scrollEnabled={false}
72+
bounces={false}
73+
// Prevent zooming
74+
scalesPageToFit={Platform.OS === "android"}
75+
// Performance optimizations
76+
androidLayerType="hardware"
77+
androidHardwareAccelerationDisabled={false}
78+
cacheEnabled={true}
79+
cacheMode="LOAD_DEFAULT"
80+
// Faster initial load
81+
startInLoadingState={false}
82+
// Prevent unnecessary re-renders
83+
setSupportMultipleWindows={false}
84+
// Allow mixed content for better Android network performance
85+
mixedContentMode="always"
86+
/>
87+
</Flex>
88+
)
89+
}

src/app/Scenes/Article/Components/__tests__/ArticleBody.tests.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ describe("ArticleBody", () => {
1919
})
2020

2121
it("renders", () => {
22-
renderWithRelay()
22+
renderWithRelay({
23+
Article: () => ({
24+
hero: {
25+
media: null,
26+
},
27+
}),
28+
})
2329

2430
expect(screen.UNSAFE_getByType(ArticleHero)).toBeTruthy()
2531
expect(screen.UNSAFE_getByType(ArticleSection)).toBeTruthy()

src/app/Scenes/Article/Components/__tests__/ArticleHero.tests.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe("ArticleHero", () => {
2828
renderWithRelay({
2929
Article: () => ({
3030
hero: {
31+
media: null,
3132
image: {
3233
url: "https://example.com/image.jpg",
3334
aspectRatio: 1.5,
@@ -50,6 +51,7 @@ describe("ArticleHero", () => {
5051
renderWithRelay({
5152
Article: () => ({
5253
hero: {
54+
media: null,
5355
image: {
5456
url: "https://example.com/image.jpg",
5557
aspectRatio: 1.5,
@@ -62,6 +64,27 @@ describe("ArticleHero", () => {
6264
expect(screen.UNSAFE_getByType(Image)).toBeTruthy()
6365
})
6466

67+
it("renders hero video if available", () => {
68+
renderWithRelay({
69+
Article: () => ({
70+
hero: {
71+
media: "https://example.com/video.mp4",
72+
image: {
73+
url: "https://example.com/image.jpg",
74+
aspectRatio: 1.5,
75+
},
76+
},
77+
vertical: "Vertical",
78+
title: "Title",
79+
byline: "Byline",
80+
publishedAt: "2023-06-15T00:00:00.000Z",
81+
}),
82+
})
83+
84+
expect(screen.UNSAFE_queryByType(Image)).toBeNull()
85+
expect(screen.getByTestId("ArticleHeroVideo")).toBeTruthy()
86+
})
87+
6588
it("does not render hero image if not available", () => {
6689
renderWithRelay({
6790
Article: () => ({

src/app/Scenes/Article/__tests__/ArticleScreen.tests.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ describe("ArticleScreen", () => {
1515
Article: () => ({
1616
title: "Article Title",
1717
layout: "STANDARD",
18+
hero: {
19+
media: null,
20+
},
1821
}),
1922
})
2023

@@ -32,6 +35,9 @@ describe("ArticleScreen", () => {
3235
Article: () => ({
3336
title: "Article Title",
3437
layout: "FEATURE",
38+
hero: {
39+
media: null,
40+
},
3541
}),
3642
})
3743

src/setupJest.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,23 @@ jest.mock("react-native-webview", () => {
167167
const React = require("react")
168168
const { View } = require("react-native")
169169

170+
const MockWebView = React.forwardRef((props: any, ref: any) => {
171+
return <View ref={ref} {...props} />
172+
})
173+
170174
return {
171175
__esModule: true,
172-
default: React.forwardRef((props: any, ref: any) => {
173-
return <View ref={ref} {...props} />
174-
}),
176+
default: MockWebView,
177+
WebView: MockWebView,
175178
}
176179
})
177180

181+
jest.mock("react-native-linear-gradient", () => {
182+
const React = require("react")
183+
const { View } = require("react-native")
184+
return React.forwardRef((props: any, ref: any) => <View {...props} ref={ref} />)
185+
})
186+
178187
jest.mock("react-native-share", () => ({
179188
open: jest.fn(),
180189
}))

0 commit comments

Comments
 (0)