mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 05:25:42 -04:00
fix(native): give home-screen widgets a container background and full-bleed artwork
iOS 17+ rendered 'Please adopt containerBackground API' because expo-widgets 56 dropped its hard-coded container background and neither widget applied the modifier. Artwork also drew at its raw pixel size because the images were never resizable, and the layout assumed a fixed 155pt box with default content margins. Apply containerBackground, make the artwork resizable, aspect-filled and clipped, replace the flat overlay with a gradient scrim, left-align the text block, use a native linear ProgressView, and disable content margins via the plugin's ios config. Also correct the Continue Watching description, since the feed only contains TV episodes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
3 files changed
+119
-61
No files matched your search
@@ -152,14 +152,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ContinueWatching",
|
"name": "ContinueWatching",
|
||||||
"displayName": "Continue Watching",
|
"displayName": "Continue Watching",
|
||||||
"description": "Your in-progress TV shows and movies",
|
"description": "The next episode of the shows you're watching",
|
||||||
"supportedFamilies": ["systemSmall"]
|
"ios": {
|
||||||
|
"supportedFamilies": ["systemSmall"],
|
||||||
|
"contentMarginsDisabled": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Upcoming",
|
"name": "Upcoming",
|
||||||
"displayName": "Upcoming",
|
"displayName": "Upcoming",
|
||||||
"description": "Upcoming episodes and movie releases",
|
"description": "Upcoming episodes and movie releases",
|
||||||
"supportedFamilies": ["systemSmall"]
|
"ios": {
|
||||||
|
"supportedFamilies": ["systemSmall"],
|
||||||
|
"contentMarginsDisabled": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
|
import { HStack, Image, ProgressView, Rectangle, Text, VStack, ZStack } from "@expo/ui/swift-ui";
|
||||||
import {
|
import {
|
||||||
background,
|
aspectRatio,
|
||||||
|
clipped,
|
||||||
|
containerBackground,
|
||||||
cornerRadius,
|
cornerRadius,
|
||||||
font,
|
font,
|
||||||
foregroundStyle,
|
foregroundStyle,
|
||||||
frame,
|
frame,
|
||||||
lineLimit,
|
lineLimit,
|
||||||
padding,
|
padding,
|
||||||
|
progressViewStyle,
|
||||||
|
resizable,
|
||||||
|
tint,
|
||||||
|
widgetAccentedRenderingMode,
|
||||||
widgetURL,
|
widgetURL,
|
||||||
} from "@expo/ui/swift-ui/modifiers";
|
} from "@expo/ui/swift-ui/modifiers";
|
||||||
import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
||||||
@@ -26,19 +32,17 @@ export type ContinueWatchingProps = {
|
|||||||
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
|
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
|
||||||
"widget";
|
"widget";
|
||||||
|
|
||||||
|
// This function is serialized and run inside the widget extension's own JS runtime, so
|
||||||
|
// everything it uses must be defined in here.
|
||||||
|
const fill = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity };
|
||||||
|
|
||||||
if (!props.titleName) {
|
if (!props.titleName) {
|
||||||
return (
|
return (
|
||||||
<VStack
|
<VStack spacing={8} modifiers={[frame(fill), containerBackground("#101010", "widget")]}>
|
||||||
modifiers={[
|
|
||||||
frame({ width: 155, height: 155 }),
|
|
||||||
background("#101010"),
|
|
||||||
padding({ all: 16 }),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{props.iconFilePath ? (
|
{props.iconFilePath ? (
|
||||||
<Image
|
<Image
|
||||||
uiImage={props.iconFilePath}
|
uiImage={props.iconFilePath}
|
||||||
modifiers={[frame({ width: 40, height: 40 }), cornerRadius(8)]}
|
modifiers={[resizable(), frame({ width: 40, height: 40 }), cornerRadius(8)]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image systemName="play.tv" color="#FFFFFF" size={40} />
|
<Image systemName="play.tv" color="#FFFFFF" size={40} />
|
||||||
@@ -55,7 +59,10 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = props.totalEpisodes > 0 ? props.watchedEpisodes / props.totalEpisodes : 0;
|
const progress = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, props.totalEpisodes > 0 ? props.watchedEpisodes / props.totalEpisodes : 0),
|
||||||
|
);
|
||||||
const episodeLabel =
|
const episodeLabel =
|
||||||
!props.isMovie && props.seasonNumber != null
|
!props.isMovie && props.seasonNumber != null
|
||||||
? `S${props.seasonNumber} · E${props.episodeNumber}`
|
? `S${props.seasonNumber} · E${props.episodeNumber}`
|
||||||
@@ -68,23 +75,43 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
|
|||||||
return (
|
return (
|
||||||
<ZStack
|
<ZStack
|
||||||
alignment="bottomLeading"
|
alignment="bottomLeading"
|
||||||
modifiers={[frame({ width: 155, height: 155 }), widgetURL(`sofa://title/${props.titleId}`)]}
|
modifiers={[
|
||||||
|
frame(fill),
|
||||||
|
containerBackground("#101010", "widget"),
|
||||||
|
widgetURL(`sofa://title/${props.titleId}`),
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{/* Background image or dark fallback */}
|
{/* Full-bleed artwork; the container background shows through when there is none */}
|
||||||
{props.imageFilePath ? (
|
{props.imageFilePath ? (
|
||||||
<Image uiImage={props.imageFilePath} modifiers={[frame({ width: 155, height: 155 })]} />
|
<Image
|
||||||
) : (
|
uiImage={props.imageFilePath}
|
||||||
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("#1a1a1a")]} />
|
modifiers={[
|
||||||
)}
|
resizable(),
|
||||||
|
widgetAccentedRenderingMode("desaturated"),
|
||||||
|
aspectRatio({ contentMode: "fill" }),
|
||||||
|
frame(fill),
|
||||||
|
clipped(),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Dark overlay for text readability */}
|
{/* Gradient scrim for text readability */}
|
||||||
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("rgba(0,0,0,0.45)")]} />
|
<Rectangle
|
||||||
|
modifiers={[
|
||||||
|
foregroundStyle({
|
||||||
|
type: "linearGradient",
|
||||||
|
colors: ["rgba(0,0,0,0)", "rgba(0,0,0,0.85)"],
|
||||||
|
startPoint: { x: 0.5, y: 0.2 },
|
||||||
|
endPoint: { x: 0.5, y: 1 },
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Text overlay at bottom */}
|
{/* Text overlay at bottom */}
|
||||||
<VStack
|
<VStack
|
||||||
alignment="leading"
|
alignment="leading"
|
||||||
spacing={2}
|
spacing={2}
|
||||||
modifiers={[padding({ all: 12 }), frame({ width: 155 })]}
|
modifiers={[frame({ maxWidth: Infinity, alignment: "leading" }), padding({ all: 14 })]}
|
||||||
>
|
>
|
||||||
{episodeLabel && (
|
{episodeLabel && (
|
||||||
<HStack spacing={4}>
|
<HStack spacing={4}>
|
||||||
@@ -92,12 +119,19 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
|
|||||||
modifiers={[
|
modifiers={[
|
||||||
font({ weight: "medium", size: 11 }),
|
font({ weight: "medium", size: 11 }),
|
||||||
foregroundStyle("rgba(255,255,255,0.7)"),
|
foregroundStyle("rgba(255,255,255,0.7)"),
|
||||||
|
lineLimit(1),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{episodeLabel}
|
{episodeLabel}
|
||||||
</Text>
|
</Text>
|
||||||
{progressLabel && (
|
{progressLabel && (
|
||||||
<Text modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)")]}>
|
<Text
|
||||||
|
modifiers={[
|
||||||
|
font({ size: 11 }),
|
||||||
|
foregroundStyle("rgba(255,255,255,0.5)"),
|
||||||
|
lineLimit(1),
|
||||||
|
]}
|
||||||
|
>
|
||||||
{progressLabel}
|
{progressLabel}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -110,22 +144,10 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
|
|||||||
</Text>
|
</Text>
|
||||||
{/* Progress bar for TV shows */}
|
{/* Progress bar for TV shows */}
|
||||||
{!props.isMovie && props.totalEpisodes > 0 && (
|
{!props.isMovie && props.totalEpisodes > 0 && (
|
||||||
<ZStack modifiers={[frame({ width: 131, height: 3 }), padding({ top: 2 })]}>
|
<ProgressView
|
||||||
<Spacer
|
value={progress}
|
||||||
modifiers={[
|
modifiers={[progressViewStyle("linear"), tint("#3b82f6"), padding({ top: 4 })]}
|
||||||
frame({ width: 131, height: 3 }),
|
/>
|
||||||
cornerRadius(1.5),
|
|
||||||
background("rgba(255,255,255,0.15)"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Spacer
|
|
||||||
modifiers={[
|
|
||||||
frame({ width: Math.max(3, Math.round(131 * progress)), height: 3 }),
|
|
||||||
cornerRadius(1.5),
|
|
||||||
background("#3b82f6"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</ZStack>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</ZStack>
|
</ZStack>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
|
import { HStack, Image, Rectangle, Text, VStack, ZStack } from "@expo/ui/swift-ui";
|
||||||
import {
|
import {
|
||||||
background,
|
aspectRatio,
|
||||||
|
clipped,
|
||||||
|
containerBackground,
|
||||||
cornerRadius,
|
cornerRadius,
|
||||||
font,
|
font,
|
||||||
foregroundStyle,
|
foregroundStyle,
|
||||||
frame,
|
frame,
|
||||||
lineLimit,
|
lineLimit,
|
||||||
padding,
|
padding,
|
||||||
|
resizable,
|
||||||
|
widgetAccentedRenderingMode,
|
||||||
widgetURL,
|
widgetURL,
|
||||||
} from "@expo/ui/swift-ui/modifiers";
|
} from "@expo/ui/swift-ui/modifiers";
|
||||||
import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
||||||
@@ -27,19 +31,17 @@ export type UpcomingProps = {
|
|||||||
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
|
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
|
||||||
"widget";
|
"widget";
|
||||||
|
|
||||||
|
// This function is serialized and run inside the widget extension's own JS runtime, so
|
||||||
|
// everything it uses must be defined in here.
|
||||||
|
const fill = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity };
|
||||||
|
|
||||||
if (!props.titleName) {
|
if (!props.titleName) {
|
||||||
return (
|
return (
|
||||||
<VStack
|
<VStack spacing={8} modifiers={[frame(fill), containerBackground("#101010", "widget")]}>
|
||||||
modifiers={[
|
|
||||||
frame({ width: 155, height: 155 }),
|
|
||||||
background("#101010"),
|
|
||||||
padding({ all: 16 }),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{props.iconFilePath ? (
|
{props.iconFilePath ? (
|
||||||
<Image
|
<Image
|
||||||
uiImage={props.iconFilePath}
|
uiImage={props.iconFilePath}
|
||||||
modifiers={[frame({ width: 40, height: 40 }), cornerRadius(8)]}
|
modifiers={[resizable(), frame({ width: 40, height: 40 }), cornerRadius(8)]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image systemName="play.tv" color="#FFFFFF" size={40} />
|
<Image systemName="play.tv" color="#FFFFFF" size={40} />
|
||||||
@@ -59,29 +61,57 @@ const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
|
|||||||
return (
|
return (
|
||||||
<ZStack
|
<ZStack
|
||||||
alignment="bottomLeading"
|
alignment="bottomLeading"
|
||||||
modifiers={[frame({ width: 155, height: 155 }), widgetURL(`sofa://title/${props.titleId}`)]}
|
modifiers={[
|
||||||
|
frame(fill),
|
||||||
|
containerBackground("#101010", "widget"),
|
||||||
|
widgetURL(`sofa://title/${props.titleId}`),
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{/* Background image or dark fallback */}
|
{/* Full-bleed artwork; the container background shows through when there is none */}
|
||||||
{props.imageFilePath ? (
|
{props.imageFilePath ? (
|
||||||
<Image uiImage={props.imageFilePath} modifiers={[frame({ width: 155, height: 155 })]} />
|
<Image
|
||||||
) : (
|
uiImage={props.imageFilePath}
|
||||||
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("#1a1a1a")]} />
|
modifiers={[
|
||||||
)}
|
resizable(),
|
||||||
|
widgetAccentedRenderingMode("desaturated"),
|
||||||
|
aspectRatio({ contentMode: "fill" }),
|
||||||
|
frame(fill),
|
||||||
|
clipped(),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Dark overlay for text readability */}
|
{/* Gradient scrim for text readability */}
|
||||||
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("rgba(0,0,0,0.45)")]} />
|
<Rectangle
|
||||||
|
modifiers={[
|
||||||
|
foregroundStyle({
|
||||||
|
type: "linearGradient",
|
||||||
|
colors: ["rgba(0,0,0,0)", "rgba(0,0,0,0.85)"],
|
||||||
|
startPoint: { x: 0.5, y: 0.2 },
|
||||||
|
endPoint: { x: 0.5, y: 1 },
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Text overlay at bottom */}
|
{/* Text overlay at bottom */}
|
||||||
<VStack
|
<VStack
|
||||||
alignment="leading"
|
alignment="leading"
|
||||||
spacing={2}
|
spacing={2}
|
||||||
modifiers={[padding({ all: 12 }), frame({ width: 155 })]}
|
modifiers={[frame({ maxWidth: Infinity, alignment: "leading" }), padding({ all: 14 })]}
|
||||||
>
|
>
|
||||||
<HStack spacing={4}>
|
<HStack spacing={4}>
|
||||||
<Text modifiers={[font({ weight: "medium", size: 11 }), foregroundStyle("#e4a532")]}>
|
<Text
|
||||||
|
modifiers={[
|
||||||
|
font({ weight: "medium", size: 11 }),
|
||||||
|
foregroundStyle("#e4a532"),
|
||||||
|
lineLimit(1),
|
||||||
|
]}
|
||||||
|
>
|
||||||
{props.dateLabel}
|
{props.dateLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)")]}>
|
<Text
|
||||||
|
modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)"), lineLimit(1)]}
|
||||||
|
>
|
||||||
· {props.episodeLabel}
|
· {props.episodeLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
Reference in new issue
Block a user