feat(mobile): cache image info (#2899)

This commit is contained in:
Stephen Zhou 2025-02-27 11:10:32 +08:00 committed by GitHub
parent bd12076b21
commit a67d84b7d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 825 additions and 75 deletions

View File

@ -0,0 +1,5 @@
CREATE TABLE `images` (
`url` text PRIMARY KEY NOT NULL,
`colors` text NOT NULL,
`created_at` integer DEFAULT (CURRENT_TIMESTAMP)
);

View File

@ -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": {}
}
}

View File

@ -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
}
]
}

View File

@ -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,
},
}

View File

@ -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<ImageColorsResult>().notNull(),
createdAt: t.integer("created_at", { mode: "timestamp" }).default(sql`(CURRENT_TIMESTAMP)`),
}))

View File

@ -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"

View File

@ -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
}

View File

@ -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 }
}

View File

@ -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<number> }) => {
const whoami = useWhoami()
usePrefetchImageColors(whoami?.image)
const insets = useSafeAreaInsets()
const MAX_PULL = 100
const SCALE_FACTOR = 1.8
const [gradientColors, setGradientColors] = useState<string[]>()
const [gradientLight, setGradientLight] = useState<boolean>(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",

View File

@ -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() {
<View className="flex-1 px-[1000] p-safe">
<LinearGradient
style={StyleSheet.absoluteFill}
colors={[backgroundColor, shadeColor(backgroundColor, -50)]}
colors={gradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>

View File

@ -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()

View File

@ -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 () => {

View File

@ -0,0 +1,7 @@
import { useImagesStore } from "./store"
const get = useImagesStore.getState
export const getImageInfo = (url: string) => {
return get().images[url]
}

View File

@ -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],
),
)
}

View File

@ -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<string, ImageModel>
}
export const useImagesStore = createZustandStore<ImageStore>("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()