MMM-GDriveAmbient
MMM-GDriveAmbient is a MagicMirror² module that displays random images from a Google Drive folder on your mirror (client-side Module.register) and uses a NodeHelper to authenticate with Google via a Service Account, list files, download a random one, and send it back as a base64 data URL.
It also:
- extracts an EXIF-like timestamp from Drive file metadata (if present),
- sends a timeline highlight notification when a photo has a date,
- converts HEIC/HEIF → JPEG server-side (so the browser can display it),
- computes a dominant/average color from the image and sets it as a CSS variable
--ambient-color.
Architecture overview
This module is split into two parts:
1) Frontend (MagicMirror browser process)
- Renders the
<img>ingetDom() - Requests a random photo periodically
- Applies the ambient color via canvas sampling
- Sends notifications to other modules (Timeline highlight)
2) Backend (NodeHelper in Node.js process)
- Authenticates to Google Drive with a service account JSON key
- Lists all images in a given folder (cached)
- Picks a random file
- Downloads file bytes
- Converts HEIC/HEIF to JPEG if needed
- Sends base64 data URL + optional date metadata to frontend
Features
Random image from a Drive folder
The frontend requests a new random photo at an interval (refreshInterval), the backend selects one from a cached list.
HEIC/HEIF support (server-side conversion)
HEIC is common from iPhones. Many browsers can’t render it reliably. The NodeHelper detects HEIC and runs heic-convert to generate a JPEG buffer.
Date extraction + Timeline highlight
If the file includes imageMediaMetadata.time, the NodeHelper parses it and sends:
dateLabel(formattedde-DE)timestamp(milliseconds since epoch)
Frontend then sends:
TIMELINE_HIGHLIGHT_DATEwith{ timestamp, label }- or
nullto clear highlight when there is no date
Ambient color theme
Once the image loads, the frontend samples it via canvas and sets:
--ambient-color: rgba(r,g,b,0.9)
Your CSS can use this variable to tint backgrounds, borders, overlays, etc.
Module configuration
Defaults
defaults: {
folderId: "",
refreshInterval: 60 * 1000,
fadeSpeed: 1000,
debug: false
}
Typical config.js usage
{
module: "MMM-GDriveAmbient",
position: "fullscreen_below", // typical for ambient backgrounds
config: {
folderId: "YOUR_GOOGLE_DRIVE_FOLDER_ID",
refreshInterval: 60 * 1000, // 1 minute
fadeSpeed: 1000,
debug: false
}
}
Notes
folderIdmust be the Drive folder ID, not the name.- Your service account must have access to that folder (shared with the service account email).
Frontend documentation (Module.register)
Startup lifecycle
When MagicMirror loads the module, start() runs:
start() {
this.currentPhotoData = null;
this.currentDateLabel = null;
this.loaded = false;
this.timer = null;
this.requestRandomPhoto();
},
What happens
- Initializes state
- Immediately requests the first photo via socket notification
Styling hook
getStyles() {
return ["MMM-GDriveAmbient.css"];
},
This is where you define visuals like:
- full-screen positioning
- transitions
- using
--ambient-color
DOM rendering: loading + image display
getDom() {
const wrapper = document.createElement("div");
wrapper.className = "gdrive-ambient-wrapper";
if (!this.loaded) {
wrapper.innerHTML = "Verbinde mit Google Drive...";
return wrapper;
}
if (!this.currentPhotoData) {
return wrapper;
}
const img = document.createElement("img");
img.className = "gdrive-ambient-image";
img.src = this.currentPhotoData;
img.onload = () => {
this.applyDominantColor(img);
};
wrapper.appendChild(img);
return wrapper;
},
Key points
- Shows a loading message until first photo arrives
- Creates an
<img>whosesrcis a base64data:URL - After image loads, calls
applyDominantColor(img)
Receiving photo data from NodeHelper
When NodeHelper sends a photo, frontend handles it here:
socketNotificationReceived(notification, payload) {
if (notification === "GDRIVE_PHOTO_DATA") {
this.currentPhotoData = payload.data;
this.currentDateLabel = payload.dateLabel;
this.loaded = true;
this.updateDom(this.config.fadeSpeed);
if (payload.dateLabel && payload.timestamp) {
this.sendNotification("TIMELINE_HIGHLIGHT_DATE", {
timestamp: payload.timestamp,
label: payload.dateLabel
});
} else {
this.sendNotification("TIMELINE_HIGHLIGHT_DATE", null);
}
this.scheduleNextPhoto();
}
if (notification === "GDRIVE_ERROR") {
Log.error("MMM-GDriveAmbient Error: " + payload);
}
},
Behavior
- Stores the base64 image in
this.currentPhotoData - Triggers
updateDom(fadeSpeed)to animate swap - Sends timeline highlight (or clears it)
- Starts/refreshes the interval loop
Requesting a random photo
requestRandomPhoto() {
this.sendSocketNotification("GDRIVE_REQUEST_RANDOM_PHOTO", {
folderId: this.config.folderId
});
},
This is the frontend → backend command to fetch a new random image.
Scheduling refresh
scheduleNextPhoto() {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => {
this.requestRandomPhoto();
}, this.config.refreshInterval);
},
Why clearInterval first? Each received photo resets the loop so you don’t accumulate multiple timers.
Ambient color sampling (dominant/average color)
applyDominantColor(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const sampleSize = 50;
canvas.width = sampleSize;
canvas.height = sampleSize;
ctx.drawImage(img, 0, 0, sampleSize, sampleSize);
const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
let r = 0, g = 0, b = 0, c = 0;
for (let i = 0; i < data.length; i += 40) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
c++;
}
if (c > 0) {
const col = `rgba(${Math.round(r/c)}, ${Math.round(g/c)}, ${Math.round(b/c)}, 0.9)`;
const w = this.dom || document.querySelector(`#${this.identifier}`);
if (w) w.style.setProperty("--ambient-color", col);
}
} catch (e) {}
}
How it works
- Downscales the image to
50×50for cheap sampling - Averages pixel channels using a stride (
i += 40) to reduce work further - Sets CSS custom property
--ambient-color
CSS usage example
.gdrive-ambient-wrapper {
background: var(--ambient-color, rgba(0,0,0,0.8));
}
Backend documentation (NodeHelper)
This part runs in Node.js and must live in your module folder as node_helper.js.
Imports
const NodeHelper = require("node_helper");
const { google } = require("googleapis");
const path = require("path");
const heicConvert = require("heic-convert");
Purpose
googleapis: Drive API clientheic-convert: Convert HEIC/HEIF → JPEGnode_helper: MagicMirror backend bridge
NodeHelper startup + caching
start() {
console.log("Starting node_helper for MMM-GDriveAmbient");
this.files = [];
this.lastListUpdate = 0;
this.cacheTime = 5 * 60 * 1000;
},
Caching
this.filesholds the last file listing- Re-lists at most every
cacheTime(5 minutes)
Handling socket request from frontend
socketNotificationReceived(notification, payload) {
if (notification === "GDRIVE_REQUEST_RANDOM_PHOTO") {
this.serveRandomPhoto(payload.folderId);
}
},
Creating a Google Drive client (Service Account)
getDriveClient() {
const auth = new google.auth.GoogleAuth({
keyFile: path.join(__dirname, "service-account.json"),
scopes: ["https://www.googleapis.com/auth/drive.readonly"]
});
return google.drive({ version: "v3", auth });
},
Important
service-account.jsonmust exist in the module folder.- Scope is readonly:
drive.readonly
Selecting a random file and extracting date metadata
async serveRandomPhoto(folderId) {
try {
const now = Date.now();
if (!this.files.length || (now - this.lastListUpdate > this.cacheTime)) {
await this.refreshFileList(folderId);
}
if (!this.files.length) {
this.sendSocketNotification("GDRIVE_ERROR", "Keine Bilder gefunden.");
return;
}
const randomFile = this.files[Math.floor(Math.random() * this.files.length)];
let dateLabel = null;
let timestamp = null;
if (randomFile.imageMediaMetadata && randomFile.imageMediaMetadata.time) {
const dateObj = this.parseDate(randomFile.imageMediaMetadata.time);
if (!isNaN(dateObj.getTime())) {
timestamp = dateObj.getTime();
dateLabel = dateObj.toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric"
});
}
}
await this.downloadAndSendPhoto(randomFile.id, dateLabel, timestamp, randomFile.name);
} catch (err) {
console.error("MMM-GDriveAmbient ERROR:", err);
}
},
Key points
- Refreshes file list if cache expired
- Extracts date from
imageMediaMetadata.timewhen available - Passes
randomFile.nameso HEIC detection can use extension fallback
Listing all images in the Drive folder
async refreshFileList(folderId) {
const drive = this.getDriveClient();
try {
const res = await drive.files.list({
q: `'${folderId}' in parents and mimeType contains 'image/' and trashed = false`,
fields: "files(id, name, mimeType, createdTime, imageMediaMetadata)",
pageSize: 1000
});
this.files = res.data.files || [];
this.lastListUpdate = Date.now();
console.log(`MMM-GDriveAmbient: ${this.files.length} Bilder gefunden.`);
} catch (err) {
console.error("Fehler beim Laden der Dateiliste:", err);
}
},
Drive query explanation
'${folderId}' in parents→ only files inside the foldermimeType contains 'image/'→ includes most image typestrashed = false→ excludes deleted items
Downloading the file + HEIC conversion + sending to frontend
async downloadAndSendPhoto(fileId, dateLabel, timestamp, filename) {
try {
const drive = this.getDriveClient();
const response = await drive.files.get(
{ fileId: fileId, alt: "media" },
{ responseType: "arraybuffer" }
);
let buffer = Buffer.from(response.data);
let mimeType = response.headers["content-type"] || "image/jpeg";
const isHeic = (
(mimeType && (mimeType.includes("heic") || mimeType.includes("heif"))) ||
(filename && filename.toLowerCase().endsWith(".heic"))
);
if (isHeic) {
try {
console.log(`MMM-GDriveAmbient: Konvertiere HEIC (${filename}) zu JPEG...`);
const outputBuffer = await heicConvert({
buffer: buffer,
format: "JPEG",
quality: 0.8
});
buffer = outputBuffer;
mimeType = "image/jpeg";
} catch (convErr) {
console.error("Fehler bei der HEIC Konvertierung:", convErr);
}
}
const base64 = buffer.toString("base64");
const dataUrl = `data:${mimeType};base64,${base64}`;
this.sendSocketNotification("GDRIVE_PHOTO_DATA", {
id: fileId,
data: dataUrl,
dateLabel: dateLabel,
timestamp: timestamp
});
} catch (err) {
console.error("Download Error:", err);
}
},
Why data: URL?
- Frontend can immediately render without saving a file.
- Works well for MagicMirror, but note memory usage for very large photos.
HEIC detection
- Checks
content-typeheader OR filename extension.heic - Converts to JPEG at quality
0.8
Date parsing helper
parseDate(dateStr) {
if (!dateStr) return new Date();
if (dateStr.includes(" ") && dateStr.includes(":")) {
const parts = dateStr.split(" ");
const datePart = parts[0].replace(/:/g, "-");
const timePart = parts[1];
return new Date(`${datePart}T${timePart}`);
}
return new Date(dateStr);
}
This accounts for metadata formats that sometimes use YYYY:MM:DD HH:MM:SS.
Notifications
Frontend → Backend
GDRIVE_REQUEST_RANDOM_PHOTO- payload:
{ folderId }
- payload:
Backend → Frontend
GDRIVE_PHOTO_DATA- payload:
{ id, data, dateLabel, timestamp }
- payload:
GDRIVE_ERROR- payload:
"message"
- payload:
Frontend → Other modules
TIMELINE_HIGHLIGHT_DATE- payload:
{ timestamp, label }ornull
- payload:
Installation & setup
1) Place module folder
MagicMirror/modules/MMM-GDriveAmbient/
├─ MMM-GDriveAmbient.js
├─ node_helper.js
├─ MMM-GDriveAmbient.css
└─ service-account.json
2) Install dependencies
From the module folder:
npm install googleapis heic-convert
3) Google Drive service account access
- Create a Google Cloud project
- Enable Google Drive API
- Create a Service Account
- Download key JSON → save as
service-account.jsonin module folder - Share your target Drive folder with the service account email (Viewer access)
Performance & reliability notes
Image size
If your Drive images are huge (phone photos), base64 data URLs can be heavy. Consider:
- using Drive API thumbnails (different endpoint / fields),
- or resizing server-side before sending.
Cache behavior
The file list is cached for 5 minutes. That reduces Drive API calls significantly.
HEIC conversion cost
HEIC conversion is CPU-heavy. If you have many HEIC photos and a short refresh interval, consider:
- increasing
refreshInterval, - pre-converting your library,
- or caching converted results.
Example CSS ideas
Use the ambient color variable:
.gdrive-ambient-wrapper {
position: relative;
width: 100%;
height: 100%;
background: var(--ambient-color, rgba(0,0,0,0.85));
transition: background 800ms ease;
}
.gdrive-ambient-image {
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(1.05) contrast(1.05);
}
Troubleshooting
“Keine Bilder gefunden.”
- Wrong
folderId - Folder not shared with service account email
- Folder contains no items with
mimeTypematchingimage/
Auth errors / permission denied
service-account.jsonmissing or invalid- Drive API not enabled
- Folder not shared with the service account
HEIC still not showing
heic-convertnot installed (missing dependency)- Conversion fails (check NodeHelper logs)
- Some HEIF variants can be tricky; consider converting offline
Source code reference
This documentation reflects the code structure as provided:
- Frontend
Module.register("MMM-GDriveAmbient", ...) - Backend
node_helper.jswith Drive + HEIC conversion + metadata
