mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -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");
|
||||
Reference in New Issue
Block a user