From 89526d19b9c9d0daeb669ef7b28d741b6581e31d Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Tue, 26 Nov 2024 21:17:33 +0000 Subject: [PATCH] refactor(background): Converted to functional component + fixed background transition quirk --- src/features/background/Background.jsx | 487 ++++++++++-------- src/features/background/api/randomColour.js | 109 +++- .../background/components/BackgroundImage.jsx | 6 +- src/features/background/scss/index.scss | 29 +- 4 files changed, 388 insertions(+), 243 deletions(-) diff --git a/src/features/background/Background.jsx b/src/features/background/Background.jsx index b42b234b..04071b2e 100644 --- a/src/features/background/Background.jsx +++ b/src/features/background/Background.jsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import variables from 'config/variables'; import PhotoInformation from './components/PhotoInformation'; import EventBus from 'utils/eventbus'; @@ -13,85 +13,26 @@ import BackgroundImage from './components/BackgroundImage'; import BackgroundVideo from './components/BackgroundVideo'; import './scss/index.scss'; -export default class Background extends PureComponent { - constructor() { - super(); - this.state = { - blob: null, - style: '', - url: '', - currentAPI: '', - firstTime: false, - photoInfo: { - hidden: false, - offline: false, - photographerURL: '', - photoURL: '', - }, - }; - } +const Background = () => { + const [state, setState] = useState({ + blob: null, + style: '', + url: '', + currentAPI: '', + firstTime: false, + photoInfo: { + hidden: false, + offline: false, + photographerURL: '', + photoURL: '', + }, + type: '', + video: false, + }); - async componentDidMount() { - const element = document.getElementById('backgroundImage'); - EventBus.on('refresh', (data) => this.handleRefreshEvent(data)); - EventBus.on('backgroundeffect', () => this.handleBackgroundEffectEvent()); - if (localStorage.getItem('welcomeTab')) { - this.setState(JSON.parse(localStorage.getItem('welcomeImage'))); - return; - } - this.getBackground(); - } + const blobRef = useRef(null); - componentDidUpdate() { - if (this.state.video !== true) { - this.setBackground(); - } - } - - componentWillUnmount() { - EventBus.off('refresh'); - EventBus.off('backgroundeffect'); - } - - async setBackground() { - if (this.blob) { - URL.revokeObjectURL(this.blob); - } - const backgroundImage = document.getElementById('backgroundImage'); - if (this.state.url !== '') { - let url = this.state.url; - const photoInformation = document.querySelector('.photoInformation'); - if (localStorage.getItem('bgtransition') === 'false') { - if (photoInformation) { - photoInformation.style.display = 'flex'; - } - backgroundImage.style.background = `url(${url})`; - return; - } - 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})`; - Stats.postEvent('feature', 'background-image', 'shown'); - } else { - backgroundImage.setAttribute('style', this.state.style); - Stats.postEvent('background', 'colour', 'set'); - } - } - - async getAPIImageData(currentPun) { + const getAPIImageData = useCallback(async (currentPun) => { let apiCategories; try { apiCategories = JSON.parse(localStorage.getItem('apiCategories')); @@ -107,7 +48,8 @@ export default class Background extends PureComponent { if (currentPun) { backgroundExclude.push(currentPun); } - let requestURL, data; + + let requestURL; switch (backgroundAPI) { case 'unsplash': case 'pexels': @@ -121,58 +63,152 @@ export default class Background extends PureComponent { requestURL = `${variables.constants.API_URL}/images/random?categories=${apiCategories || ''}&quality=${apiQuality}&excludes=${backgroundExclude}`; break; } + const accept = `application/json, ${supportsAVIF() ? 'image/avif' : 'image/webp'}`; try { - data = await (await fetch(requestURL, { headers: { accept } })).json(); + const response = await fetch(requestURL, { headers: { accept } }); + const data = await response.json(); + 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, + }, + }; } catch (e) { - this.setState(getOfflineImage('api')); + setState(getOfflineImage('api')); Stats.postEvent('background', 'image', 'offline'); 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, - }, - }; - } + }, []); - async getBackground() { + const setBackground = useCallback(async () => { + // Clean up previous blob URL + if (blobRef.current) { + URL.revokeObjectURL(blobRef.current); + blobRef.current = null; + } + + const backgroundImage = document.getElementById('backgroundImage'); + const blurhashOverlay = document.getElementById('blurhashOverlay'); + const backgroundImageActual = document.getElementById('backgroundImageActual'); + const photoInformation = document.querySelector('.photoInformation'); + + // Reset elements to default state + backgroundImageActual.style.opacity = 0; + blurhashOverlay.style.backgroundImage = ''; + blurhashOverlay.style.backgroundColor = ''; + + // Handle different background types + try { + switch (state.type) { + case 'api': + // Handle API image with blurhash + if (state.url) { + if (localStorage.getItem('bgtransition') === 'false') { + if (photoInformation) { + photoInformation.style.display = 'flex'; + } + backgroundImage.style.background = `url(${state.url})`; + return; + } + + // Set blurhash overlay if available + if (state.photoInfo.blur_hash) { + blurhashOverlay.style.backgroundColor = state.photoInfo.colour; + 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(state.photoInfo.blur_hash, 32, 32)); + ctx.putImageData(imageData, 0, 0); + blurhashOverlay.style.backgroundImage = `url(${canvas.toDataURL()})`; + } + + // Load actual image + const newBlob = URL.createObjectURL(await (await fetch(state.url)).blob()); + blobRef.current = newBlob; + backgroundImageActual.src = newBlob; + } + break; + + case 'colour': + case 'random_colour': + case 'random_gradient': + backgroundImage.style.background = state.style; + backgroundImageActual.src = ''; + Stats.postEvent('background', 'colour', 'set'); + break; + + case 'custom': + case 'photo_pack': + if (state.url) { + backgroundImage.style.background = `url(${state.url})`; + if (photoInformation && !state.photoInfo.hidden) { + photoInformation.style.display = 'flex'; + } + } + Stats.postEvent('background', state.type, 'set'); + break; + + default: + // Fallback to solid color + backgroundImage.style.background = state.style || 'rgb(0,0,0)'; + Stats.postEvent('background', 'colour', 'set'); + } + + // Set up image load handler for all image types + if (backgroundImageActual.src) { + backgroundImageActual.onload = () => { + backgroundImageActual.style.opacity = 1; + blurhashOverlay.style.opacity = 0; + Stats.postEvent('feature', 'background-image', 'shown'); + }; + } + } catch (error) { + console.error('Error setting background:', error); + // Fallback to solid black background + backgroundImage.style.background = 'rgb(0,0,0)'; + } + }, [state]); + + const getBackground = useCallback(async () => { let offline = localStorage.getItem('offlineMode') === 'true'; if (localStorage.getItem('showWelcome') !== 'false') { offline = true; } + const setFavourited = ({ type, url, credit, location, camera, pun, offline }) => { if (type === 'random_colour' || type === 'random_gradient') { - this.setState({ + setState({ type: 'colour', - style: `background:${url}`, + style: url, }); } else { - this.setState({ + setState({ url, photoInfo: { credit, @@ -186,27 +222,29 @@ export default class Background extends PureComponent { } Stats.postEvent('background', 'favourite', 'set'); }; + const favourited = JSON.parse(localStorage.getItem('favourite')); if (favourited) { setFavourited(favourited); return; } + const type = localStorage.getItem('backgroundType') || defaults.backgroundType; switch (type) { case 'api': if (offline) { - this.setState(getOfflineImage('api')); + setState(getOfflineImage('api')); Stats.postEvent('background', 'image', 'offline'); return; } - let data = JSON.parse(localStorage.getItem('nextImage')) || (await this.getAPIImageData()); + let data = JSON.parse(localStorage.getItem('nextImage')) || (await getAPIImageData()); localStorage.setItem('nextImage', null); if (data) { - this.setState(data); + setState(data); localStorage.setItem('currentBackground', JSON.stringify(data)); localStorage.setItem( 'nextImage', - JSON.stringify(await this.getAPIImageData(data.photoInfo.pun)), + JSON.stringify(await getAPIImageData(data.photoInfo.pun)), ); Stats.postEvent('background', 'image', 'api'); } @@ -222,15 +260,19 @@ export default class Background extends PureComponent { customBackgroundColour = 'rgb(0,0,0)'; } } - this.setState({ + setState({ type: 'colour', - style: `background: ${customBackgroundColour || 'rgb(0,0,0)'}`, + style: customBackgroundColour || 'rgb(0,0,0)', }); Stats.postEvent('background', 'colour', 'custom'); break; case 'random_colour': case 'random_gradient': - this.setState(randomColourStyleBuilder(type)); + const randomStyle = randomColourStyleBuilder(type); + setState({ + type: 'colour', + style: randomStyle, + }); Stats.postEvent('background', 'colour', 'random'); break; case 'custom': @@ -246,7 +288,7 @@ export default class Background extends PureComponent { } customBackground = customBackground[Math.floor(Math.random() * customBackground.length)]; if (offline && !customBackground.startsWith('data:')) { - this.setState(getOfflineImage('custom')); + setState(getOfflineImage('custom')); Stats.postEvent('background', 'image', 'offline'); return; } @@ -263,14 +305,14 @@ export default class Background extends PureComponent { hidden: true, }, }; - this.setState(object); + setState(object); localStorage.setItem('currentBackground', JSON.stringify(object)); Stats.postEvent('background', 'image', 'custom'); } break; case 'photo_pack': if (offline) { - this.setState(getOfflineImage('photo')); + setState(getOfflineImage('photo')); Stats.postEvent('background', 'image', 'offline'); return; } @@ -283,12 +325,12 @@ export default class Background extends PureComponent { } }); if (photoPack.length === 0) { - this.setState(getOfflineImage('photo')); + setState(getOfflineImage('photo')); Stats.postEvent('background', 'image', 'offline'); return; } const photo = photoPack[Math.floor(Math.random() * photoPack.length)]; - this.setState({ + setState({ url: photo.url.default, type: 'photo_pack', video: videoCheck(photo.url.default), @@ -301,111 +343,132 @@ export default class Background extends PureComponent { default: break; } - } + }, [getAPIImageData]); - handleRefreshEvent(data) { - const element = document.getElementById('backgroundImage'); - const refresh = () => { - element.classList.remove('fade-in'); - this.setState({ - url: '', - style: '', - type: '', - video: false, - photoInfo: { - hidden: true, - }, - }); - this.getBackground(); - }; - if (data === 'welcomeLanguage') { - localStorage.setItem('welcomeImage', JSON.stringify(this.state)); - } - if (data === 'background') { - if (localStorage.getItem('background') === 'false') { - if (this.state.photoInfo.hidden === false) { - document.querySelector('.photoInformation').style.display = 'none'; - } - if (this.state.video === true) { - document.getElementById('backgroundVideo').style.display = 'none'; - } else { - element.style.display = 'none'; - } - return; + const handleRefreshEvent = useCallback( + (data) => { + const element = document.getElementById('backgroundImage'); + const refresh = () => { + element.classList.remove('fade-in'); + setState({ + url: '', + style: '', + type: '', + video: false, + photoInfo: { + hidden: true, + }, + }); + getBackground(); + }; + + if (data === 'welcomeLanguage') { + localStorage.setItem('welcomeImage', JSON.stringify(state)); } - 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 + if (data === 'background') { + if (localStorage.getItem('background') === 'false') { + if (state.photoInfo.hidden === false) { + document.querySelector('.photoInformation').style.display = 'none'; } + if (state.video === true) { + document.getElementById('backgroundVideo').style.display = 'none'; + } else { + element.style.display = 'none'; + } + return; } - element.style.display = 'block'; - } - const backgroundType = localStorage.getItem('backgroundType') || defaults.backgroundType; - if (this.state.photoInfo.offline !== true) { - 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) - ) { + if (state.video === true) { + document.getElementById('backgroundVideo').style.display = 'block'; + } else { + if (state.photoInfo.hidden === false) { + try { + document.querySelector('.photoInformation').style.display = 'flex'; + } catch (e) { + // Disregard exception + } + } + element.style.display = 'block'; + } + const backgroundType = localStorage.getItem('backgroundType') || defaults.backgroundType; + if (state.photoInfo.offline !== true) { + if ( + backgroundType !== state.type || + (state.type === 'api' && localStorage.getItem('backgroundAPI') !== state.currentAPI) || + (state.type === 'custom' && localStorage.getItem('customBackground') !== state.url) || + JSON.parse(localStorage.getItem('backgroundExclude')).includes(state.photoInfo.pun) + ) { + refresh(); + return; + } + } else if (backgroundType !== state.type) { refresh(); return; } - } else if (backgroundType !== this.state.type) { - refresh(); - return; } - } - if ( - data === 'marketplacebackgrounduninstall' || - data === 'backgroundwelcome' || - data === 'backgroundrefresh' - ) { - refresh(); - } - } + if ( + data === 'marketplacebackgrounduninstall' || + data === 'backgroundwelcome' || + data === 'backgroundrefresh' + ) { + refresh(); + } + }, + [state, getBackground], + ); - handleBackgroundEffectEvent() { + const handleBackgroundEffectEvent = useCallback(() => { const element = document.getElementById('backgroundImage'); const backgroundFilterSetting = localStorage.getItem('backgroundFilter') || defaults.backgroundFilter; const backgroundFilter = backgroundFilterSetting && backgroundFilterSetting !== 'none'; - const filterValue = `blur(${localStorage.getItem('blur')}px) brightness(${localStorage.getItem( - 'brightness', - )}%) ${ + const filterValue = `blur(${localStorage.getItem('blur')}px) brightness(${localStorage.getItem('brightness')}%) ${ backgroundFilter ? backgroundFilterSetting + '(' + localStorage.getItem('backgroundFilterAmount') + '%)' : '' }`; - if (this.state.video === true) { + if (state.video === true) { document.getElementById('backgroundVideo').style.filter = filterValue; } else { element.style.filter = filterValue; } + }, [state.video]); + + useEffect(() => { + if (localStorage.getItem('welcomeTab')) { + setState(JSON.parse(localStorage.getItem('welcomeImage'))); + return; + } + getBackground(); + }, [getBackground]); + + useEffect(() => { + EventBus.on('refresh', handleRefreshEvent); + EventBus.on('backgroundeffect', handleBackgroundEffectEvent); + + return () => { + EventBus.off('refresh', handleRefreshEvent); + EventBus.off('backgroundeffect', handleBackgroundEffectEvent); + }; + }, [handleRefreshEvent, handleBackgroundEffectEvent]); + + useEffect(() => { + if (state.video !== true) { + setBackground(); + } + }, [state, setBackground]); + + if (state.video === true) { + return ; } - render() { - if (this.state.video === true) { - return ; - } - return ( - <> - - {this.state.photoInfo.credit !== '' && ( - - )} - - ); - } -} + return ( + <> + + {state.photoInfo && state.photoInfo.credit && ( + + )} + + ); +}; + +export default Background; diff --git a/src/features/background/api/randomColour.js b/src/features/background/api/randomColour.js index 6d919fa4..fc63326c 100644 --- a/src/features/background/api/randomColour.js +++ b/src/features/background/api/randomColour.js @@ -1,34 +1,89 @@ /** - * It returns a random colour or random gradient as a style object - * @param type - The type of the style. This is used to determine which style builder to use. - * @returns An object with two properties: type and style. + * Converts HSL values to Hex color code + * @param {number} h - Hue (0-360) + * @param {number} s - Saturation (0-100) + * @param {number} l - Lightness (0-100) + * @returns {string} Hex color code + */ +const hslToHex = (h, s, l) => { + l /= 100; + const a = (s * Math.min(l, 1 - l)) / 100; + const f = (n) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +}; + +/** + * Generates a random number within a range + * @param {number} min - Minimum value + * @param {number} max - Maximum value + * @returns {number} + */ +const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; + +/** + * Aesthetic color palettes + */ +const PALETTES = { + nature: [ + { h: [85, 150], s: [40, 70], l: [35, 60] }, // Forest greens + { h: [200, 240], s: [40, 70], l: [40, 65] }, // Ocean blues + { h: [35, 45], s: [40, 80], l: [35, 60] }, // Earth tones + { h: [270, 310], s: [30, 60], l: [40, 65] }, // Lavender + ], + sunset: [ + { h: [0, 40], s: [60, 90], l: [45, 65] }, // Warm oranges + { h: [280, 320], s: [30, 60], l: [40, 65] }, // Soft purples + { h: [20, 40], s: [70, 90], l: [50, 70] }, // Golden hour + ], +}; + +/** + * Generates an aesthetically pleasing random color + * @returns {string} Hex color code + */ +const generateRandomColor = () => { + const palette = PALETTES.nature.concat(PALETTES.sunset); + const colors = palette[Math.floor(Math.random() * palette.length)]; + + const h = randomRange(colors.h[0], colors.h[1]); + const s = randomRange(colors.s[0], colors.s[1]); + const l = randomRange(colors.l[0], colors.l[1]); + + return hslToHex(h, s, l); +}; + +/** + * Generates a complementary color based on the input color + * @param {string} baseColor - Base color in hex + * @returns {string} Complementary color in hex + */ +const getComplementaryColor = (baseColor) => { + // Convert hex to HSL, shift hue by 180 degrees + const h = (parseInt(baseColor.slice(1), 16) + 180) % 360; + const s = randomRange(40, 70); + const l = randomRange(40, 65); + return hslToHex(h, s, l); +}; + +/** + * Generates random background style based on type + * @param {string} type - Either 'random_colour' or 'random_gradient' + * @returns {string} CSS background style string */ export function randomColourStyleBuilder(type) { - // randomColour based on https://stackoverflow.com/a/5092872 - const randomColour = () => - '#000000'.replace(/0/g, () => { - return (~~(Math.random() * 16)).toString(16); - }); - let style = `background:${randomColour()};`; - if (type === 'random_gradient') { - const directions = [ - 'to right', - 'to left', - 'to bottom', - 'to top', - 'to bottom right', - 'to bottom left', - 'to top right', - 'to top left', - ]; - style = `background:linear-gradient(${ - directions[Math.floor(Math.random() * directions.length)] - }, ${randomColour()}, ${randomColour()});`; + const directions = ['to right', 'to bottom right', 'to bottom', 'to bottom left', 'to left']; + const direction = directions[Math.floor(Math.random() * directions.length)]; + const color1 = generateRandomColor(); + const color2 = getComplementaryColor(color1); + return `linear-gradient(${direction}, ${color1}, ${color2})`; } - return { - type: 'colour', - style, - }; + return generateRandomColor(); } diff --git a/src/features/background/components/BackgroundImage.jsx b/src/features/background/components/BackgroundImage.jsx index 03ae5f42..360d00a2 100644 --- a/src/features/background/components/BackgroundImage.jsx +++ b/src/features/background/components/BackgroundImage.jsx @@ -16,7 +16,11 @@ const BackgroundImage = () => { }`, }} id="backgroundImage" - /> + className="backgroundImage" + > +
+ background +
); }; diff --git a/src/features/background/scss/index.scss b/src/features/background/scss/index.scss index 86e80cb7..529cf794 100644 --- a/src/features/background/scss/index.scss +++ b/src/features/background/scss/index.scss @@ -7,13 +7,13 @@ background-repeat: no-repeat !important; background-position: center !important; background-attachment: fixed !important; - zoom: 100% !important; + transform: scale(1) !important; + position: relative; } .backgroundTransform { @include animation('fadein2 2s'); - - transition: background 2s; + transition: opacity 2s; } @include keyframes(fadein2) { @@ -26,6 +26,29 @@ } } +.blurhashOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + transition: opacity 2s; +} + +.backgroundImageActual { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 2s; +} + .backgroundPreload { opacity: 0; }