diff --git a/src/features/background/api/backgroundLoader.js b/src/features/background/api/backgroundLoader.js index 542337bd..e07db2f4 100644 --- a/src/features/background/api/backgroundLoader.js +++ b/src/features/background/api/backgroundLoader.js @@ -7,6 +7,7 @@ import { getAllBackgrounds, getAllBackgroundsWithMetadata } from 'utils/customBa import { BackgroundQueueManager } from 'utils/backgroundQueue'; import { getProxiedImageUrl } from 'utils/marketplace'; import { safeParseJSON } from 'utils/jsonStorage'; +import { buildPhotoPool, checkAndRefreshAPIPacks } from './photoPackAPI'; /** * Fetches image data from the configured API @@ -295,15 +296,15 @@ async function prefetchCustomBackgrounds(queueManager, allBackgrounds, currentId /** * Gets photo pack background with prefetching and blurhash + * Now supports both static and API-based photo packs */ function getPhotoPackBackground(isOffline) { if (isOffline) return getOfflineImage('photo_pack'); - const photos = safeParseJSON('installed', []).flatMap((item) => - item.type === 'photos' && item.photos ? item.photos : [], - ); + // Build combined pool from static and API packs + const pool = buildPhotoPool(); - if (photos.length === 0) return null; + if (pool.length === 0) return null; const queueManager = new BackgroundQueueManager('photoPackQueue', 3); let photoData; @@ -313,19 +314,20 @@ function getPhotoPackBackground(isOffline) { if (cachedQueue.length > 0) { photoData = queueManager.shift(); } else { - // Pick random photo - const index = Math.floor(Math.random() * photos.length); - const selected = photos[index]; + // Pick random photo from pool + const selected = pool[Math.floor(Math.random() * pool.length)]; photoData = { - url: getProxiedImageUrl(selected.url.default), + url: getProxiedImageUrl(selected.url.default || selected.url), type: 'photo_pack', photoInfo: { hidden: false, credit: selected.photographer, location: selected.location, blur_hash: selected.blur_hash || null, - url: selected.url.default, + url: selected.url.default || selected.url, + source: selected.source, + pack_id: selected.pack_id, }, }; } @@ -338,7 +340,7 @@ function getPhotoPackBackground(isOffline) { // Prefetch more photos in the background if (queueManager.needsPrefetch()) { - prefetchPhotoPackImages(queueManager, photos, photoData, cachedQueue).catch((error) => { + prefetchPhotoPackImages(queueManager, pool, photoData, cachedQueue).catch((error) => { console.error('Failed to prefetch photo pack images:', error); }); } @@ -348,19 +350,23 @@ function getPhotoPackBackground(isOffline) { /** * Prefetch photo pack images in the background + * Supports both static and API-based photo packs * @param {BackgroundQueueManager} queueManager - The queue manager - * @param {Array} allPhotos - All available photos from installed packs + * @param {Array} pool - Combined pool of photos from all packs * @param {Object} currentPhoto - The current photo data * @param {Array} currentQueue - The current queue state */ -async function prefetchPhotoPackImages(queueManager, allPhotos, currentPhoto, currentQueue) { +async function prefetchPhotoPackImages(queueManager, pool, currentPhoto, currentQueue) { const count = queueManager.getSpaceNeeded(); // Get already used URLs const usedUrls = [currentPhoto.url, ...currentQueue.map((p) => p.url)]; - // Filter available photos - const available = allPhotos.filter((p) => !usedUrls.includes(p.url.default)); + // Filter available photos (handle both URL formats) + const available = pool.filter((p) => { + const url = p.url.default || p.url; + return !usedUrls.includes(url) && !usedUrls.includes(getProxiedImageUrl(url)); + }); if (available.length === 0) return; @@ -370,16 +376,21 @@ async function prefetchPhotoPackImages(queueManager, allPhotos, currentPhoto, cu // Normalize metadata const normalized = selected.map((photo) => ({ - url: getProxiedImageUrl(photo.url.default), + url: getProxiedImageUrl(photo.url.default || photo.url), type: 'photo_pack', photoInfo: { hidden: false, credit: photo.photographer, location: photo.location, blur_hash: photo.blur_hash || null, - url: photo.url.default, + url: photo.url.default || photo.url, + source: photo.source, + pack_id: photo.pack_id, }, })); queueManager.push(normalized); + + // Check if any API pack cache needs refresh + checkAndRefreshAPIPacks(); } diff --git a/src/features/background/api/photoPackAPI.js b/src/features/background/api/photoPackAPI.js new file mode 100644 index 00000000..b69723f0 --- /dev/null +++ b/src/features/background/api/photoPackAPI.js @@ -0,0 +1,213 @@ +import variables from 'config/variables'; + +/** + * Fetch photos from MUE API based on pack settings + * @param {string} packId - The ID of the photo pack + * @param {object} settings - Pack-specific settings + * @returns {Promise} Photo object or null on error + */ +export async function fetchFromMUE(packId, settings) { + const { quality, categories } = settings; + + const params = new URLSearchParams({ + quality: quality || 'high', + categories: (categories || []).join(','), + }); + + try { + const response = await fetch(`${variables.constants.API_URL}/images/random?${params}`); + + if (!response.ok) throw new Error(`API error: ${response.status}`); + + const data = await response.json(); + + return { + photographer: data.photographer, + location: data.location?.name || 'Unknown', + url: { default: data.file }, + blur_hash: data.blur_hash, + colour: data.colour, + category: data.category, + camera: data.camera, + }; + } catch (error) { + console.error('MUE API fetch failed:', error); + return null; + } +} + +/** + * Fetch photos from Unsplash API + * @param {string} packId - The ID of the photo pack + * @param {object} settings - Pack-specific settings (must include api_key) + * @returns {Promise} Photo object or null on error + */ +export async function fetchFromUnsplash(packId, settings) { + const { api_key, collections } = settings; + + if (!api_key) { + console.warn('Unsplash API key not configured'); + return null; + } + + // Deobfuscate API key + const decodedKey = atob(api_key); + + const params = new URLSearchParams({ + orientation: 'landscape', + }); + + if (collections) { + params.append('collections', collections); + } + + try { + const response = await fetch(`https://api.unsplash.com/photos/random?${params}`, { + headers: { + Authorization: `Client-ID ${decodedKey}`, + }, + }); + + if (!response.ok) { + if (response.status === 429) { + console.warn('Unsplash rate limit hit'); + } + throw new Error(`Unsplash API error: ${response.status}`); + } + + const data = await response.json(); + + return { + photographer: data.user.name, + location: data.location?.title || 'Unknown', + url: { default: data.urls.regular }, + blur_hash: data.blur_hash, + colour: data.color, + unsplash_url: data.links.html, + photographer_url: data.user.links.html, + }; + } catch (error) { + console.error('Unsplash API fetch failed:', error); + return null; + } +} + +/** + * Fetch multiple photos for a pack and update cache + * @param {string} packId - The ID of the photo pack to refresh + * @returns {Promise} Success status + */ +export async function refreshAPIPackCache(packId) { + const installed = JSON.parse(localStorage.getItem('installed') || '[]'); + const pack = installed.find((p) => p.id === packId); + + if (!pack || !pack.api_enabled) return false; + + const settings = JSON.parse(localStorage.getItem(`photopack_settings_${packId}`) || '{}'); + + const fetchFunction = pack.api_provider === 'mue' ? fetchFromMUE : fetchFromUnsplash; + + // Fetch 8 photos + const promises = Array.from({ length: 8 }, () => fetchFunction(packId, settings)); + const results = await Promise.all(promises); + const validPhotos = results.filter(Boolean); + + if (validPhotos.length === 0) { + console.warn(`No photos fetched for pack ${packId}`); + return false; + } + + // Update cache + const apiPackCache = JSON.parse(localStorage.getItem('api_pack_cache') || '{}'); + apiPackCache[packId] = { + photos: validPhotos, + last_fetched: Date.now(), + last_refresh_attempt: Date.now(), + }; + + try { + localStorage.setItem('api_pack_cache', JSON.stringify(apiPackCache)); + return true; + } catch (error) { + if (error.name === 'QuotaExceededError') { + // Keep only 5 most recent photos + apiPackCache[packId].photos = validPhotos.slice(0, 5); + localStorage.setItem('api_pack_cache', JSON.stringify(apiPackCache)); + } + return false; + } +} + +/** + * Check all API packs and refresh if needed + * Hardcoded 1 hour refresh interval (separate from global background change frequency) + */ +export async function checkAndRefreshAPIPacks() { + const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]'); + const apiPackCache = JSON.parse(localStorage.getItem('api_pack_cache') || '{}'); + + // Hardcoded 1 hour refresh interval (separate from global background change frequency) + const CACHE_REFRESH_INTERVAL = 3600 * 1000; // 1 hour in milliseconds + + for (const packId of apiPacksReady) { + const cached = apiPackCache[packId]; + + const needsRefresh = + !cached || + Date.now() - cached.last_fetched > CACHE_REFRESH_INTERVAL || + cached.photos.length < 3; + + if (needsRefresh) { + // Don't block - refresh in background + refreshAPIPackCache(packId).catch((error) => { + console.error(`Failed to refresh ${packId}:`, error); + }); + } + } +} + +/** + * Build combined photo pool from static and API packs + * @returns {Array} Combined array of photos from all sources + */ +export function buildPhotoPool() { + const pool = []; + const installed = JSON.parse(localStorage.getItem('installed') || '[]'); + const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]'); + const apiPackCache = JSON.parse(localStorage.getItem('api_pack_cache') || '{}'); + + installed.forEach((pack) => { + if (pack.type !== 'photos') return; + + if (pack.api_enabled) { + // API pack - check if configured and ready + if (apiPacksReady.includes(pack.id)) { + const cached = apiPackCache[pack.id]; + if (cached && cached.photos.length > 0) { + // Add cached photos with source metadata + cached.photos.forEach((photo) => { + pool.push({ + ...photo, + source: `api:${pack.api_provider}`, + pack_id: pack.id, + }); + }); + } + } + } else { + // Static pack - add all photos + pack.photos.forEach((photo) => { + pool.push({ + photographer: photo.photographer, + location: photo.location, + url: photo.url, + blur_hash: photo.blur_hash, + source: 'static', + pack_id: pack.id, + }); + }); + } + }); + + return pool; +} diff --git a/src/features/background/options/sections/PhotoPackSettings.jsx b/src/features/background/options/sections/PhotoPackSettings.jsx new file mode 100644 index 00000000..815dfd28 --- /dev/null +++ b/src/features/background/options/sections/PhotoPackSettings.jsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect } from 'react'; +import variables from 'config/variables'; +import EventBus from 'utils/eventbus'; +import { Dropdown, Text, Switch, Slider } from 'components/Form/Settings'; +import { Row, Content, Action } from 'components/Layout/Settings/Item'; +import { Button } from 'components/Elements'; +import { refreshAPIPackCache } from 'features/background/api/photoPackAPI'; +import { MdRefresh, MdWarning } from 'react-icons/md'; + +/** + * ChipSelect component for multi-select options + */ +const ChipSelect = ({ label, options, defaultValue, name, onChange }) => { + const [selected, setSelected] = useState(defaultValue || []); + + const toggleChip = (value) => { + const newSelected = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value]; + setSelected(newSelected); + onChange(newSelected); + }; + + return ( +
+ +
+ {options.map((option) => ( + + ))} +
+
+ ); +}; + +const PhotoPackSettings = ({ pack }) => { + if (!pack.settings_schema || pack.settings_schema.length === 0) { + return null; + } + + const [settings, setSettings] = useState(() => { + const saved = localStorage.getItem(`photopack_settings_${pack.id}`); + return saved ? JSON.parse(saved) : {}; + }); + + const [dynamicOptions, setDynamicOptions] = useState({}); + const [isRefreshing, setIsRefreshing] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + + // Load dynamic options (e.g., categories from API) + useEffect(() => { + pack.settings_schema.forEach((field) => { + if (field.dynamic && field.options_source) { + loadDynamicOptions(field); + } + }); + }, [pack.id]); + + // Validate settings + useEffect(() => { + validateSettings(); + }, [settings]); + + const loadDynamicOptions = async (field) => { + if (field.options_source === 'api:categories') { + try { + const response = await fetch(`${variables.constants.API_URL}/images/categories`); + const categories = await response.json(); + setDynamicOptions((prev) => ({ + ...prev, + [field.key]: categories.map((cat) => ({ value: cat, label: cat })), + })); + } catch (error) { + console.error('Failed to load categories:', error); + } + } + }; + + const validateSettings = () => { + const errors = []; + pack.settings_schema.forEach((field) => { + if (field.required && !settings[field.key]) { + errors.push(`${field.label} is required`); + } + }); + setValidationErrors(errors); + + // Update api_packs_ready list + const apiPacksReady = JSON.parse(localStorage.getItem('api_packs_ready') || '[]'); + const isReady = errors.length === 0; + const isInList = apiPacksReady.includes(pack.id); + + if (isReady && !isInList) { + apiPacksReady.push(pack.id); + localStorage.setItem('api_packs_ready', JSON.stringify(apiPacksReady)); + } else if (!isReady && isInList) { + const filtered = apiPacksReady.filter((id) => id !== pack.id); + localStorage.setItem('api_packs_ready', JSON.stringify(filtered)); + } + }; + + const handleSettingChange = (key, value, secure = false) => { + const processedValue = secure ? btoa(value) : value; + const newSettings = { ...settings, [key]: processedValue }; + setSettings(newSettings); + localStorage.setItem(`photopack_settings_${pack.id}`, JSON.stringify(newSettings)); + }; + + const handleManualRefresh = async () => { + setIsRefreshing(true); + await refreshAPIPackCache(pack.id); + setIsRefreshing(false); + // Trigger background refresh + EventBus.emit('refresh', 'background'); + }; + + const renderField = (field, index) => { + const value = + field.secure && settings[field.key] ? atob(settings[field.key]) : settings[field.key] || field.default; + + switch (field.type) { + case 'dropdown': + return ( + handleSettingChange(field.key, newValue)} + /> + ); + + case 'chipselect': + const options = field.dynamic ? dynamicOptions[field.key] || [] : field.options; + return ( + handleSettingChange(field.key, newValue)} + /> + ); + + case 'text': + return ( + handleSettingChange(field.key, e.target.value, field.secure)} + subtitle={field.help_text} + /> + ); + + case 'switch': + return ( + handleSettingChange(field.key, newValue)} + /> + ); + + case 'slider': + return ( + handleSettingChange(field.key, newValue)} + /> + ); + + default: + return null; + } + }; + + return ( + <> + + + +