From d7496f133c214568e1e2393108f0e5842f4847e2 Mon Sep 17 00:00:00 2001 From: David Ralph Date: Tue, 28 Oct 2025 21:17:33 +0000 Subject: [PATCH] refactor: restructure background feature --- src/features/background/Background.jsx | 545 +----------------- .../api/{avif.js => avifSupport.js} | 10 +- .../background/api/backgroundFilters.js | 18 + .../background/api/backgroundLoader.js | 211 +++++++ src/features/background/api/blobUrl.js | 15 + src/features/background/api/blurHash.js | 24 + .../{getOfflineImage.js => offlineImage.js} | 13 +- .../background/components/BackgroundImage.jsx | 18 + .../background/components/BackgroundVideo.jsx | 23 + .../background/hooks/useBackgroundEvents.js | 56 ++ .../background/hooks/useBackgroundLoader.js | 47 ++ .../background/hooks/useBackgroundRenderer.js | 72 +++ .../background/hooks/useBackgroundState.js | 48 ++ 13 files changed, 567 insertions(+), 533 deletions(-) rename src/features/background/api/{avif.js => avifSupport.js} (81%) create mode 100644 src/features/background/api/backgroundFilters.js create mode 100644 src/features/background/api/backgroundLoader.js create mode 100644 src/features/background/api/blobUrl.js create mode 100644 src/features/background/api/blurHash.js rename src/features/background/api/{getOfflineImage.js => offlineImage.js} (54%) create mode 100644 src/features/background/components/BackgroundImage.jsx create mode 100644 src/features/background/components/BackgroundVideo.jsx create mode 100644 src/features/background/hooks/useBackgroundEvents.js create mode 100644 src/features/background/hooks/useBackgroundLoader.js create mode 100644 src/features/background/hooks/useBackgroundRenderer.js create mode 100644 src/features/background/hooks/useBackgroundState.js diff --git a/src/features/background/Background.jsx b/src/features/background/Background.jsx index 72b61474..8389d32c 100644 --- a/src/features/background/Background.jsx +++ b/src/features/background/Background.jsx @@ -1,526 +1,35 @@ -// todo: rewrite this mess -import variables from 'config/variables'; -import { PureComponent } from 'react'; +import { useEffect } from 'react'; -import PhotoInformation from './components/PhotoInformation'; +import BackgroundImage from './components/BackgroundImage'; +import BackgroundVideo from './components/BackgroundVideo'; -import EventBus from 'utils/eventbus'; - -import { supportsAVIF } from './api/avif'; -import { getOfflineImage } from './api/getOfflineImage'; -import videoCheck from './api/videoCheck'; -import { randomColourStyleBuilder } from './api/randomColour'; +import { useBackgroundState } from './hooks/useBackgroundState'; +import { useBackgroundLoader } from './hooks/useBackgroundLoader'; +import { useBackgroundRenderer, useBackgroundFilters } from './hooks/useBackgroundRenderer'; +import { useBackgroundEvents } from './hooks/useBackgroundEvents'; import './scss/index.scss'; -import { decodeBlurHash } from 'fast-blurhash'; -export default class Background extends PureComponent { - constructor() { - super(); - this.state = { - blob: null, - style: '', - url: '', - currentAPI: '', - firstTime: false, - photoInfo: { hidden: false, offline: false, photographerURL: '', photoURL: '' }, - }; - } +/** + * Background component - Manages and displays backgrounds + * Supports: API images, custom images, colors, gradients, videos, and photo packs + */ +export default function Background() { + const { backgroundData, updateBackground, resetBackground } = useBackgroundState(); + const { refreshBackground } = useBackgroundLoader(updateBackground, resetBackground); + const filterStyle = useBackgroundFilters(); - async setBackground() { - // clean up the previous image to prevent a memory leak - if (this.blob) { - URL.revokeObjectURL(this.blob); - } + useBackgroundRenderer(backgroundData); + useBackgroundEvents(backgroundData, refreshBackground); - const backgroundImage = document.getElementById('backgroundImage'); - - if (this.state.url !== '') { - const url = this.state.url; - const photoInformation = document.querySelector('.photoInformation'); - - // just set the background - if (localStorage.getItem('bgtransition') === 'false') { - photoInformation?.[(photoInformation.style.display = 'flex')]; - return (backgroundImage.style.background = `url(${url})`); - } - - backgroundImage.style.background = null; - - if (this.state.photoInfo.blur_hash) { - backgroundImage.style.backgroundColor = this.state.photoInfo.colour; - backgroundImage.classList.add('fade-in'); - const canvas = document.createElement('canvas'); - canvas.width = 32; - canvas.height = 32; - const ctx = canvas.getContext('2d'); - const imageData = ctx.createImageData(32, 32); - imageData.data.set(decodeBlurHash(this.state.photoInfo.blur_hash, 32, 32)); - ctx.putImageData(imageData, 0, 0); - backgroundImage.style.backgroundImage = `url(${canvas.toDataURL()})`; - } - - this.blob = URL.createObjectURL(await (await fetch(url)).blob()); - backgroundImage.classList.add('backgroundTransform'); - backgroundImage.style.backgroundImage = `url(${this.blob})`; - } else { - // custom colour - backgroundImage.setAttribute('style', this.state.style); - } - } - - async getAPIImageData(currentPun) { - let apiCategories; - - try { - apiCategories = JSON.parse(localStorage.getItem('apiCategories')); - } catch (error) { - apiCategories = localStorage.getItem('apiCategories'); - } - - const backgroundAPI = localStorage.getItem('backgroundAPI'); - const apiQuality = localStorage.getItem('apiQuality'); - let backgroundExclude = JSON.parse(localStorage.getItem('backgroundExclude')); - if (!Array.isArray(backgroundExclude)) { - backgroundExclude = []; - } - if (currentPun) { - backgroundExclude.push(currentPun); - } - - let requestURL, data; - - switch (backgroundAPI) { - case 'unsplash': - case 'pexels': { - const collection = localStorage.getItem('unsplashCollections'); - if (collection) { - requestURL = `${variables.constants.API_URL}/images/unsplash?collections=${collection}&quality=${apiQuality}`; - } else { - requestURL = `${variables.constants.API_URL}/images/unsplash?categories=${apiCategories || ''}&quality=${apiQuality}`; - } - break; - } - // Defaults to Mue - default: - requestURL = `${variables.constants.API_URL}/images/random?categories=${apiCategories || ''}&quality=${apiQuality}&excludes=${backgroundExclude}`; - break; - } - - const accept = 'application/json, ' + ((await supportsAVIF()) ? 'image/avif' : 'image/webp'); - try { - data = await (await fetch(requestURL, { headers: { accept } })).json(); - } catch { - // if requesting to the API fails, we get an offline image - this.setState(getOfflineImage('api')); - return null; - } - - let photoURL, photographerURL; - - if (backgroundAPI === 'unsplash') { - photoURL = data.photo_page; - photographerURL = data.photographer_page; - } - - return { - url: data.file, - type: 'api', - currentAPI: backgroundAPI, - photoInfo: { - hidden: false, - category: data.category, - credit: data.photographer, - location: data.location.name, - camera: data.camera, - url: data.file, - photographerURL, - photoURL, - latitude: data.location.latitude || null, - longitude: data.location.longitude || null, - views: data.views || null, - downloads: data.downloads || null, - likes: data.likes || null, - description: data.description || null, - colour: data.colour, - blur_hash: data.blur_hash, - pun: data.pun || null, - }, - }; - } - - // Main background getting function - async getBackground() { - let offline = localStorage.getItem('offlineMode') === 'true'; - if (localStorage.getItem('showWelcome') === 'true') { - offline = true; - } - - const setFavourited = ({ type, url, credit, location, camera, pun, offline }) => { - if (type === 'random_colour' || type === 'random_gradient') { - return this.setState({ type: 'colour', style: `background:${url}` }); - } - this.setState({ url, photoInfo: { credit, location, camera, pun, offline, url } }); - }; - - const favourited = JSON.parse(localStorage.getItem('favourite')); - if (favourited) { - return setFavourited(favourited); - } - - const type = localStorage.getItem('backgroundType'); - switch (type) { - case 'api': { - if (offline) { - return this.setState(getOfflineImage('api')); - } - - // API background - const data = - JSON.parse(localStorage.getItem('nextImage')) || (await this.getAPIImageData()); - localStorage.setItem('nextImage', null); - if (data) { - this.setState(data); - localStorage.setItem('currentBackground', JSON.stringify(data)); - localStorage.setItem( - 'nextImage', - JSON.stringify(await this.getAPIImageData(data.photoInfo.pun)), - ); // pre-fetch data about the next image - } - break; - } - - case 'colour': { - let customBackgroundColour = localStorage.getItem('customBackgroundColour'); - // check if its a json object - if (customBackgroundColour && customBackgroundColour.startsWith('{')) { - const customBackground = JSON.parse(customBackgroundColour); - // move to new format - try { - localStorage.setItem('customBackgroundColour', customBackground.gradient[0].colour); - customBackgroundColour = customBackground.gradient.colour; - } catch { - // give up - customBackgroundColour = 'rgb(0,0,0)'; - } - } - this.setState({ - type: 'colour', - style: `background: ${customBackgroundColour || 'rgb(0,0,0)'}`, - }); - break; - } - - case 'random_colour': - case 'random_gradient': - this.setState(randomColourStyleBuilder(type)); - break; - case 'custom': { - let customBackground = []; - const customSaved = localStorage.getItem('customBackground'); - try { - customBackground = JSON.parse(customSaved); - } catch { - if (customSaved !== '') { - // move to new format - customBackground = [customSaved]; - } - localStorage.setItem('customBackground', JSON.stringify(customBackground)); - } - - // pick random - customBackground = customBackground[Math.floor(Math.random() * customBackground.length)]; - - // allow users to use offline images - if (offline && !customBackground.startsWith('data:')) { - return this.setState(getOfflineImage('custom')); - } - - if ( - customBackground !== '' && - customBackground !== 'undefined' && - customBackground !== undefined - ) { - const object = { - url: customBackground, - type: 'custom', - video: videoCheck(customBackground), - photoInfo: { hidden: true }, - }; - - this.setState(object); - - localStorage.setItem('currentBackground', JSON.stringify(object)); - } - break; - } - - case 'photo_pack': { - if (offline) { - return this.setState(getOfflineImage('photo_pack')); - } - - const photofavourited = JSON.parse(localStorage.getItem('favourite')); - if (photofavourited) { - return setFavourited(photofavourited); - } - - const photoPack = []; - const installed = JSON.parse(localStorage.getItem('installed')); - installed.forEach((item) => { - if (item.type === 'photos') { - photoPack.push(...item.photos); - } - }); - if (photoPack) { - const randomNumber = Math.floor(Math.random() * photoPack.length); - const randomPhoto = photoPack[randomNumber]; - if ( - (localStorage.getItem('backgroundchange') === 'refresh' && - this.state.firstTime === true) || - (localStorage.getItem('backgroundchange') === null && this.state.firstTime === true) - ) { - if (this.state.firstTime !== true) { - localStorage.setItem('marketplaceNumber', randomNumber); - this.setState({ - firstTime: false, - url: randomPhoto.url.default, - type: 'photo_pack', - photoInfo: { - hidden: false, - credit: randomPhoto.photographer, - location: randomPhoto.location, - }, - }); - } - } else { - if ( - Number( - Number(localStorage.getItem('backgroundStartTime')) + - Number(localStorage.getItem('backgroundchange')) >= - Number(Date.now()), - ) - ) { - const randomPhoto = photoPack[localStorage.getItem('marketplaceNumber')]; - if (this.state.firstTime !== true) { - this.setState({ - url: randomPhoto.url.default, - type: 'photo_pack', - photoInfo: { - hidden: false, - credit: randomPhoto.photographer, - location: randomPhoto.location, - }, - }); - } else { - this.setState({ firstTime: true }); - } - this.setState({ firstTime: true }); - } else { - localStorage.setItem('marketplaceNumber', randomNumber); - return this.setState({ - url: randomPhoto.url.default, - type: 'photo_pack', - photoInfo: { - hidden: false, - credit: randomPhoto.photographer, - location: randomPhoto.location, - }, - }); - } - } - } - break; - } - default: - break; - } - } - - componentDidMount() { - const element = document.getElementById('backgroundImage'); - - // this resets it so the fade in and getting background all works properly - const refresh = () => { - element.classList.remove('fade-in'); - this.setState({ url: '', style: '', type: '', video: false, photoInfo: { hidden: true } }); - this.getBackground(); - }; - - EventBus.on('refresh', (data) => { - if (data === 'welcomeLanguage') { - localStorage.setItem('welcomeImage', JSON.stringify(this.state)); - } - - if (data === 'background') { - if (localStorage.getItem('background') === 'false') { - // user is using custom colour or image - if (this.state.photoInfo.hidden === false) { - document.querySelector('.photoInformation').style.display = 'none'; - } - - // video backgrounds - if (this.state.video === true) { - return (document.getElementById('backgroundVideo').style.display = 'none'); - } else { - return (element.style.display = 'none'); - } - } - - // video backgrounds - if (this.state.video === true) { - document.getElementById('backgroundVideo').style.display = 'block'; - } else { - if (this.state.photoInfo.hidden === false) { - try { - document.querySelector('.photoInformation').style.display = 'flex'; - } catch (e) { - // Disregard exception - } - } - - element.style.display = 'block'; - } - - const backgroundType = localStorage.getItem('backgroundType'); - - if (this.state.photoInfo.offline !== true) { - // basically check to make sure something has changed before we try getting another background - if ( - backgroundType !== this.state.type || - (this.state.type === 'api' && - localStorage.getItem('backgroundAPI') !== this.state.currentAPI) || - (this.state.type === 'custom' && - localStorage.getItem('customBackground') !== this.state.url) || - JSON.parse(localStorage.getItem('backgroundExclude')).includes(this.state.photoInfo.pun) - ) { - return refresh(); - } - } else if (backgroundType !== this.state.type) { - return refresh(); - } - } - - // uninstall photo pack reverts your background to what you had previously - if ( - data === 'marketplacebackgrounduninstall' || - data === 'backgroundwelcome' || - data === 'backgroundrefresh' - ) { - refresh(); - } - - if (data === 'backgroundeffect') { - // background effects so we don't get another image again - const backgroundFilterSetting = localStorage.getItem('backgroundFilter'); - const backgroundFilter = backgroundFilterSetting && backgroundFilterSetting !== 'none'; - - if (this.state.video === true) { - document.getElementById('backgroundVideo').style.webkitFilter = - `blur(${localStorage.getItem('blur')}px) brightness(${localStorage.getItem( - 'brightness', - )}%) ${ - backgroundFilter - ? backgroundFilterSetting + - '(' + - localStorage.getItem('backgroundFilterAmount') + - '%)' - : '' - }`; - } else { - element.style.webkitFilter = `blur(${localStorage.getItem( - 'blur', - )}px) brightness(${localStorage.getItem('brightness')}%) ${ - backgroundFilter - ? backgroundFilterSetting + - '(' + - localStorage.getItem('backgroundFilterAmount') + - '%)' - : '' - }`; - } - } - }); - - if (localStorage.getItem('welcomeTab')) { - return this.setState(JSON.parse(localStorage.getItem('welcomeImage'))); - } - - if ( - localStorage.getItem('backgroundchange') === 'refresh' || - localStorage.getItem('backgroundchange') === null - ) { - try { - document.getElementById('backgroundImage').classList.remove('fade-in'); - document.getElementsByClassName('photoInformation')[0].classList.remove('fade-in'); - } catch (e) { - // Disregard exception - } - this.getBackground(); - localStorage.setItem('backgroundStartTime', Date.now()); - } - } - - // only set once we've got the info - componentDidUpdate() { - if (this.state.video === true) { - return; - } - - this.setBackground(); - } - - componentWillUnmount() { - EventBus.off('refresh'); - } - - render() { - if (this.state.video === true) { - const enabled = (setting) => { - return localStorage.getItem(setting) === 'true'; - }; - - return ( - - ); - } - - const backgroundFilter = localStorage.getItem('backgroundFilter'); - - return ( - <> -
- {this.state.photoInfo.credit !== '' && ( - - )} - - ); - } + return backgroundData.video ? ( + + ) : ( + + ); } diff --git a/src/features/background/api/avif.js b/src/features/background/api/avifSupport.js similarity index 81% rename from src/features/background/api/avif.js rename to src/features/background/api/avifSupport.js index 1ea5ff35..00294f0c 100644 --- a/src/features/background/api/avif.js +++ b/src/features/background/api/avifSupport.js @@ -2,14 +2,14 @@ const testImage = 'AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAFCbWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAFBETmF2aWYAAAAADnBpdG0AAAAAAAEAAAAsaWxvYwAAAABEAAACAAEAAAABAAACRgAAABgAAgAAAAEAAAFqAAAA3AAAAEFpaW5mAAAAAAACAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAAAZaW5mZQIAAAEAAgAARXhpZkV4aWYAAAAAGmlyZWYAAAAAAAAADmNkc2MAAgABAAEAAAB5aXBycAAAAFlpcGNvAAAAFGlzcGUAAAAAAAAAAQAAAAEAAAAQcGFzcAAAAAEAAAABAAAADGF2MUOBABwAAAAADnBpeGkAAAAAAQgAAAATY29scm5jbHgAAQANAAGAAAAAGGlwbWEAAAAAAAAAAQABBQECg4SFAAAA/G1kYXQAAAAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAADEBAgARAAAAZgAAAGmHBAABAAAAeAAAAAAAAAABAAAAAQAAAAEAAAABAAAAcGFpbnQubmV0IDQuMy4xMgAABQAAkAcABAAAADAyMzABoAMAAQAAAAEAAAACoAQAAQAAAAEAAAADoAQAAQAAAAEAAAAFoAQAAQAAALoAAAAAAAAAAgABAAIABAAAAFI5OAACAAcABAAAADAxMDAAAAAAEgAKBxgABpgIaA0yCxJABBEAEADG1FkX'; /** - * It creates a new image element, sets the source to a base64 encoded AVIF image, and then resolves - * true if the image loads successfully, or false if it doesn't + * Checks if the browser supports AVIF image format + * @returns {Promise} True if AVIF is supported, false otherwise */ -export const supportsAVIF = () => { - new Promise((resolve) => { +export function supportsAVIF() { + return new Promise((resolve) => { const image = new Image(); image.src = `data:image/avif;base64,${testImage}`; image.onload = () => resolve(true); image.onerror = () => resolve(false); }); -}; +} diff --git a/src/features/background/api/backgroundFilters.js b/src/features/background/api/backgroundFilters.js new file mode 100644 index 00000000..d9ef6d76 --- /dev/null +++ b/src/features/background/api/backgroundFilters.js @@ -0,0 +1,18 @@ +/** + * Generates the filter style string for backgrounds + * @returns {string} The filter CSS string + */ +export function getBackgroundFilterStyle() { + const blur = localStorage.getItem('blur') || '0'; + const brightness = localStorage.getItem('brightness') || '100'; + const backgroundFilter = localStorage.getItem('backgroundFilter'); + const backgroundFilterAmount = localStorage.getItem('backgroundFilterAmount') || '100'; + + let filterString = `blur(${blur}px) brightness(${brightness}%)`; + + if (backgroundFilter && backgroundFilter !== 'none') { + filterString += ` ${backgroundFilter}(${backgroundFilterAmount}%)`; + } + + return filterString; +} diff --git a/src/features/background/api/backgroundLoader.js b/src/features/background/api/backgroundLoader.js new file mode 100644 index 00000000..ed97eece --- /dev/null +++ b/src/features/background/api/backgroundLoader.js @@ -0,0 +1,211 @@ +import variables from 'config/variables'; +import { supportsAVIF } from './avifSupport'; +import { getOfflineImage } from './offlineImage'; +import { randomColourStyleBuilder } from './randomColour'; +import videoCheck from './videoCheck'; + +const parseJSON = (key, fallback = null) => { + try { + return JSON.parse(localStorage.getItem(key)) || fallback; + } catch { + return fallback; + } +}; + +/** + * Fetches image data from the configured API + */ +export async function fetchAPIImageData(excludedPun = null) { + const api = localStorage.getItem('backgroundAPI') || 'mue'; + const quality = localStorage.getItem('apiQuality') || 'high'; + const categories = parseJSON('apiCategories', localStorage.getItem('apiCategories')); + const excludes = [...parseJSON('backgroundExclude', []), ...(excludedPun ? [excludedPun] : [])]; + + const baseURL = `${variables.constants.API_URL}/images`; + const collection = localStorage.getItem('unsplashCollections'); + + const url = (api === 'unsplash' || api === 'pexels') + ? `${baseURL}/unsplash?${collection ? `collections=${collection}` : `categories=${categories || ''}`}&quality=${quality}` + : `${baseURL}/random?categories=${categories || ''}&quality=${quality}&excludes=${excludes}`; + + try { + const accept = `application/json, ${await supportsAVIF() ? 'image/avif' : 'image/webp'}`; + const data = await (await fetch(url, { headers: { accept } })).json(); + + return { + url: data.file, + type: 'api', + currentAPI: api, + photoInfo: { + hidden: false, + category: data.category, + credit: data.photographer, + location: data.location.name, + camera: data.camera, + url: data.file, + photographerURL: api === 'unsplash' ? data.photographer_page : undefined, + photoURL: api === 'unsplash' ? data.photo_page : undefined, + latitude: data.location.latitude, + longitude: data.location.longitude, + views: data.views, + downloads: data.downloads, + likes: data.likes, + description: data.description, + colour: data.colour, + blur_hash: data.blur_hash, + pun: data.pun, + }, + }; + } catch (error) { + console.error('Failed to fetch API image:', error); + return null; + } +} + +/** + * Gets background data based on current configuration + */ +export async function getBackgroundData() { + const isOffline = localStorage.getItem('offlineMode') === 'true' || localStorage.getItem('showWelcome') === 'true'; + + // Handle favourited background + const fav = parseJSON('favourite'); + if (fav) { + if (fav.type === 'random_colour' || fav.type === 'random_gradient') { + return { type: 'colour', style: `background:${fav.url}` }; + } + return { url: fav.url, photoInfo: { ...fav, url: fav.url } }; + } + + const type = localStorage.getItem('backgroundType'); + + switch (type) { + case 'api': + return getAPIBackground(isOffline); + + case 'colour': + return getColourBackground(); + + case 'random_colour': + case 'random_gradient': + return randomColourStyleBuilder(type); + + case 'custom': + return getCustomBackground(isOffline); + + case 'photo_pack': + return getPhotoPackBackground(isOffline); + + default: + return null; + } +} + +/** + * Gets solid colour background + */ +function getColourBackground() { + let colour = localStorage.getItem('customBackgroundColour'); + + // Migrate legacy format + if (colour?.startsWith('{')) { + try { + colour = JSON.parse(colour).gradient[0].colour; + localStorage.setItem('customBackgroundColour', colour); + } catch { + colour = 'rgb(0,0,0)'; + } + } + + return { type: 'colour', style: `background: ${colour || 'rgb(0,0,0)'}` }; +} + +/** + * Gets API background with caching + */ +async function getAPIBackground(isOffline) { + if (isOffline) return getOfflineImage('api'); + + // Use cached next image if available + const cached = parseJSON('nextImage'); + const data = cached || await fetchAPIImageData(); + + if (!data) return getOfflineImage('api'); + + localStorage.setItem('currentBackground', JSON.stringify(data)); + localStorage.setItem('nextImage', null); + + // Pre-fetch next image + fetchAPIImageData(data.photoInfo.pun).then((next) => { + if (next) localStorage.setItem('nextImage', JSON.stringify(next)); + }); + + return data; +} + +/** + * Gets custom background + */ +function getCustomBackground(isOffline) { + let backgrounds = parseJSON('customBackground'); + + // Migrate legacy format + if (!Array.isArray(backgrounds)) { + const saved = localStorage.getItem('customBackground'); + backgrounds = saved ? [saved] : []; + localStorage.setItem('customBackground', JSON.stringify(backgrounds)); + } + + if (backgrounds.length === 0) return null; + + const selected = backgrounds[Math.floor(Math.random() * backgrounds.length)]; + + if (isOffline && !selected.startsWith('data:')) return getOfflineImage('custom'); + + const data = { + url: selected, + type: 'custom', + video: videoCheck(selected), + photoInfo: { hidden: true }, + }; + + localStorage.setItem('currentBackground', JSON.stringify(data)); + return data; +} + +/** + * Gets photo pack background + */ +function getPhotoPackBackground(isOffline) { + if (isOffline) return getOfflineImage('photo_pack'); + + const photos = parseJSON('installed', []).flatMap((item) => + item.type === 'photos' && item.photos ? item.photos : [] + ); + + if (photos.length === 0) return null; + + const interval = localStorage.getItem('backgroundchange'); + const startTime = Number(localStorage.getItem('backgroundStartTime')); + const shouldRefresh = !interval || interval === 'refresh' || startTime + Number(interval) < Date.now(); + + let index; + if (shouldRefresh) { + index = Math.floor(Math.random() * photos.length); + localStorage.setItem('marketplaceNumber', index); + } else { + index = Number(localStorage.getItem('marketplaceNumber')) || 0; + } + + const photo = photos[index]; + + return photo ? { + url: photo.url.default, + type: 'photo_pack', + photoInfo: { + hidden: false, + credit: photo.photographer, + location: photo.location, + }, + } : null; +} diff --git a/src/features/background/api/blobUrl.js b/src/features/background/api/blobUrl.js new file mode 100644 index 00000000..c8ee5f73 --- /dev/null +++ b/src/features/background/api/blobUrl.js @@ -0,0 +1,15 @@ +/** + * Creates a blob URL from a remote URL + * @param {string} url - The remote URL to fetch + * @returns {Promise} The blob URL or null if failed + */ +export async function createBlobUrl(url) { + try { + const response = await fetch(url); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (error) { + console.error('Failed to create blob URL:', error); + return null; + } +} diff --git a/src/features/background/api/blurHash.js b/src/features/background/api/blurHash.js new file mode 100644 index 00000000..1fc3118f --- /dev/null +++ b/src/features/background/api/blurHash.js @@ -0,0 +1,24 @@ +import { decodeBlurHash } from 'fast-blurhash'; + +/** + * Generates a blur hash placeholder image as a data URL + * @param {string} blurHash - The blur hash string + * @param {number} width - Canvas width (default: 32) + * @param {number} height - Canvas height (default: 32) + * @returns {string|null} - Data URL of the blur hash image or null if failed + */ +export function generateBlurHashDataUrl(blurHash, width = 32, height = 32) { + try { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + const imageData = ctx.createImageData(width, height); + imageData.data.set(decodeBlurHash(blurHash, width, height)); + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL(); + } catch (error) { + console.error('Failed to generate blur hash:', error); + return null; + } +} diff --git a/src/features/background/api/getOfflineImage.js b/src/features/background/api/offlineImage.js similarity index 54% rename from src/features/background/api/getOfflineImage.js rename to src/features/background/api/offlineImage.js index f45ba34b..f72805f3 100644 --- a/src/features/background/api/getOfflineImage.js +++ b/src/features/background/api/offlineImage.js @@ -1,16 +1,9 @@ import offlineImages from '../offline_images.json'; /** - * It gets a random photographer from the offlineImages.json file, then gets a random image from that - * photographer, and returns an object with the image's URL, type, and photoInfo. - * - * @param type - 'background' or 'thumbnail' - * @returns An object with the following properties: - * url: A string that is the path to the image. - * type: A string that is the type of image. - * photoInfo: An object with the following properties: - * offline: A boolean that is true. - * credit: A string that is the name of the photographer. + * Gets a random offline image from the bundled collection + * @param {string} type - The background type (for storage) + * @returns {object} Background data object with offline image */ export function getOfflineImage(type) { const photographers = Object.keys(offlineImages); diff --git a/src/features/background/components/BackgroundImage.jsx b/src/features/background/components/BackgroundImage.jsx new file mode 100644 index 00000000..baf7cc5f --- /dev/null +++ b/src/features/background/components/BackgroundImage.jsx @@ -0,0 +1,18 @@ +import { memo } from 'react'; +import PhotoInformation from './PhotoInformation'; + +/** + * BackgroundImage component for rendering image backgrounds + */ +function BackgroundImage({ filterStyle, photoInfo, currentAPI, url }) { + return ( + <> +
+ {photoInfo?.credit && ( + + )} + + ); +} + +export default memo(BackgroundImage); diff --git a/src/features/background/components/BackgroundVideo.jsx b/src/features/background/components/BackgroundVideo.jsx new file mode 100644 index 00000000..04c98cc0 --- /dev/null +++ b/src/features/background/components/BackgroundVideo.jsx @@ -0,0 +1,23 @@ +import { memo } from 'react'; + +/** + * BackgroundVideo component for rendering video backgrounds + */ +function BackgroundVideo({ url, filterStyle }) { + const isMuted = localStorage.getItem('backgroundVideoMute') === 'true'; + const shouldLoop = localStorage.getItem('backgroundVideoLoop') === 'true'; + + return ( + + ); +} + +export default memo(BackgroundVideo); diff --git a/src/features/background/hooks/useBackgroundEvents.js b/src/features/background/hooks/useBackgroundEvents.js new file mode 100644 index 00000000..efd6ed4c --- /dev/null +++ b/src/features/background/hooks/useBackgroundEvents.js @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; +import EventBus from 'utils/eventbus'; +import { getBackgroundFilterStyle } from '../api/backgroundFilters'; + +/** + * Hook for handling EventBus background events + */ +export function useBackgroundEvents(backgroundData, refreshBackground) { + useEffect(() => { + const handleEvent = (event) => { + if (event === 'welcomeLanguage') { + localStorage.setItem('welcomeImage', JSON.stringify(backgroundData)); + } else if (event === 'background') { + handleVisibilityToggle(); + } else if (['marketplacebackgrounduninstall', 'backgroundwelcome', 'backgroundrefresh'].includes(event)) { + refreshBackground(); + } else if (event === 'backgroundeffect') { + applyFilters(); + } + }; + + const handleVisibilityToggle = () => { + const element = document.getElementById(backgroundData.video ? 'backgroundVideo' : 'backgroundImage'); + const photoInfo = document.querySelector('.photoInformation'); + const isEnabled = localStorage.getItem('background') !== 'false'; + + if (!isEnabled) { + element?.style.setProperty('display', 'none'); + if (!backgroundData.photoInfo?.hidden) photoInfo?.style.setProperty('display', 'none'); + return; + } + + element?.style.setProperty('display', 'block'); + if (!backgroundData.photoInfo?.hidden) photoInfo?.style.setProperty('display', 'flex'); + + // Check if refresh needed + const type = localStorage.getItem('backgroundType'); + const needsRefresh = + (type !== backgroundData.type && !(backgroundData.photoInfo?.offline && type === backgroundData.type)) || + (backgroundData.type === 'api' && localStorage.getItem('backgroundAPI') !== backgroundData.currentAPI) || + (backgroundData.type === 'custom' && localStorage.getItem('customBackground') !== backgroundData.url) || + (backgroundData.photoInfo?.pun && JSON.parse(localStorage.getItem('backgroundExclude') || '[]').includes(backgroundData.photoInfo.pun)); + + if (needsRefresh) refreshBackground(); + }; + + const applyFilters = () => { + const filter = getBackgroundFilterStyle(); + const element = document.getElementById(backgroundData.video ? 'backgroundVideo' : 'backgroundImage'); + if (element) element.style.webkitFilter = filter; + }; + + EventBus.on('refresh', handleEvent); + return () => EventBus.off('refresh', handleEvent); + }, [backgroundData, refreshBackground]); +} diff --git a/src/features/background/hooks/useBackgroundLoader.js b/src/features/background/hooks/useBackgroundLoader.js new file mode 100644 index 00000000..223e0e1a --- /dev/null +++ b/src/features/background/hooks/useBackgroundLoader.js @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { getBackgroundData } from '../api/backgroundLoader'; + +/** + * Hook for loading and refreshing background data + */ +export function useBackgroundLoader(updateBackground, resetBackground) { + const isLoadingRef = useRef(false); + + const loadBackground = useCallback(async () => { + if (isLoadingRef.current) return; + isLoadingRef.current = true; + + try { + // Check for welcome tab first + const welcomeImage = localStorage.getItem('welcomeImage'); + if (localStorage.getItem('welcomeTab') && welcomeImage) { + updateBackground(JSON.parse(welcomeImage)); + return; + } + + const data = await getBackgroundData(); + if (data) updateBackground(data); + } catch (error) { + console.error('Failed to load background:', error); + } finally { + isLoadingRef.current = false; + } + }, [updateBackground]); + + const refreshBackground = useCallback(() => { + resetBackground(); + loadBackground(); + }, [loadBackground, resetBackground]); + + // Initial load - only run once on mount + useEffect(() => { + const changeMode = localStorage.getItem('backgroundchange'); + if (changeMode === 'refresh' || !changeMode) { + localStorage.setItem('backgroundStartTime', Date.now()); + loadBackground(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { loadBackground, refreshBackground }; +} diff --git a/src/features/background/hooks/useBackgroundRenderer.js b/src/features/background/hooks/useBackgroundRenderer.js new file mode 100644 index 00000000..eb0e6049 --- /dev/null +++ b/src/features/background/hooks/useBackgroundRenderer.js @@ -0,0 +1,72 @@ +import { useEffect, useRef } from 'react'; +import { createBlobUrl } from '../api/blobUrl'; +import { generateBlurHashDataUrl } from '../api/blurHash'; +import { getBackgroundFilterStyle } from '../api/backgroundFilters'; + +/** + * Hook for rendering backgrounds to the DOM + */ +export function useBackgroundRenderer(backgroundData) { + const blobRef = useRef(null); + + useEffect(() => { + if (backgroundData.video) return; + + const element = document.getElementById('backgroundImage'); + if (!element) return; + + // Cleanup previous blob + if (blobRef.current) { + URL.revokeObjectURL(blobRef.current); + blobRef.current = null; + } + + const applyBackground = async () => { + if (backgroundData.url) { + const hasTransition = localStorage.getItem('bgtransition') !== 'false'; + + if (!hasTransition) { + element.style.background = `url(${backgroundData.url})`; + document.querySelector('.photoInformation')?.style.setProperty('display', 'flex'); + return; + } + + element.style.background = null; + + // Apply blur hash placeholder + if (backgroundData.photoInfo?.blur_hash && backgroundData.photoInfo?.colour) { + element.style.backgroundColor = backgroundData.photoInfo.colour; + element.classList.add('fade-in'); + + const blurHash = generateBlurHashDataUrl(backgroundData.photoInfo.blur_hash); + if (blurHash) element.style.backgroundImage = `url(${blurHash})`; + } + + // Load full image + const blobUrl = await createBlobUrl(backgroundData.url); + if (blobUrl) { + blobRef.current = blobUrl; + element.classList.add('backgroundTransform'); + element.style.backgroundImage = `url(${blobUrl})`; + } else { + element.style.backgroundImage = `url(${backgroundData.url})`; + } + } else if (backgroundData.style) { + element.setAttribute('style', backgroundData.style); + } + }; + + applyBackground(); + + return () => { + if (blobRef.current) URL.revokeObjectURL(blobRef.current); + }; + }, [backgroundData.url, backgroundData.style, backgroundData.video, backgroundData.photoInfo]); +} + +/** + * Hook to get computed filter styles + */ +export function useBackgroundFilters() { + return { WebkitFilter: getBackgroundFilterStyle() }; +} diff --git a/src/features/background/hooks/useBackgroundState.js b/src/features/background/hooks/useBackgroundState.js new file mode 100644 index 00000000..2d6489c9 --- /dev/null +++ b/src/features/background/hooks/useBackgroundState.js @@ -0,0 +1,48 @@ +import { useState, useCallback } from 'react'; + +/** + * Custom hook for managing background state + */ +export function useBackgroundState() { + const [backgroundData, setBackgroundData] = useState({ + url: '', + style: '', + type: '', + currentAPI: '', + video: false, + photoInfo: { + hidden: false, + offline: false, + photographerURL: '', + photoURL: '', + }, + }); + + const updateBackground = useCallback((newData) => { + setBackgroundData((prev) => ({ + ...prev, + ...newData, + photoInfo: { + ...prev.photoInfo, + ...(newData.photoInfo || {}), + }, + })); + }, []); + + const resetBackground = useCallback(() => { + setBackgroundData({ + url: '', + style: '', + type: '', + currentAPI: '', + video: false, + photoInfo: { hidden: true }, + }); + }, []); + + return { + backgroundData, + updateBackground, + resetBackground, + }; +}