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)
@@ -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": {
|
||||
|
||||
|
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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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:",
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 33 KiB |