We’ll build a cross-platform mobile app for taking photos and uploading to firebase.
In Part 1, we saw how to take a picture and save it to Firebase Cloud Storage. In this post we’ll move the uploading to a separate thread via web worker, and use the blueimp library to generate a thumbnail locally and show it while uploading.
Web Workers are a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface. (mozilla.org)
So we’ll use a web worker to offload code that can potentially block our UI thread — in our case the Firebase Storage code — to another thread.
We’ll use workerize-loader to make using web workers a little easier (web workers interface is a little weird).
yarn add workerize-loader
workerize-loader
. In quasar.conf.js
in the build
section add:extendWebpack(cfg) {
cfg.module.rules.push({
test: /\.worker\.js$/,
loader: 'workerize-loader',
})
},
chainWebpack (chain, { isServer, isClient }) {
chain.output.globalObject('self')
}
This tells webpack to load the file
worker.js
using workerize-loader
.Let’s add the file
src/services/worker.js
and move our uploading code into it:import cloudStorage from './cloud-storage'
export async function uploadPicture(imageData) {
let storageId = new Date().getTime().toString();
let downloadURL = await cloudStorage.uploadBase64(
imageData,
storageId
);
return {
storageId,
downloadURL,
}
}
export function initFB() {
cloudStorage.initialize();
}
export async function deletePic(storageId) {
await cloudStorage.deleteFromStorage(storageId)
}
We’ve also added a
deletePic
call which we’ll see later in cloudStorage.js
.We’ll want to create an instance of the worker and save it in the app’s state. We don’t have state management yet, so let’s add a simple state management pattern using a
store.js
file:import worker from "workerize-loader!./worker.js";
var store = {
debug: true,
state: {
pics: [],
uploading: false
},
initWorker() {
this.state.workerInstance = worker();
this.state.workerInstance.initFB();
},
async loadPictures() {
if (this.debug) console.log("loadPictures triggered")
let picsJson = await localStorage.getItem("pics");
if (!picsJson) this.state.pics = [];
else this.state.pics = JSON.parse(picsJson);
this.state.pics = this.state.pics.filter(pic => !pic.uploading)
},
async addPic(pic) {
if (this.debug) console.log("addPic triggered with", pic)
pic.failed = false;
this.state.pics.splice(0, 0, pic);
localStorage.setItem("pics", JSON.stringify(this.state.pics));
},
async deletePic(idx) {
if (this.debug) console.log("deletePic triggered with", idx)
this.state.pics[idx].uploading = true;
if (this.state.pics[idx].storageId) {
await this.state.workerInstance.deletePic(this.state.pics[idx].storageId)
}
this.state.pics.splice(idx, 1);
localStorage.setItem("pics", JSON.stringify(this.state.pics));
},
async updatePicUploaded(oldPic, newPic) {
if (this.debug) console.log("updatePicUploaded triggered with", oldPic, newPic)
oldPic.uploading = false
oldPic.url = newPic.downloadURL
oldPic.storageId = newPic.storageId
oldPic.width = newPic.width
oldPic.height = newPic.height
localStorage.setItem("pics", JSON.stringify(this.state.pics));
},
async updatePicFailed(pic) {
if (this.debug) console.log("updatePicFailed triggered with", pic)
pic.failed = true
},
}
export default {
...store
}
A few things going on here:
We’re going to generate a thumbnail (on the client) to show while the image is uploading. We’ll use the blueimp library for this:
yarn add blueimp-load-image
Let’s add another service for manipulating images: src/services/image-ops.js:
import loadImage from 'blueimp-load-image'
const base64JpegPrefix = "data:image/jpeg;base64,";
function removeBase64Prefix(base64Str) {
return base64Str.substr(base64Str.indexOf(",") + 1);
}
function addBase64Prefix(imageData) {
return base64JpegPrefix + imageData
}
async function generateThumbnail(imageData, maxWidth) {
return new Promise(async resolve => {
let url = base64JpegPrefix + imageData
let res = await fetch(url)
let blob = await res.blob()
loadImage(
blob,
(canvas) => {
let dataURL = canvas.toDataURL('image/jpeg');
resolve(removeBase64Prefix(dataURL));
}, {
maxWidth: maxWidth,
canvas: true
}
);
});
}
export default {
removeBase64Prefix,
generateThumbnail,
addBase64Prefix,
};
We’ll extract all the image uploading flow to a service to keep our UI component clean. Add the file src/services/image-uploader.js:
import store from "./store";
import cordovaCamera from "./cordova-camera";
import imageOps from "./image-ops";
import config from "./config";
async function uploadImageFromCamera() {
let base64 = await cordovaCamera.getBase64FromCamera();
let imageData = imageOps.removeBase64Prefix(base64);
let thumbnailImageData = await imageOps.generateThumbnail(
imageData,
config.photos.thumbnailMaxWidth
);
let localPic = {
url: imageOps.addBase64Prefix(thumbnailImageData),
uploading: true
};
store.addPic(localPic);
let uploadedPic = await store.state.workerInstance.uploadPicture(
imageData
);
store.updatePicUploaded(localPic, uploadedPic);
}
export default {
uploadImageFromCamera
}
What we’re doing in the uploadImageFromCamera method is: getting base64 from the cordova camera -> generating thumbnails using our imageOps -> generating a new pic object in our state with uploading=true -> uploading the picture to Firebase using the web worker -> updating the pic’s uploading state property when uploading is finished.
Now we’ve added all these services, we need to call them from our components.
In App.vue, we’ll use the mounted lifecycle hook to initialize the worker and load the saved picture urls from the saved state:
<template>
<div id="q-app">
<router-view />
</div>
</template>
<script>
import store from "./services/store.js";
export default {
name: "App",
async mounted() {
store.initWorker();
store.loadPictures();
}
};
</script>
<style>
</style>
Finally, we’ll add the interface for viewing all this in Index.vue:
<template>
<q-page class>
<div class="q-pa-md">
<div class="row justify-center q-ma-md" v-for="(pic, idx) in pics" :key="idx">
<div class="col">
<q-card v-if="pic">
<q-img spinner-color="white" :src="pic.url">
<div class="spinner-container" v-if="pic.uploading && !pic.failed">
<q-spinner color="white" size="4em" />
</div>
<div class="spinner-container" v-if="pic.failed">
<q-icon name="cloud_off" style="font-size: 48px;"></q-icon>
</div>
</q-img>
<q-card-actions align="around">
<q-btn flat round color="red" icon="favorite" @click="notifyNotImplemented()" />
<q-btn flat round color="teal" icon="bookmark" @click="notifyNotImplemented()" />
<q-btn
flat
round
color="primary"
icon="delete"
@click="deletePic(idx)"
:disable="pic.uploading"
/>
</q-card-actions>
</q-card>
</div>
</div>
</div>
</q-page>
</template>
<style scoped>
.spinner-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>
<script>
import store from "../services/store";
import { EventBus } from "../services/event-bus";
import imageUploader from "../services/image-uploader";
export default {
name: "PageIndex",
data() {
return {
state: store.state
};
},
mounted() {
EventBus.$off("takePicture");
EventBus.$on("takePicture", this.uploadImageFromCamera);
},
computed: {
pics() {
return this.state.pics;
}
},
methods: {
notifyNotImplemented() {
this.$q.notify({ message: "Not implemented yet :/" });
},
async deletePic(idx) {
try {
await store.deletePic(idx);
this.$q.notify({ message: "Picture deleted." });
} catch (err) {
console.error(err);
this.$q.notify({ message: "Delete failed. Check log." });
}
},
async uploadImageFromCamera() {
try {
imageUploader.uploadImageFromCamera();
} catch (err) {
console.error("Uploading failed");
console.dir(err);
store.updatePicFailed(localPic);
this.$q.notify({ message: "Uploading failed. Check log." });
}
}
}
};
</script>
What’s going on here:
That’s it! We have an image uploading app that shows a blurred thumbnails with a spinner, a little like WhatsApp’s image upload. Running all this using quasar dev -m android/ios will show the final result:
Some ideas for improving this app further would be:
The full code is on GitHub here: vue-firebase-image-upload.
Enjoy :)