From a67d84b7d7819fe59d5291397bc7642daa2bbdbe Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:10:32 +0800 Subject: [PATCH] feat(mobile): cache image info (#2899) --- .../0011_mysterious_stark_industries.sql | 5 + apps/mobile/drizzle/meta/0011_snapshot.json | 615 ++++++++++++++++++ apps/mobile/drizzle/meta/_journal.json | 7 + apps/mobile/drizzle/migrations.js | 2 + apps/mobile/src/database/schemas/index.ts | 9 +- apps/mobile/src/database/schemas/types.ts | 6 + .../src/modules/player/PlayerTabBar.tsx | 4 + apps/mobile/src/modules/player/hooks.ts | 29 + .../src/modules/settings/UserHeaderBanner.tsx | 62 +- apps/mobile/src/screens/player.tsx | 42 +- apps/mobile/src/services/image.ts | 30 + apps/mobile/src/services/index.ts | 2 + apps/mobile/src/store/image/getters.ts | 7 + apps/mobile/src/store/image/hooks.ts | 26 + apps/mobile/src/store/image/store.ts | 54 ++ 15 files changed, 825 insertions(+), 75 deletions(-) create mode 100644 apps/mobile/drizzle/0011_mysterious_stark_industries.sql create mode 100644 apps/mobile/drizzle/meta/0011_snapshot.json create mode 100644 apps/mobile/src/modules/player/hooks.ts create mode 100644 apps/mobile/src/services/image.ts create mode 100644 apps/mobile/src/store/image/getters.ts create mode 100644 apps/mobile/src/store/image/hooks.ts create mode 100644 apps/mobile/src/store/image/store.ts diff --git a/apps/mobile/drizzle/0011_mysterious_stark_industries.sql b/apps/mobile/drizzle/0011_mysterious_stark_industries.sql new file mode 100644 index 000000000..d1e4e4e36 --- /dev/null +++ b/apps/mobile/drizzle/0011_mysterious_stark_industries.sql @@ -0,0 +1,5 @@ +CREATE TABLE `images` ( + `url` text PRIMARY KEY NOT NULL, + `colors` text NOT NULL, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP) +); diff --git a/apps/mobile/drizzle/meta/0011_snapshot.json b/apps/mobile/drizzle/meta/0011_snapshot.json new file mode 100644 index 000000000..d4a6fda25 --- /dev/null +++ b/apps/mobile/drizzle/meta/0011_snapshot.json @@ -0,0 +1,615 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6268a018-0d72-4aa1-9e39-6caab324682e", + "prevId": "b19d005b-f43c-474b-99bc-f9aa04dd28dd", + "tables": { + "collections": { + "name": "collections", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_url": { + "name": "author_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_avatar": { + "name": "author_avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media": { + "name": "media", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra": { + "name": "extra", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inbox_handle": { + "name": "inbox_handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feeds": { + "name": "feeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_at": { + "name": "error_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "images": { + "name": "images", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "colors": { + "name": "colors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inboxes": { + "name": "inboxes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_ids": { + "name": "feed_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fee": { + "name": "fee", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry_ids": { + "name": "entry_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "summaries": { + "name": "summaries", + "columns": { + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "unq": { + "name": "unq", + "columns": ["entry_id", "language"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unread": { + "name": "unread", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_me": { + "name": "is_me", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/mobile/drizzle/meta/_journal.json b/apps/mobile/drizzle/meta/_journal.json index 35cb7ef31..965804b15 100644 --- a/apps/mobile/drizzle/meta/_journal.json +++ b/apps/mobile/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1739350709266, "tag": "0010_legal_ben_grimm", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1740623123535, + "tag": "0011_mysterious_stark_industries", + "breakpoints": true } ] } diff --git a/apps/mobile/drizzle/migrations.js b/apps/mobile/drizzle/migrations.js index 85bd1bfca..a73ba499e 100644 --- a/apps/mobile/drizzle/migrations.js +++ b/apps/mobile/drizzle/migrations.js @@ -11,6 +11,7 @@ import m0007 from "./0007_curvy_tarantula.sql" import m0008 from "./0008_last_the_santerians.sql" import m0009 from "./0009_lucky_power_man.sql" import m0010 from "./0010_legal_ben_grimm.sql" +import m0011 from "./0011_mysterious_stark_industries.sql" import journal from "./meta/_journal.json" export default { @@ -27,5 +28,6 @@ export default { m0008, m0009, m0010, + m0011, }, } diff --git a/apps/mobile/src/database/schemas/index.ts b/apps/mobile/src/database/schemas/index.ts index 047e7e390..428f82b85 100644 --- a/apps/mobile/src/database/schemas/index.ts +++ b/apps/mobile/src/database/schemas/index.ts @@ -1,7 +1,8 @@ import type { FeedViewType } from "@follow/constants" +import { sql } from "drizzle-orm" import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" -import type { AttachmentsModel, ExtraModel, MediaModel } from "./types" +import type { AttachmentsModel, ExtraModel, ImageColorsResult, MediaModel } from "./types" export const feedsTable = sqliteTable("feeds", { id: text("id").primaryKey(), @@ -104,3 +105,9 @@ export const summariesTable = sqliteTable( unq: uniqueIndex("unq").on(table.entryId, table.language), }), ) + +export const imagesTable = sqliteTable("images", (t) => ({ + url: t.text("url").notNull().primaryKey(), + colors: t.text("colors", { mode: "json" }).$type().notNull(), + createdAt: t.integer("created_at", { mode: "timestamp" }).default(sql`(CURRENT_TIMESTAMP)`), +})) diff --git a/apps/mobile/src/database/schemas/types.ts b/apps/mobile/src/database/schemas/types.ts index b13eb40eb..cdc231696 100644 --- a/apps/mobile/src/database/schemas/types.ts +++ b/apps/mobile/src/database/schemas/types.ts @@ -2,6 +2,7 @@ import type { collectionsTable, entriesTable, feedsTable, + imagesTable, inboxesTable, listsTable, subscriptionsTable, @@ -27,6 +28,9 @@ export type EntrySchema = typeof entriesTable.$inferSelect export type CollectionSchema = typeof collectionsTable.$inferSelect export type SummarySchema = typeof summariesTable.$inferSelect + +export type ImageSchema = typeof imagesTable.$inferInsert + export type MediaModel = { url: string type: "photo" | "video" @@ -51,3 +55,5 @@ export type ExtraModel = { content_html?: string }[] } + +export { ImageColorsResult } from "react-native-image-colors" diff --git a/apps/mobile/src/modules/player/PlayerTabBar.tsx b/apps/mobile/src/modules/player/PlayerTabBar.tsx index 55f425531..4846b608e 100644 --- a/apps/mobile/src/modules/player/PlayerTabBar.tsx +++ b/apps/mobile/src/modules/player/PlayerTabBar.tsx @@ -5,6 +5,7 @@ import { Pressable, Text, View } from "react-native" import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated" import { useActiveTrack } from "@/src/lib/player" +import { usePrefetchImageColors } from "@/src/store/image/hooks" import { PlayPauseButton, SeekButton } from "./control" @@ -13,6 +14,9 @@ const allowedRoutes = new Set(["/", "/subscriptions", "/player"]) export function PlayerTabBar({ className }: { className?: string }) { const activeTrack = useActiveTrack() const pathname = usePathname() + + usePrefetchImageColors(activeTrack?.artwork) + if (!activeTrack || !allowedRoutes.has(pathname)) { return null } diff --git a/apps/mobile/src/modules/player/hooks.ts b/apps/mobile/src/modules/player/hooks.ts new file mode 100644 index 000000000..77dad8314 --- /dev/null +++ b/apps/mobile/src/modules/player/hooks.ts @@ -0,0 +1,29 @@ +import { getLuminance, shadeColor } from "@follow/utils" +import { useMemo } from "react" + +import { useImageColors } from "@/src/store/image/hooks" + +const defaultBackgroundColor = "#000000" + +export function useCoverGradient(url?: string) { + const imageColors = useImageColors(url) + + const backgroundColor = useMemo(() => { + if (imageColors?.platform === "ios") { + return imageColors.background + } else if (imageColors?.platform === "android") { + return imageColors.average + } + return defaultBackgroundColor + }, [imageColors]) + + const isGradientLight = useMemo(() => { + return getLuminance(backgroundColor) > 0.5 + }, [backgroundColor]) + + const gradientColors = useMemo(() => { + return [backgroundColor, shadeColor(backgroundColor, -50)] as const + }, [backgroundColor]) + + return { isGradientLight, gradientColors } +} diff --git a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx index 30f6c7c62..0b487fc05 100644 --- a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx +++ b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx @@ -1,63 +1,47 @@ import { cn, getLuminance } from "@follow/utils" import { LinearGradient } from "expo-linear-gradient" -import { useEffect, useState } from "react" +import { useMemo } from "react" import { StyleSheet, Text, View } from "react-native" -import ImageColors from "react-native-image-colors" import type { SharedValue } from "react-native-reanimated" import ReAnimated, { FadeIn, FadeOut, interpolate, useAnimatedStyle } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar" +import { useImageColors, usePrefetchImageColors } from "@/src/store/image/hooks" import { useWhoami } from "@/src/store/user/hooks" -import { accentColor } from "@/src/theme/colors" const defaultGradientColors = ["#000", "#100", "#200"] export const UserHeaderBanner = ({ scrollY }: { scrollY: SharedValue }) => { const whoami = useWhoami() + usePrefetchImageColors(whoami?.image) const insets = useSafeAreaInsets() const MAX_PULL = 100 const SCALE_FACTOR = 1.8 - const [gradientColors, setGradientColors] = useState() - const [gradientLight, setGradientLight] = useState(false) - - useEffect(() => { - const extractColors = async () => { - if (!whoami?.image) return - - try { - const result = await ImageColors.getColors(whoami.image, { - fallback: accentColor, - cache: true, - }) - - if (result.platform === "web") return - if (result.platform === "android") { - setGradientColors([ - result.dominant, - result.average || result.vibrant, - result.vibrant || result.dominant, - ]) - - const dominantLuminance = getLuminance(result.dominant) - const isLight = dominantLuminance > 0.5 - setGradientLight(isLight) - } else { - const dominantLuminance = getLuminance(result.primary) - const isLight = dominantLuminance > 0.5 - - setGradientLight(isLight) - setGradientColors([result.primary, result.secondary, result.background]) - } - } catch (error) { - console.warn("Failed to extract colors:", error) - } + const imageColors = useImageColors(whoami?.image) + const gradientColors = useMemo(() => { + if (!imageColors || imageColors.platform === "web") return defaultGradientColors + if (imageColors.platform === "android") { + return [ + imageColors.dominant, + imageColors.average || imageColors.vibrant, + imageColors.vibrant || imageColors.dominant, + ] } + return [imageColors.primary, imageColors.secondary, imageColors.background] + }, [imageColors]) + + const gradientLight = useMemo(() => { + if (!imageColors) return false + if (imageColors.platform === "web") return false + const dominantLuminance = getLuminance( + imageColors.platform === "android" ? imageColors.dominant : imageColors.primary, + ) + return dominantLuminance > 0.5 + }, [imageColors]) - extractColors() - }, [whoami?.image]) const styles = useAnimatedStyle(() => { const scaleValue = interpolate(scrollY.value, [-MAX_PULL, 0], [SCALE_FACTOR, 1], { extrapolateLeft: "extend", diff --git a/apps/mobile/src/screens/player.tsx b/apps/mobile/src/screens/player.tsx index 8cb6f8fa9..854877f7d 100644 --- a/apps/mobile/src/screens/player.tsx +++ b/apps/mobile/src/screens/player.tsx @@ -1,18 +1,17 @@ -import { cn, getLuminance, shadeColor } from "@follow/utils" +import { cn } from "@follow/utils" import { Image } from "expo-image" import { LinearGradient } from "expo-linear-gradient" import { router } from "expo-router" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo } from "react" import { StyleSheet, Text, View } from "react-native" -import ImageColors from "react-native-image-colors" import Reanimated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" import { SheetScreen } from "react-native-sheet-transitions" import { useActiveTrack, useIsPlaying } from "../lib/player" import { PlayerScreenContext } from "../modules/player/context" import { ControlGroup, ProgressBar, VolumeBar } from "../modules/player/control" - -const defaultBackgroundColor = "#000000" +import { useCoverGradient } from "../modules/player/hooks" +import { usePrefetchImageColors } from "../store/image/hooks" function CoverArt({ cover }: { cover?: string }) { const scale = useSharedValue(1) @@ -37,42 +36,15 @@ function CoverArt({ cover }: { cover?: string }) { export default function PlaterScreen() { const activeTrack = useActiveTrack() + usePrefetchImageColors(activeTrack?.artwork) - const [backgroundColor, setBackgroundColor] = useState(defaultBackgroundColor) - const [isGradientLight, setIsGradientLight] = useState(false) + const { gradientColors, isGradientLight } = useCoverGradient(activeTrack?.artwork) const playerScreenContextValue = useMemo( () => ({ isBackgroundLight: isGradientLight }), [isGradientLight], ) - useEffect(() => { - async function extractColors() { - if (!activeTrack?.artwork) { - return - } - - const result = await ImageColors.getColors(activeTrack.artwork, { - fallback: defaultBackgroundColor, - cache: true, - }) - - if (result.platform === "web") { - return - } - - if (result.platform === "ios") { - setIsGradientLight(getLuminance(result.background) > 0.5) - setBackgroundColor(result.background) - } else { - setIsGradientLight(getLuminance(result.dominant) > 0.5) - setBackgroundColor(result.average) - } - } - - extractColors() - }, [activeTrack?.artwork]) - if (!activeTrack) { return null } @@ -83,7 +55,7 @@ export default function PlaterScreen() { diff --git a/apps/mobile/src/services/image.ts b/apps/mobile/src/services/image.ts new file mode 100644 index 000000000..6336d8a2b --- /dev/null +++ b/apps/mobile/src/services/image.ts @@ -0,0 +1,30 @@ +import { imagesTable } from "@/src/database/schemas" +import type { ImageSchema } from "@/src/database/schemas/types" + +import { db } from "../database" +import { imageActions } from "../store/image/store" +import type { Hydratable, Resetable } from "./internal/base" +import { conflictUpdateAllExcept } from "./internal/utils" + +class ImageServiceStatic implements Hydratable, Resetable { + async reset() { + await db.delete(imagesTable).execute() + } + async hydrate() { + const images = await db.query.imagesTable.findMany() + imageActions.upsertManyInSession(images) + } + + async upsertMany(imageColors: ImageSchema[]) { + if (imageColors.length === 0) return + await db + .insert(imagesTable) + .values(imageColors) + .onConflictDoUpdate({ + target: [imagesTable.url], + set: conflictUpdateAllExcept(imagesTable, ["url"]), + }) + } +} + +export const ImagesService = new ImageServiceStatic() diff --git a/apps/mobile/src/services/index.ts b/apps/mobile/src/services/index.ts index 4f406952c..c9c2e1999 100644 --- a/apps/mobile/src/services/index.ts +++ b/apps/mobile/src/services/index.ts @@ -1,6 +1,7 @@ import { CollectionService } from "./collection" import { EntryService } from "./entry" import { FeedService } from "./feed" +import { ImagesService } from "./image" import { InboxService } from "./inbox" import type { Hydratable } from "./internal/base" import { ListService } from "./list" @@ -17,6 +18,7 @@ const hydrates: Hydratable[] = [ UserService, EntryService, CollectionService, + ImagesService, ] export const hydrateDatabaseToStore = async () => { diff --git a/apps/mobile/src/store/image/getters.ts b/apps/mobile/src/store/image/getters.ts new file mode 100644 index 000000000..8690b6775 --- /dev/null +++ b/apps/mobile/src/store/image/getters.ts @@ -0,0 +1,7 @@ +import { useImagesStore } from "./store" + +const get = useImagesStore.getState + +export const getImageInfo = (url: string) => { + return get().images[url] +} diff --git a/apps/mobile/src/store/image/hooks.ts b/apps/mobile/src/store/image/hooks.ts new file mode 100644 index 000000000..222bcc5f3 --- /dev/null +++ b/apps/mobile/src/store/image/hooks.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query" +import { useCallback } from "react" + +import { imageSyncService, useImagesStore } from "./store" + +export const usePrefetchImageColors = (url?: string | null) => { + useQuery({ + queryKey: ["image", "colors", url], + queryFn: () => imageSyncService.getColors(url), + enabled: !!url, + }) +} + +export const useImageColors = (url?: string | null) => { + return useImagesStore( + useCallback( + (state) => { + if (!url) { + return + } + return state.images[url]?.colors + }, + [url], + ), + ) +} diff --git a/apps/mobile/src/store/image/store.ts b/apps/mobile/src/store/image/store.ts new file mode 100644 index 000000000..d0d0eb79a --- /dev/null +++ b/apps/mobile/src/store/image/store.ts @@ -0,0 +1,54 @@ +import ImageColors from "react-native-image-colors" + +import type { ImageSchema } from "@/src/database/schemas/types" +import { ImagesService } from "@/src/services/image" + +import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper" +import { getImageInfo } from "./getters" + +export type ImageModel = ImageSchema +type ImageStore = { + images: Record +} + +export const useImagesStore = createZustandStore("images")(() => ({ + images: {}, +})) + +const immerSet = createImmerSetter(useImagesStore) + +class ImageSyncService { + async getColors(url?: string | null) { + if (!url) { + return + } + const existing = getImageInfo(url)?.colors + if (existing) { + return existing + } + + const result = await ImageColors.getColors(url, { cache: true }) + await imageActions.upsertMany([{ url, colors: result }]) + return result + } +} + +class ImageActions { + upsertManyInSession(images: ImageModel[]) { + immerSet((state) => { + for (const image of images) { + state.images[image.url] = image + } + }) + } + + async upsertMany(images: ImageModel[]) { + const tx = createTransaction() + tx.store(() => this.upsertManyInSession(images)) + tx.persist(() => ImagesService.upsertMany(images)) + await tx.run() + } +} + +export const imageSyncService = new ImageSyncService() +export const imageActions = new ImageActions()