feat(native): add iOS home screen widgets for continue watching and upcoming

- Add `ContinueWatching` (systemSmall) and `Upcoming` (systemSmall) SwiftUI widgets via `expo-widgets`, registered in `app.json` under the `group.com.jakejarvis.sofa` app group
- Add `widget-images` local Expo module with a Swift `WidgetImagesModule` that downloads and resizes remote poster images into the shared app group container for use by widgets; also copies bundled assets (sofa icon fallback)
- Add `src/lib/widgets.ts` to fetch dashboard data, cache poster images, and write a JSON timeline to the shared container via `expo-widgets`
- Add `ContinueWatching` and `Upcoming` SwiftUI widget views in `src/widgets/`
- Add `useWidgetRefresh` hook that refreshes widgets on foreground (throttled to once per minute) and on session ready
- Debounce widget refresh in `invalidateTitleQueries` so rapid mutations (e.g. marking multiple episodes watched) batch into a single refresh
- Expose `titles.upcoming` from the dashboard procedure and add `upcomingTitles` field to the dashboard schema
- Bump iOS deployment target to 16.2 (required by WidgetKit timeline APIs)
This commit is contained in:
2026-03-21 21:03:58 -04:00
parent 61013ee41f
commit fd4a8119ce
26 changed files with 784 additions and 56 deletions
+29
View File
@@ -23,6 +23,14 @@
"package": "com.jakejarvis.sofa"
},
"plugins": [
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "16.2"
}
}
],
"expo-router",
"expo-secure-store",
[
@@ -105,6 +113,27 @@
{
"userTrackingPermission": "This identifier will be used to measure app performance and improve your experience."
}
],
[
"expo-widgets",
{
"groupIdentifier": "group.com.jakejarvis.sofa",
"bundleIdentifier": "com.jakejarvis.sofa.ExpoWidgetsTarget",
"widgets": [
{
"name": "ContinueWatching",
"displayName": "Continue Watching",
"description": "Your in-progress TV shows and movies",
"supportedFamilies": ["systemSmall"]
},
{
"name": "Upcoming",
"displayName": "Upcoming",
"description": "Upcoming episodes and movie releases",
"supportedFamilies": ["systemSmall"]
}
]
}
]
],
"experiments": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["WidgetImagesModule"]
}
}
@@ -0,0 +1,21 @@
import { Platform } from "react-native";
function getModule() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("./src/WidgetImagesModule").default;
}
export async function downloadWidgetImage(url: string, key: string): Promise<string | null> {
if (Platform.OS !== "ios") return null;
return getModule().downloadWidgetImage(url, key);
}
export async function copyBundledAsset(assetName: string, key: string): Promise<string | null> {
if (Platform.OS !== "ios") return null;
return getModule().copyBundledAsset(assetName, key);
}
export async function clearWidgetImages(): Promise<void> {
if (Platform.OS !== "ios") return;
return getModule().clearWidgetImages();
}
@@ -0,0 +1,113 @@
import ExpoModulesCore
import UIKit
private let groupIdentifier = "group.com.jakejarvis.sofa"
private let imageDirectory = "widget_images"
public class WidgetImagesModule: Module {
public func definition() -> ModuleDefinition {
Name("WidgetImages")
AsyncFunction("downloadWidgetImage") { (url: String, key: String) -> String? in
guard let imageUrl = URL(string: url) else {
return nil
}
guard let directoryUrl = self.ensureImageDirectory() else {
return nil
}
let destinationUrl = directoryUrl.appendingPathComponent(key)
let (data, response) = try await URLSession.shared.data(from: imageUrl)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
return nil
}
guard let image = UIImage(data: data) else {
return nil
}
// Resize to fit widget dimensions (systemSmall ~155pt = ~465px at 3x)
let maxDimension: CGFloat = 465
let resized = resizeImage(image, maxDimension: maxDimension)
guard let jpegData = resized.jpegData(compressionQuality: 0.7) else {
return nil
}
try jpegData.write(to: destinationUrl, options: .atomic)
return destinationUrl.absoluteString
}
AsyncFunction("copyBundledAsset") { (assetName: String, key: String) -> String? in
guard let directoryUrl = self.ensureImageDirectory() else {
return nil
}
let destinationUrl = directoryUrl.appendingPathComponent(key)
// Skip if already copied
if FileManager.default.fileExists(atPath: destinationUrl.path) {
return destinationUrl.absoluteString
}
guard let image = UIImage(named: assetName) else {
return nil
}
guard let pngData = image.pngData() else {
return nil
}
try pngData.write(to: destinationUrl, options: .atomic)
return destinationUrl.absoluteString
}
AsyncFunction("clearWidgetImages") {
guard let containerUrl = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupIdentifier
) else {
return
}
let directoryUrl = containerUrl.appendingPathComponent(imageDirectory)
try? FileManager.default.removeItem(at: directoryUrl)
}
}
private func ensureImageDirectory() -> URL? {
guard let containerUrl = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupIdentifier
) else {
return nil
}
let directoryUrl = containerUrl.appendingPathComponent(imageDirectory)
try? FileManager.default.createDirectory(
at: directoryUrl,
withIntermediateDirectories: true
)
return directoryUrl
}
private func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0)
if scale >= 1.0 { return image }
let newSize = CGSize(
width: round(size.width * scale),
height: round(size.height * scale)
)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}
@@ -0,0 +1,3 @@
import { requireNativeModule } from "expo-modules-core";
export default requireNativeModule("WidgetImages");
+4 -2
View File
@@ -36,14 +36,15 @@
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.27",
"@tanstack/query-async-storage-persister": "5.94.5",
"@tanstack/react-form": "1.28.5",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.27",
"@tanstack/react-query-persist-client": "5.94.5",
"better-auth": "catalog:",
"burnt": "0.13.0",
"expo": "55.0.8",
"expo-application": "55.0.10",
"expo-build-properties": "55.0.10",
"expo-clipboard": "55.0.9",
"expo-constants": "55.0.9",
"expo-dev-client": "55.0.18",
@@ -64,6 +65,7 @@
"expo-system-ui": "55.0.10",
"expo-tracking-transparency": "55.0.9",
"expo-web-browser": "55.0.10",
"expo-widgets": "55.0.7",
"posthog-react-native": "4.37.5",
"react": "19.2.0",
"react-dom": "19.2.0",
+4
View File
@@ -26,6 +26,7 @@ import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { useServerConnection } from "@/hooks/use-server-connection";
import { useWidgetRefresh } from "@/hooks/use-widget-refresh";
import { initLocale } from "@/lib/i18n";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
@@ -119,6 +120,9 @@ function AppContent() {
}
}, [isPending, hasServerUrl, isLocaleReady]);
// Refresh iOS home screen widgets on foreground and when session becomes ready
useWidgetRefresh(!!session);
return (
<ThemeProvider value={sofaTheme}>
<StatusBar style="light" />
@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
import { AppState, Platform } from "react-native";
import { refreshWidgets } from "@/lib/widgets";
const THROTTLE_MS = 60_000; // Don't refresh more than once per minute on foreground
export function useWidgetRefresh(isReady: boolean) {
const lastRefresh = useRef(0);
// Refresh when auth/server becomes ready (handles cold launch and login)
useEffect(() => {
if (Platform.OS !== "ios" || !isReady) return;
void refreshWidgets();
lastRefresh.current = Date.now();
}, [isReady]);
// Refresh on app foreground
useEffect(() => {
if (Platform.OS !== "ios") return;
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
const now = Date.now();
if (now - lastRefresh.current > THROTTLE_MS) {
void refreshWidgets();
lastRefresh.current = now;
}
}
});
return () => subscription.remove();
}, []);
}
+10
View File
@@ -3,12 +3,22 @@ import { msg, plural } from "@lingui/core/macro";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import { refreshWidgets } from "@/lib/widgets";
import { i18n } from "@sofa/i18n";
let widgetRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** Invalidate title + dashboard queries. Used by most title mutations. */
export function invalidateTitleQueries() {
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
// Debounce widget refresh to batch rapid mutations (e.g. watching multiple episodes)
if (widgetRefreshTimer) clearTimeout(widgetRefreshTimer);
widgetRefreshTimer = setTimeout(() => {
void refreshWidgets();
widgetRefreshTimer = null;
}, 2000);
}
/**
+176
View File
@@ -0,0 +1,176 @@
import { Platform } from "react-native";
import { client } from "@/lib/orpc";
import { resolveUrl } from "@/lib/server";
import type { ContinueWatchingProps } from "@/widgets/continue-watching";
import type { UpcomingProps } from "@/widgets/upcoming";
import { copyBundledAsset, downloadWidgetImage } from "../../modules/widget-images";
/** Resolve a potentially-relative image path to an absolute URL and download it. */
async function downloadImage(path: string | null, key: string): Promise<string | null> {
const url = resolveUrl(path);
if (!url) return null;
return downloadWidgetImage(url, key);
}
const TIMELINE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes (iOS minimum)
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function formatShortDate(dateStr: string): string {
const parts = dateStr.split("-");
const monthIdx = parseInt(parts[1]!, 10) - 1;
const day = parseInt(parts[2]!, 10);
return `${MONTHS[monthIdx]} ${day}`;
}
function formatEpisodeLabel(item: {
titleType: "movie" | "tv";
episodeCount: number;
seasonNumber?: number | null;
episodeNumber?: number | null;
}): string {
if (item.titleType !== "tv") return "Movie";
if (item.episodeCount > 1 && item.seasonNumber != null)
return `S${item.seasonNumber} · ${item.episodeCount} eps`;
if (item.seasonNumber != null) return `S${item.seasonNumber} · E${item.episodeNumber}`;
return "TV";
}
let iconFilePath: string | null = null;
async function ensureIcon(): Promise<string | null> {
if (!iconFilePath) {
iconFilePath = await copyBundledAsset("sofa-icon", "sofa_icon");
}
return iconFilePath;
}
export async function refreshWidgets(): Promise<void> {
if (Platform.OS !== "ios") return;
// Lazy import to avoid loading widget modules on Android
const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([
import("@/widgets/continue-watching"),
import("@/widgets/upcoming"),
]);
await ensureIcon();
await Promise.all([
refreshContinueWatching(ContinueWatchingWidget),
refreshUpcoming(UpcomingWidget),
]);
}
async function refreshContinueWatching(
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
): Promise<void> {
try {
const { items } = await client.dashboard.continueWatching();
if (items.length === 0) {
widget.updateSnapshot({
isEmpty: true,
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: null,
watchedEpisodes: 0,
totalEpisodes: 0,
isMovie: false,
});
return;
}
const top5 = items.slice(0, 5);
const entries = await Promise.all(
top5.map(async (item, index) => {
const imageKey = `cw_${index}`;
const imagePath = item.nextEpisode?.stillPath ?? item.title.backdropPath;
const imageFilePath = await downloadImage(imagePath, imageKey);
const props: ContinueWatchingProps = {
titleId: item.title.id,
titleName: item.title.title,
imageFilePath,
iconFilePath: null,
seasonNumber: item.nextEpisode?.seasonNumber,
episodeNumber: item.nextEpisode?.episodeNumber,
watchedEpisodes: item.watchedEpisodes,
totalEpisodes: item.totalEpisodes,
isMovie: !item.nextEpisode,
isEmpty: false,
};
return {
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
props,
};
}),
);
widget.updateTimeline(entries);
} catch (error) {
console.warn("[Widgets] Failed to refresh Continue Watching:", error);
}
}
async function refreshUpcoming(
widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
): Promise<void> {
try {
const { items } = await client.dashboard.upcoming({
days: 30,
limit: 5,
});
if (items.length === 0) {
widget.updateSnapshot({
isEmpty: true,
iconFilePath,
titleId: "",
titleName: "",
imageFilePath: null,
titleType: "tv",
episodeCount: 0,
dateLabel: "",
episodeLabel: "",
});
return;
}
const top5 = items.slice(0, 5);
const entries = await Promise.all(
top5.map(async (item, index) => {
const imageKey = `up_${index}`;
const imageFilePath = await downloadImage(item.backdropPath, imageKey);
const props: UpcomingProps = {
titleId: item.titleId,
titleName: item.titleName,
imageFilePath,
iconFilePath: null,
titleType: item.titleType,
seasonNumber: item.seasonNumber,
episodeNumber: item.episodeNumber,
episodeCount: item.episodeCount,
dateLabel: formatShortDate(item.date),
episodeLabel: formatEpisodeLabel(item),
isEmpty: false,
};
return {
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
props,
};
}),
);
widget.updateTimeline(entries);
} catch (error) {
console.warn("[Widgets] Failed to refresh Upcoming:", error);
}
}
@@ -0,0 +1,136 @@
import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
import {
background,
cornerRadius,
font,
foregroundStyle,
frame,
lineLimit,
padding,
widgetURL,
} from "@expo/ui/swift-ui/modifiers";
import { createWidget, type WidgetEnvironment } from "expo-widgets";
export type ContinueWatchingProps = {
titleId: string;
titleName: string;
imageFilePath: string | null;
iconFilePath: string | null;
seasonNumber?: number;
episodeNumber?: number;
watchedEpisodes: number;
totalEpisodes: number;
isMovie: boolean;
isEmpty: boolean;
};
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
"widget";
if (props.isEmpty) {
return (
<VStack
modifiers={[
frame({ width: 155, height: 155 }),
background("#101010"),
padding({ all: 16 }),
]}
>
{props.iconFilePath ? (
<Image
uiImage={props.iconFilePath}
modifiers={[frame({ width: 40, height: 40 }), cornerRadius(8)]}
/>
) : (
<Image systemName="play.tv" color="#FFFFFF" size={40} />
)}
<Text
modifiers={[
font({ weight: "medium", size: 13 }),
foregroundStyle("rgba(255,255,255,0.7)"),
]}
>
Nothing to watch
</Text>
</VStack>
);
}
const progress = props.totalEpisodes > 0 ? props.watchedEpisodes / props.totalEpisodes : 0;
const episodeLabel =
!props.isMovie && props.seasonNumber != null
? `S${props.seasonNumber} · E${props.episodeNumber}`
: null;
const progressLabel =
!props.isMovie && props.totalEpisodes > 0
? `${props.watchedEpisodes} of ${props.totalEpisodes}`
: null;
return (
<ZStack
alignment="bottomLeading"
modifiers={[frame({ width: 155, height: 155 }), widgetURL(`sofa://title/${props.titleId}`)]}
>
{/* Background image or dark fallback */}
{props.imageFilePath ? (
<Image uiImage={props.imageFilePath} modifiers={[frame({ width: 155, height: 155 })]} />
) : (
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("#1a1a1a")]} />
)}
{/* Dark overlay for text readability */}
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("rgba(0,0,0,0.45)")]} />
{/* Text overlay at bottom */}
<VStack
alignment="leading"
spacing={2}
modifiers={[padding({ all: 12 }), frame({ width: 155 })]}
>
{episodeLabel && (
<HStack spacing={4}>
<Text
modifiers={[
font({ weight: "medium", size: 11 }),
foregroundStyle("rgba(255,255,255,0.7)"),
]}
>
{episodeLabel}
</Text>
{progressLabel && (
<Text modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)")]}>
{progressLabel}
</Text>
)}
</HStack>
)}
<Text
modifiers={[font({ weight: "bold", size: 15 }), foregroundStyle("#FFFFFF"), lineLimit(2)]}
>
{props.titleName}
</Text>
{/* Progress bar for TV shows */}
{!props.isMovie && props.totalEpisodes > 0 && (
<ZStack modifiers={[frame({ width: 131, height: 3 }), padding({ top: 2 })]}>
<Spacer
modifiers={[
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>
</ZStack>
);
};
export default createWidget("ContinueWatching", ContinueWatchingWidget);
+99
View File
@@ -0,0 +1,99 @@
import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
import {
background,
cornerRadius,
font,
foregroundStyle,
frame,
lineLimit,
padding,
widgetURL,
} from "@expo/ui/swift-ui/modifiers";
import { createWidget, type WidgetEnvironment } from "expo-widgets";
export type UpcomingProps = {
titleId: string;
titleName: string;
imageFilePath: string | null;
iconFilePath: string | null;
titleType: "movie" | "tv";
seasonNumber?: number | null;
episodeNumber?: number | null;
episodeCount: number;
dateLabel: string;
episodeLabel: string;
isEmpty: boolean;
};
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
"widget";
if (props.isEmpty) {
return (
<VStack
modifiers={[
frame({ width: 155, height: 155 }),
background("#101010"),
padding({ all: 16 }),
]}
>
{props.iconFilePath ? (
<Image
uiImage={props.iconFilePath}
modifiers={[frame({ width: 40, height: 40 }), cornerRadius(8)]}
/>
) : (
<Image systemName="play.tv" color="#FFFFFF" size={40} />
)}
<Text
modifiers={[
font({ weight: "medium", size: 13 }),
foregroundStyle("rgba(255,255,255,0.7)"),
]}
>
Nothing upcoming
</Text>
</VStack>
);
}
return (
<ZStack
alignment="bottomLeading"
modifiers={[frame({ width: 155, height: 155 }), widgetURL(`sofa://title/${props.titleId}`)]}
>
{/* Background image or dark fallback */}
{props.imageFilePath ? (
<Image uiImage={props.imageFilePath} modifiers={[frame({ width: 155, height: 155 })]} />
) : (
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("#1a1a1a")]} />
)}
{/* Dark overlay for text readability */}
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("rgba(0,0,0,0.45)")]} />
{/* Text overlay at bottom */}
<VStack
alignment="leading"
spacing={2}
modifiers={[padding({ all: 12 }), frame({ width: 155 })]}
>
<HStack spacing={4}>
<Text modifiers={[font({ weight: "medium", size: 11 }), foregroundStyle("#e4a532")]}>
{props.dateLabel}
</Text>
<Text modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)")]}>
· {props.episodeLabel}
</Text>
</HStack>
<Text
modifiers={[font({ weight: "bold", size: 15 }), foregroundStyle("#FFFFFF"), lineLimit(2)]}
>
{props.titleName}
</Text>
</VStack>
</ZStack>
);
};
export default createWidget("Upcoming", UpcomingWidget);
@@ -98,6 +98,7 @@ export const upcoming = os.dashboard.upcoming.use(authed).handler(({ input, cont
items: result.items.map((item) => ({
...item,
posterPath: tmdbImageUrl(item.posterPath, "posters"),
backdropPath: tmdbImageUrl(item.backdropPath, "backdrops"),
streamingProvider: item.streamingProvider
? {
...item.streamingProvider,
+2 -2
View File
@@ -50,9 +50,9 @@
"@tailwindcss/vite": "4.2.2",
"@tanstack/devtools-vite": "0.6.0",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-query-devtools": "5.94.5",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/router-plugin": "1.167.1",
"@tanstack/router-plugin": "1.167.2",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
Executable → Regular
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 33 KiB