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] } + } + } + ] + } } }