)
diff --git a/src/layouts/widgets/photo/components/photo-empty-state.tsx b/src/layouts/widgets/photo/components/photo-empty-state.tsx
index 85a79ac41..5a89be67e 100644
--- a/src/layouts/widgets/photo/components/photo-empty-state.tsx
+++ b/src/layouts/widgets/photo/components/photo-empty-state.tsx
@@ -8,6 +8,7 @@ interface PhotoEmptyStateProps {
export function PhotoEmptyState({ size }: PhotoEmptyStateProps) {
const is1x1 = size?.w === 1 && size?.h === 1
const is2x2 = size?.w === 2 && size?.h === 2
+ const is2x4 = size?.w === 2 && size?.h === 4
if (is1x1) {
return (
@@ -21,7 +22,7 @@ export function PhotoEmptyState({ size }: PhotoEmptyStateProps) {
)
}
- if (is2x2) {
+ if (is2x2 || is2x4) {
return (
diff --git a/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts b/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts
index f4dc01cbd..cc6cc7232 100644
--- a/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts
+++ b/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { getFromStorage } from '@/common/storage'
import { listenEvent } from '@/common/utils/call-event'
import type { StoredWallpaper } from '@/common/wallpaper.interface'
@@ -23,6 +23,11 @@ const DEFAULT_THEME: WallpaperClockTheme = {
isDark: true,
}
+const MIN_CONTRAST_RATIO = 4.5
+
+const SECONDARY_HUE_WEIGHT_RATIO = 0.45
+const SECONDARY_HUE_MIN_DISTANCE = 40
+
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255
g /= 255
@@ -53,6 +58,16 @@ function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]
}
+function hslToRgb(h: number, s: number, l: number): [number, number, number] {
+ const sNorm = s / 100
+ const lNorm = l / 100
+ const k = (n: number) => (n + h / 30) % 12
+ const a = sNorm * Math.min(lNorm, 1 - lNorm)
+ const f = (n: number) =>
+ lNorm - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
+ return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]
+}
+
function hexToRgb(hex: string): [number, number, number] {
const cleaned = hex.replace('#', '').trim()
const full =
@@ -67,47 +82,249 @@ function hexToRgb(hex: string): [number, number, number] {
return [(num >> 16) & 255, (num >> 8) & 255, num & 255]
}
-function computeThemeFromHsl(
- avgL: number,
- dominantHue: number,
- dominantS: number
-): WallpaperClockTheme {
- const isDark = avgL < 55
-
- if (dominantS < 12) {
- if (isDark) {
- return {
- primaryColor: '#f1f5f9',
- secondaryColor: 'rgba(241, 245, 249, 0.85)',
- accentGlow: CLOCK_SHADOW_SETTINGS.darkBgShadow,
- isDark: true,
- }
- }
+function relativeLuminance(r: number, g: number, b: number): number {
+ const toLinear = (c: number) => {
+ const cs = c / 255
+ return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4)
+ }
+ const rl = toLinear(r)
+ const gl = toLinear(g)
+ const bl = toLinear(b)
+ return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
+}
+
+function contrastRatio(lumA: number, lumB: number): number {
+ const brightest = Math.max(lumA, lumB) + 0.05
+ const darkest = Math.min(lumA, lumB) + 0.05
+ return brightest / darkest
+}
+
+function ensureContrast(
+ hue: number,
+ sat: number,
+ initialL: number,
+ bgLuminance: number,
+ isDark: boolean
+): number {
+ let l = initialL
+ const step = isDark ? 2 : -2
+ const limit = isDark ? 97 : 4
+
+ for (let i = 0; i < 40; i++) {
+ const [r, g, b] = hslToRgb(hue, sat, l)
+ const ratio = contrastRatio(relativeLuminance(r, g, b), bgLuminance)
+ if (ratio >= MIN_CONTRAST_RATIO) break
+ if ((isDark && l >= limit) || (!isDark && l <= limit)) break
+ l += step
+ }
+
+ return Math.max(0, Math.min(100, l))
+}
+
+interface DominantColor {
+ hue: number
+ saturation: number
+ weight: number
+}
+
+interface ImageColorStats {
+ avgLuminance: number
+ primary: DominantColor
+ secondary: DominantColor | null
+}
+
+function analyzePixels(data: Uint8ClampedArray): ImageColorStats {
+ const BIN_COUNT = 12
+ const BIN_SIZE = 360 / BIN_COUNT
+
+ const hueWeights = new Array(BIN_COUNT).fill(0)
+ const hueSats = new Array(BIN_COUNT).fill(0)
+ const hueCounts = new Array(BIN_COUNT).fill(0)
+
+ let totalLuminance = 0
+ let opaquePixelCount = 0
+
+ const huesForRefine: { h: number; s: number; weight: number }[] = []
+
+ for (let i = 0; i < data.length; i += 4) {
+ const alpha = data[i + 3]
+ if (alpha < 20) continue
+
+ const r = data[i]
+ const g = data[i + 1]
+ const b = data[i + 2]
+ const [h, s, l] = rgbToHsl(r, g, b)
+
+ totalLuminance += relativeLuminance(r, g, b)
+ opaquePixelCount++
+
+ const bin = Math.min(BIN_COUNT - 1, Math.floor(h / BIN_SIZE))
+ const vibrancy = (s / 100) * (1 - Math.abs(l - 50) / 50)
+ hueWeights[bin] += vibrancy
+ hueSats[bin] += s
+ hueCounts[bin] += 1
+ huesForRefine.push({ h, s, weight: vibrancy })
+ }
+
+ if (opaquePixelCount === 0) {
return {
- primaryColor: '#1e293b',
- secondaryColor: 'rgba(30, 41, 59, 0.85)',
- accentGlow: CLOCK_SHADOW_SETTINGS.lightBgShadow,
- isDark: false,
+ avgLuminance: 0.5,
+ primary: { hue: 0, saturation: 0, weight: 0 },
+ secondary: null,
}
}
- if (isDark) {
- const targetS = Math.min(85, Math.max(50, dominantS + 10))
- return {
- primaryColor: `hsl(${dominantHue}, ${targetS}%, 90%)`,
- secondaryColor: `hsla(${dominantHue}, ${Math.max(40, targetS - 15)}%, 82%, 0.88)`,
- accentGlow: CLOCK_SHADOW_SETTINGS.darkBgShadow,
- isDark: true,
+ const avgLuminance = totalLuminance / opaquePixelCount
+
+ const ranked = hueWeights
+ .map((weight, bin) => ({ bin, weight }))
+ .sort((a, b) => b.weight - a.weight)
+
+ const bestBin = ranked[0].bin
+ const bestBinCenter = bestBin * BIN_SIZE + BIN_SIZE / 2
+
+ let sumSin = 0
+ let sumCos = 0
+ let sumSat = 0
+ let refineWeight = 0
+ const window = BIN_SIZE + 10
+
+ for (const { h, s, weight } of huesForRefine) {
+ let diff = Math.abs(h - bestBinCenter)
+ if (diff > 180) diff = 360 - diff
+ if (diff > window) continue
+ const rad = (h * Math.PI) / 180
+ sumSin += Math.sin(rad) * weight
+ sumCos += Math.cos(rad) * weight
+ sumSat += s * weight
+ refineWeight += weight
+ }
+
+ let refinedHue = bestBinCenter
+ let refinedSat = hueCounts[bestBin] > 0 ? hueSats[bestBin] / hueCounts[bestBin] : 0
+
+ if (refineWeight > 0) {
+ const angle = Math.atan2(sumSin, sumCos)
+ refinedHue = ((angle * 180) / Math.PI + 360) % 360
+ refinedSat = sumSat / refineWeight
+ }
+
+ const primary: DominantColor = {
+ hue: Math.round(refinedHue),
+ saturation: Math.round(refinedSat),
+ weight: ranked[0].weight,
+ }
+
+ let secondary: DominantColor | null = null
+ for (const candidate of ranked.slice(1)) {
+ const candidateCenter = candidate.bin * BIN_SIZE + BIN_SIZE / 2
+ let hueDistance = Math.abs(candidateCenter - primary.hue)
+ if (hueDistance > 180) hueDistance = 360 - hueDistance
+
+ if (
+ candidate.weight >= primary.weight * SECONDARY_HUE_WEIGHT_RATIO &&
+ hueDistance >= SECONDARY_HUE_MIN_DISTANCE
+ ) {
+ secondary = {
+ hue: Math.round(candidateCenter),
+ saturation:
+ hueCounts[candidate.bin] > 0
+ ? Math.round(hueSats[candidate.bin] / hueCounts[candidate.bin])
+ : 0,
+ weight: candidate.weight,
+ }
+ break
}
}
- const targetS = Math.min(90, Math.max(60, dominantS + 20))
- return {
- primaryColor: `hsl(${dominantHue}, ${targetS}%, 18%)`,
- secondaryColor: `hsla(${dominantHue}, ${Math.max(50, targetS - 10)}%, 26%, 0.88)`,
- accentGlow: CLOCK_SHADOW_SETTINGS.lightBgShadow,
- isDark: false,
+ return { avgLuminance, primary, secondary }
+}
+
+function buildTheme(stats: ImageColorStats): WallpaperClockTheme {
+ const { avgLuminance, primary, secondary } = stats
+ const isDark = avgLuminance < 0.4
+
+ const shadow = isDark
+ ? CLOCK_SHADOW_SETTINGS.darkBgShadow
+ : CLOCK_SHADOW_SETTINGS.lightBgShadow
+
+ if (primary.saturation < 12) {
+ return isDark
+ ? {
+ primaryColor: '#f1f5f9',
+ secondaryColor: 'rgba(241, 245, 249, 0.85)',
+ accentGlow: shadow,
+ isDark: true,
+ }
+ : {
+ primaryColor: '#1e293b',
+ secondaryColor: 'rgba(30, 41, 59, 0.85)',
+ accentGlow: shadow,
+ isDark: false,
+ }
+ }
+
+ const targetS = isDark
+ ? Math.min(85, Math.max(50, primary.saturation + 10))
+ : Math.min(90, Math.max(60, primary.saturation + 20))
+
+ const initialL = isDark ? 90 : 18
+ const finalL = ensureContrast(primary.hue, targetS, initialL, avgLuminance, isDark)
+
+ const primaryColor = `hsl(${primary.hue}, ${targetS}%, ${finalL}%)`
+
+ let secondaryColor: string
+ if (secondary) {
+ const secTargetS = isDark
+ ? Math.max(40, Math.min(80, secondary.saturation))
+ : Math.max(50, Math.min(85, secondary.saturation + 10))
+ const secInitialL = isDark ? 82 : 26
+ const secFinalL = ensureContrast(
+ secondary.hue,
+ secTargetS,
+ secInitialL,
+ avgLuminance,
+ isDark
+ )
+ secondaryColor = `hsla(${secondary.hue}, ${secTargetS}%, ${secFinalL}%, 0.88)`
+ } else {
+ const secS = Math.max(isDark ? 40 : 50, targetS - (isDark ? 15 : 10))
+ const secL = isDark ? Math.max(70, finalL - 8) : Math.min(35, finalL + 8)
+ secondaryColor = `hsla(${primary.hue}, ${secS}%, ${secL}%, 0.88)`
}
+
+ return { primaryColor, secondaryColor, accentGlow: shadow, isDark }
+}
+
+function extractThemeFromGradient(gradient: {
+ from: string
+ to: string
+}): WallpaperClockTheme {
+ const rgb1 = hexToRgb(gradient.from)
+ const rgb2 = hexToRgb(gradient.to)
+ const [h1, s1] = rgbToHsl(...rgb1)
+ const [h2, s2] = rgbToHsl(...rgb2)
+
+ const avgLuminance = (relativeLuminance(...rgb1) + relativeLuminance(...rgb2)) / 2
+ const dominantHue = s1 >= s2 ? h1 : h2
+ const dominantS = Math.max(s1, s2)
+
+ let hueDistance = Math.abs(h1 - h2)
+ if (hueDistance > 180) hueDistance = 360 - hueDistance
+ const secondary: DominantColor | null =
+ hueDistance >= SECONDARY_HUE_MIN_DISTANCE
+ ? {
+ hue: dominantHue === h1 ? h2 : h1,
+ saturation: dominantHue === h1 ? s2 : s1,
+ weight: Math.min(s1, s2),
+ }
+ : null
+
+ return buildTheme({
+ avgLuminance,
+ primary: { hue: dominantHue, saturation: dominantS, weight: Math.max(s1, s2) },
+ secondary,
+ })
}
async function extractThemeFromImage(src: string): Promise {
@@ -118,6 +335,11 @@ async function extractThemeFromImage(src: string): Promise
img.onload = () => {
try {
+ if (!img.naturalWidth || !img.naturalHeight) {
+ resolve(DEFAULT_THEME)
+ return
+ }
+
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
@@ -125,53 +347,15 @@ async function extractThemeFromImage(src: string): Promise
return
}
- const sampleW = 48
- const sampleH = 48
+ const sampleW = 64
+ const sampleH = 64
canvas.width = sampleW
canvas.height = sampleH
ctx.drawImage(img, 0, 0, sampleW, sampleH)
- const data = ctx.getImageData(0, 0, sampleW, sampleH).data
- let totalL = 0
- const totalPixels = data.length / 4
-
- const hueWeights = new Array(12).fill(0)
- const hueSats = new Array(12).fill(0)
- const hueCounts = new Array(12).fill(0)
-
- for (let i = 0; i < data.length; i += 4) {
- const r = data[i]
- const g = data[i + 1]
- const b = data[i + 2]
- const [h, s, l] = rgbToHsl(r, g, b)
-
- totalL += l
-
- const bin = Math.min(11, Math.floor(h / 30))
- const vibrancy = (s / 100) * (1 - Math.abs(l - 50) / 50)
- hueWeights[bin] += vibrancy
- hueSats[bin] += s
- hueCounts[bin] += 1
- }
-
- const avgL = totalL / totalPixels
-
- let bestBin = 0
- let maxWeight = -1
- for (let b = 0; b < 12; b++) {
- if (hueWeights[b] > maxWeight) {
- maxWeight = hueWeights[b]
- bestBin = b
- }
- }
-
- const dominantHue = bestBin * 30 + 15
- const dominantS =
- hueCounts[bestBin] > 0
- ? Math.round(hueSats[bestBin] / hueCounts[bestBin])
- : 0
-
- resolve(computeThemeFromHsl(avgL, dominantHue, dominantS))
+ const { data } = ctx.getImageData(0, 0, sampleW, sampleH)
+ const stats = analyzePixels(data)
+ resolve(buildTheme(stats))
} catch {
resolve(DEFAULT_THEME)
}
@@ -183,24 +367,9 @@ async function extractThemeFromImage(src: string): Promise
})
}
-function extractThemeFromGradient(gradient: {
- from: string
- to: string
-}): WallpaperClockTheme {
- const rgb1 = hexToRgb(gradient.from)
- const rgb2 = hexToRgb(gradient.to)
- const [h1, s1, l1] = rgbToHsl(...rgb1)
- const [h2, s2, l2] = rgbToHsl(...rgb2)
-
- const avgL = (l1 + l2) / 2
- const dominantHue = s1 >= s2 ? h1 : h2
- const dominantS = Math.max(s1, s2)
-
- return computeThemeFromHsl(avgL, dominantHue, dominantS)
-}
-
export function useWallpaperClockTheme(): WallpaperClockTheme {
const [theme, setTheme] = useState(DEFAULT_THEME)
+ const requestIdRef = useRef(0)
useEffect(() => {
let isMounted = true
@@ -208,14 +377,17 @@ export function useWallpaperClockTheme(): WallpaperClockTheme {
async function updateFromWallpaper(wallpaper: StoredWallpaper | null) {
if (!wallpaper) return
+ const requestId = ++requestIdRef.current
+
if (wallpaper.type === 'IMAGE' && wallpaper.src) {
const computed = await extractThemeFromImage(wallpaper.src)
- if (isMounted) setTheme(computed)
+ if (isMounted && requestId === requestIdRef.current) setTheme(computed)
} else if (wallpaper.type === 'GRADIENT' && wallpaper.gradient) {
const computed = extractThemeFromGradient(wallpaper.gradient)
- if (isMounted) setTheme(computed)
+ if (isMounted && requestId === requestIdRef.current) setTheme(computed)
} else if (wallpaper.type === 'VIDEO') {
- if (isMounted) setTheme(DEFAULT_THEME)
+ if (isMounted && requestId === requestIdRef.current)
+ setTheme(DEFAULT_THEME)
}
}
diff --git a/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx b/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx
index a8594759a..c0e1eac93 100644
--- a/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx
+++ b/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx
@@ -17,7 +17,6 @@ export function TransparentClockEnglish({
const gregorianDate = time.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
- year: 'numeric',
})
return (
@@ -43,7 +42,6 @@ export function TransparentClockEnglish({
{renderDigitSlots(minutes)}
-
diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx
index 57aaa0587..e0732b03f 100644
--- a/src/layouts/widgets/widget-registry.tsx
+++ b/src/layouts/widgets/widget-registry.tsx
@@ -60,17 +60,6 @@ export const WIDGET_DEFINITIONS: Record
= {
),
},
- [WidgetKeys.widgetify]: {
- id: WidgetKeys.widgetify,
- label: 'ویجتیفای',
- emoji: '✨',
- category: 'lifestyle',
- allowedSizes: [{ w: 2, h: 3 }],
- defaultSize: { w: 2, h: 3 },
- settingsTab: WidgetTabKeys.Pet,
- canDuplicate: false,
- node: (_instanceId) => ,
- },
[WidgetKeys.pet]: {
id: WidgetKeys.pet,
label: 'پت (حیوان خانگی)',
@@ -489,6 +478,7 @@ export const WIDGET_DEFINITIONS: Record = {
{ w: 1, h: 1, isVipOnly: true },
{ w: 2, h: 1 },
{ w: 2, h: 2 },
+ { w: 2, h: 4 },
],
defaultSize: { w: 2, h: 2 },
canDuplicate: true,
diff --git a/src/main.tsx b/src/main.tsx
index 77216b9f9..9e9cc9f22 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -7,7 +7,7 @@ import App from './app'
if (import.meta.env.DEV) {
scan({
- enabled: true,
+ enabled: false,
log: true,
})
}
diff --git a/wxt.config.ts b/wxt.config.ts
index 7b2d02bf8..e337a9cbb 100644
--- a/wxt.config.ts
+++ b/wxt.config.ts
@@ -47,7 +47,7 @@ export default defineConfig({
'@wxt-dev/module-react',
],
manifest: {
- version: '1.1.8',
+ version: '1.1.18',
name: 'Widgetify beta',
description:
'Transform your new tab into a smart dashboard with Widgetify! Get currency rates, crypto prices, weather & more.',