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,
+ };
+}