diff --git a/.devcontainer/devcontainer.env b/.devcontainer/devcontainer.env
new file mode 100644
index 0000000..2787e4a
--- /dev/null
+++ b/.devcontainer/devcontainer.env
@@ -0,0 +1,2 @@
+CHOKIDAR_USEPOLLING=true
+WDS_SOCKET_PORT=0
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..53acf79
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,39 @@
+{
+ "name": "Noisedash Dev",
+ "image": "mcr.microsoft.com/devcontainers/node:20-bookworm",
+ "forwardPorts": [8080, 1432],
+ "portsAttributes": {
+ "8080": { "label": "Vue dev server" },
+ "1432": { "label": "Noisedash API" }
+ },
+ "postCreateCommand": "npm install",
+ "remoteEnv": {
+ "HOST": "0.0.0.0"
+ },
+ "containerEnv": {
+ "HOST": "0.0.0.0"
+ },
+ "runArgs": ["--env-file", ".devcontainer/devcontainer.env"],
+ "remoteUser": "node",
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "eslint.format.enable": true,
+ "eslint.validate": ["javascript", "vue"],
+ "terminal.integrated.defaultProfile.linux": "bash",
+ "vetur.validation.template": true,
+ "vetur.validation.script": true,
+ "vetur.validation.style": true
+ },
+ "extensions": [
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
+ "octref.vetur"
+ ]
+ }
+ },
+ "updateContentCommand": "",
+ "onCreateCommand": "mkdir -p samples sessions log",
+ "postStartCommand": "npm run server & npm run serve"
+}
diff --git a/.gitea/README.md b/.gitea/README.md
new file mode 100644
index 0000000..3a17b6d
--- /dev/null
+++ b/.gitea/README.md
@@ -0,0 +1,17 @@
+Gitea Actions for Docker builds
+
+This folder contains workflows for building and pushing Docker images to the Gitea Container Registry.
+
+Setup
+- Ensure Gitea Actions is enabled in your instance and for this repo.
+- Create the following repository secrets:
+ - REGISTRY_HOST: your Gitea host (e.g., gitea.example.com)
+ - REGISTRY_OWNER: your namespace/user/org (e.g., ryan)
+ - REGISTRY_REPO: repository name (e.g., noisedash)
+ - REGISTRY_USERNAME: registry username
+ - REGISTRY_PASSWORD: registry password or a scoped token
+
+Notes
+- The workflow builds on pushes to main/master/dev and on tags v*.*.*.
+- It produces multi-arch images (amd64, arm64). Adjust platforms if not needed.
+- Image name resolves to: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_REPO} with tags derived from branch/tag/sha.
diff --git a/.gitea/workflows/docker-image.yml b/.gitea/workflows/docker-image.yml
new file mode 100644
index 0000000..d4118a0
--- /dev/null
+++ b/.gitea/workflows/docker-image.yml
@@ -0,0 +1,70 @@
+name: build-and-push-docker
+
+on:
+ push:
+ branches: [ main, master, dev ]
+ tags:
+ - 'v*.*.*'
+ pull_request:
+ branches: [ main, master, dev ]
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64,arm
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY_HOST }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_PASSWORD }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY_HOST }}/${{ env.REGISTRY_OWNER }}/${{ env.REGISTRY_REPO }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=sha
+
+ - name: Build (PR only)
+ if: ${{ github.event_name == 'pull_request' }}
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ push: false
+ platforms: linux/amd64
+ labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Build and push (branches/tags)
+ if: ${{ github.event_name != 'pull_request' }}
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ env:
+ # Example: registry.example.com or gitea.example.com
+ REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
+ # Example: ryan (your user or org in Gitea)
+ REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
+ # Example: noisedash (this repo name)
+ REGISTRY_REPO: ${{ secrets.REGISTRY_REPO }}
diff --git a/package.json b/package.json
index 97bcabd..d7eb303 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
+ "@vue/cli-plugin-pwa": "^5.0.8",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
diff --git a/public/index.html b/public/index.html
index bc51465..eb3db4b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,6 +5,8 @@
+
+
<%= htmlWebpackPlugin.options.title %>
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..08d0ddd
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "name": "Noisedash",
+ "short_name": "Noisedash",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#121212",
+ "theme_color": "#121212",
+ "description": "Ambient noise generator",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ }
+ ]
+}
diff --git a/src/components/noise.js b/src/components/noise.js
index 275eb38..58d00c2 100644
--- a/src/components/noise.js
+++ b/src/components/noise.js
@@ -1,4 +1,5 @@
import * as Tone from 'tone'
+import { setMediaSessionMetadata } from '@/registerServiceWorker'
export default {
name: 'Noise',
@@ -241,6 +242,17 @@ export default {
})
Tone.Transport.start('+0.1')
+
+ // Update Media Session so playback can continue in background with proper metadata
+ try {
+ setMediaSessionMetadata({ title: (this.selectedProfile && this.selectedProfile.text) ? this.selectedProfile.text : 'Noisedash' })
+ if ('mediaSession' in navigator) {
+ navigator.mediaSession.playbackState = 'playing'
+ navigator.mediaSession.setActionHandler('play', () => this.play())
+ navigator.mediaSession.setActionHandler('pause', () => this.stop())
+ navigator.mediaSession.setActionHandler('stop', () => this.stop())
+ }
+ } catch (e) { /* no-op */ }
},
playSporadicSample (id) {
const sample = this.loadedSamples.find(s => s.id === id)
@@ -271,6 +283,11 @@ export default {
clearInterval(s.sporadicInterval)
}
})
+
+ // Reflect playback state for OS controls
+ if ('mediaSession' in navigator) {
+ try { navigator.mediaSession.playbackState = 'paused' } catch (e) { /* no-op */ }
+ }
},
startTimer () {
this.timeRemaining -= 1
diff --git a/src/main.js b/src/main.js
index 48b9cd7..6dbef93 100644
--- a/src/main.js
+++ b/src/main.js
@@ -3,6 +3,7 @@ import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify'
import instance from './axios'
+import './registerServiceWorker'
Vue.prototype.$http = instance
diff --git a/src/registerServiceWorker.js b/src/registerServiceWorker.js
new file mode 100644
index 0000000..64e0ba5
--- /dev/null
+++ b/src/registerServiceWorker.js
@@ -0,0 +1,46 @@
+/* global workbox */
+// Registers the service worker generated by @vue/cli-plugin-pwa
+// This enables installability and offline caching for static assets.
+
+if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ const register = async () => {
+ try {
+ const reg = await navigator.serviceWorker.register(`${process.env.BASE_URL}service-worker.js`)
+ // Listen for updates and activate immediately
+ reg.addEventListener('updatefound', () => {
+ const newWorker = reg.installing
+ if (!newWorker) return
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ // New content is available; refresh clients
+ reg.waiting && reg.waiting.postMessage({ type: 'SKIP_WAITING' })
+ }
+ })
+ })
+
+ // Claim clients after activation
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ // optional: location.reload()
+ })
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('SW registration failed', e)
+ }
+ }
+ register()
+}
+
+// Minimal Media Session API wiring so playback can continue in background on supported browsers
+export function setMediaSessionMetadata (opts = {}) {
+ if ('mediaSession' in navigator) {
+ const md = new window.MediaMetadata({
+ title: opts.title || 'Noisedash',
+ artist: opts.artist || 'Ambient Generator',
+ album: opts.album || 'Noisedash',
+ artwork: opts.artwork || [
+ { src: '/favicon.ico', sizes: '64x64', type: 'image/x-icon' }
+ ]
+ })
+ navigator.mediaSession.metadata = md
+ }
+}
diff --git a/vue.config.js b/vue.config.js
index 861dd31..1ce7ae2 100644
--- a/vue.config.js
+++ b/vue.config.js
@@ -3,6 +3,48 @@ module.exports = {
'vuetify'
],
devServer: {
- proxy: 'http://localhost:1432'
+ host: '0.0.0.0',
+ port: 8080,
+ allowedHosts: 'all',
+ proxy: 'http://localhost:1432'
+ },
+ pwa: {
+ name: 'Noisedash',
+ themeColor: '#121212',
+ backgroundColor: '#121212',
+ display: 'standalone',
+ startUrl: '/',
+ manifestOptions: {
+ short_name: 'Noisedash',
+ description: 'Ambient noise generator',
+ categories: ['music', 'audio', 'productivity'],
+ display_override: ['standalone', 'browser'],
+ orientation: 'any'
+ },
+ iconPaths: {
+ favicon32: 'favicon.ico',
+ favicon16: 'favicon.ico',
+ appleTouchIcon: 'favicon.ico',
+ maskIcon: 'favicon.ico',
+ msTileImage: 'favicon.ico'
+ },
+ workboxPluginMode: 'GenerateSW',
+ workboxOptions: {
+ cleanupOutdatedCaches: true,
+ skipWaiting: true,
+ clientsClaim: true,
+ offlineGoogleAnalytics: false,
+ runtimeCaching: [
+ {
+ urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|mp3|wav|ogg)$/,
+ handler: 'CacheFirst',
+ options: {
+ cacheName: 'assets-cache',
+ expiration: { maxEntries: 60, maxAgeSeconds: 7 * 24 * 60 * 60 },
+ cacheableResponse: { statuses: [0, 200] }
+ }
+ }
+ ]
+ }
}
}