diff --git a/package-lock.json b/package-lock.json index ca3b1d3..408234a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beatnik", - "version": "0.5.3", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beatnik", - "version": "0.5.3", + "version": "0.5.4", "dependencies": { "@angular/animations": "^19.0.0", "@angular/common": "^19.0.0", @@ -33,6 +33,9 @@ "immer": "^10.1.1", "ionicons": "^7.0.0", "lodash-es": "^4.17.21", + "mopidy": "^1.3.0", + "mpc-js": "^2.1.1", + "mpc-js-web": "^1.3.2", "rxjs": "~7.8.0", "swiper": "7.2.0", "tslib": "^2.3.0", @@ -7930,7 +7933,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -10864,8 +10866,7 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, "node_modules/events": { "version": "3.3.0", @@ -13149,6 +13150,15 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -14991,6 +15001,80 @@ "node": ">=0.10.0" } }, + "node_modules/mopidy": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/mopidy/-/mopidy-1.3.0.tgz", + "integrity": "sha512-wBk5XKXpZY5tMedeWtiS8mZlwOu5RZYibNe5TW85dQMovN4ZW0gl6SPNAwfqiwQ9YiCDOQwwEQ6CnPNPab6QYA==", + "license": "Apache-2.0", + "dependencies": { + "isomorphic-ws": "^4.0.1", + "ws": "^7.3.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/mopidy/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/mpc-js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mpc-js/-/mpc-js-2.1.1.tgz", + "integrity": "sha512-FQ/C4QDq8vVhnYBG51ZCrFgdzUhMfcYu35eCiGjcsqyNXvrDFL585Tg0R+vQ/Up4vb/7t1sGM4TwT7QwL3a0bA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/mpc-js-core": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mpc-js-core/-/mpc-js-core-1.3.2.tgz", + "integrity": "sha512-puBzEZ8/gMUNdpXMzQZv/VFTc2eVmPZpIFofk9Kl7bpALpc6CuTVeC9uA3yYNs293Y1b+OZUdv5HyEqr4feOKA==", + "deprecated": "The functionality of this package has been integrated into version 2 of the mpc-js package", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.7" + } + }, + "node_modules/mpc-js-web": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mpc-js-web/-/mpc-js-web-1.3.2.tgz", + "integrity": "sha512-nUa8ci/nGkzBpnl3J1CLer0wQ/n8fmb+mP0c2Q13KUZ6Cn6/69yIDO7iVzT3d9cOwnJ4Jk/ucG4v4yiAlltyDQ==", + "deprecated": "The functionality of this package has been integrated into version 2 of the mpc-js package", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1", + "mpc-js-core": "^1.3.2", + "text-encoder-lite": "^2.0.0" + } + }, + "node_modules/mpc-js/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -19352,6 +19436,11 @@ "b4a": "^1.6.4" } }, + "node_modules/text-encoder-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/text-encoder-lite/-/text-encoder-lite-2.0.0.tgz", + "integrity": "sha512-bo08ND8LlBwPeU23EluRUcO3p2Rsb/eN5EIfOVqfRmblNDEVKK5IzM9Qfidvo+odT0hhV8mpXQcP/M5MMzABXw==" + }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -21112,7 +21201,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 4b01258..8e51f8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "beatnik", - "version": "0.5.3", + "version": "0.5.4", "author": "byrds & bytes gmbh", "homepage": "https://beatnik.audio", "scripts": { @@ -44,6 +44,9 @@ "immer": "^10.1.1", "ionicons": "^7.0.0", "lodash-es": "^4.17.21", + "mopidy": "^1.3.0", + "mpc-js": "^2.1.1", + "mpc-js-web": "^1.3.2", "rxjs": "~7.8.0", "swiper": "7.2.0", "tslib": "^2.3.0", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 5d8eb95..273e2f9 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -98,6 +98,14 @@ const routes: Routes = [ path: 'volume-preset-edit', loadChildren: () => import('./pages/volume-preset-edit/volume-preset-edit.module').then( m => m.VolumePresetEditPageModule) }, + { + path: 'stream-presets', + loadChildren: () => import('./pages/streams/stream-presets/stream-presets.module').then( m => m.StreamPresetsPageModule) + }, + { + path: 'stream-preset-edit', + loadChildren: () => import('./pages/streams/stream-preset-edit/stream-preset-edit.module').then( m => m.StreamPresetEditPageModule) + }, ]; diff --git a/src/app/components/client-info/client-info.component.html b/src/app/components/client-info/client-info.component.html index 88edfa3..359f0d7 100644 --- a/src/app/components/client-info/client-info.component.html +++ b/src/app/components/client-info/client-info.component.html @@ -111,7 +111,7 @@

Device Settings

- +

Snapcast Server Status

@@ -120,51 +120,81 @@

Status: Active

Status: Inactive

-

"Inactive" Means this device is configured to be a Beatnik / snapcast client. Clients will automatically try to connect to the server in your local Network.

+

"Inactive" Means this device is configured to be a Beatnik / snapcast client. Clients will automatically try + to connect to the server in your local Network.

{{ snapcastServerStatus.message}}

- - Refresh Snapcast Server Status - - - - Disable Snapcast Server - - - - Enable Snapcast Server - - - Reboot - - - - Choose Speakers - - - Hardware API Test + System Info - - - Get System Info (Console) - - - LED: Solid Red - - - LED: Pulse Green/Blue - - - LED: Blink Yellow - - - LED: Turn Off - + + + + +

Hostname

+

{{ systemInfo.hostname }}

+
+
+ + +

IP Addresses

+

{{ ip }}

+
+
+ + +

RAM Usage

+

{{ systemInfo.freeRam | number:'1.0-0' }} MB ({{ (systemInfo.freeRam / 1024) | number:'1.1-2' }} GB) Free / + {{ systemInfo.totalRam | number:'1.0-0' }} MB ({{ (systemInfo.totalRam / 1024) | number:'1.1-2' }} GB) Total +

+
+
+ + +

Temperature

+

{{ systemInfo.temperature }} °C

+
+
+
+ +
+ + + Refresh Snapcast Server Status + + + + + Disable Snapcast Server + + + + + Enable Snapcast Server + + + + Choose Speakers + + + + + Reboot Device + + + + + Refresh System Info + +
diff --git a/src/app/components/client-info/client-info.component.ts b/src/app/components/client-info/client-info.component.ts index 1c2570f..e233413 100644 --- a/src/app/components/client-info/client-info.component.ts +++ b/src/app/components/client-info/client-info.component.ts @@ -6,11 +6,11 @@ import { UserPreference } from 'src/app/enum/user-preference.enum'; import { SnapCastServerStatusResponse, Client } from 'src/app/model/snapcast.model'; import { BeatnikHardwareService, HardwareStatus } from 'src/app/services/beatnik-hardware.service'; import { BeatnikSnapcastService, SnapcastActionResponse } from 'src/app/services/beatnik-snapcast.service'; -import { BeatnikSystemService } from 'src/app/services/beatnik-system.service'; +import { BeatnikSystemService, SystemInfo } from 'src/app/services/beatnik-system.service'; import { CamillaDspService } from 'src/app/services/camilla-dsp.service'; import { SnapcastService } from 'src/app/services/snapcast.service'; import { SoundcardPickerComponent } from '../soundcard-picker/soundcard-picker.component'; -import { AlertController, ModalController } from '@ionic/angular'; +import { AlertController, ModalController, ToastController } from '@ionic/angular'; import { ChooseSpeakersComponent } from '../choose-speakers/choose-speakers.component'; @Component({ @@ -25,11 +25,12 @@ export class ClientInfoComponent implements OnInit { serverState?: Observable; segment: 'details' | 'soundcard' | 'camilla-dsp' | 'settings' = 'camilla-dsp'; hardwareStatus$: Observable; + systemInfo$?: Observable; hats = Object.values(SUPPORTED_HATS); camillaDspUrl: string = ''; snapcastServerStatus?: SnapcastActionResponse; - - + + isLoading: { [key: string]: boolean } = {}; constructor( @@ -39,13 +40,15 @@ export class ClientInfoComponent implements OnInit { private beatnikSnapcastService: BeatnikSnapcastService, private beatnikSystemService: BeatnikSystemService, private camillaService: CamillaDspService, - private alertController: AlertController + private alertController: AlertController, + private toastController: ToastController ) { } async ngOnInit() { this.serverState = this.snapcastService.state$; this.camillaDspUrl = await this.getCamillaDspUrl(); this.getHardwareInfo(); + this.getSystemInfo(); this.refreshSnapcastStatus(); } @@ -209,18 +212,33 @@ export class ClientInfoComponent implements OnInit { await alert.present(); } + async presentToast(message: string, color: 'success' | 'danger' | 'warning' | 'primary' = 'success') { + const toast = await this.toastController.create({ + message, + duration: 2000, + color, + position: 'bottom' + }); + toast.present(); + } + async disableSnapcastServer() { if (!this.client) { console.error('Client Info Component: No client available to disable Snapcast server'); return; } + this.isLoading['disableSnapcast'] = true; const localHostName = this.client.host.name + '.local'; try { const response = await firstValueFrom(this.beatnikSnapcastService.disable(localHostName)); console.log(`Client Info Component: Disabled Snapcast server for client ${this.client.id}:`, response); this.snapcastServerStatus = response; + this.presentToast('Snapcast Server Disabled', 'success'); } catch (error) { console.error(`Client Info Component: Failed to disable Snapcast server for client ${this.client.id}`, error); + this.presentToast('Failed to Disable Snapcast', 'danger'); + } finally { + this.isLoading['disableSnapcast'] = false; } } @@ -229,13 +247,18 @@ export class ClientInfoComponent implements OnInit { console.error('Client Info Component: No client available to enable Snapcast server'); return; } + this.isLoading['enableSnapcast'] = true; const localHostName = this.client.host.name + '.local'; try { const response = await firstValueFrom(this.beatnikSnapcastService.enable(localHostName)); console.log(`Client Info Component: Enabled Snapcast server for client ${this.client.id}:`, response); this.snapcastServerStatus = response; + this.presentToast('Snapcast Server Enabled', 'success'); } catch (error) { console.error(`Client Info Component: Failed to enable Snapcast server for client ${this.client.id}`, error); + this.presentToast('Failed to Enable Snapcast', 'danger'); + } finally { + this.isLoading['enableSnapcast'] = false; } } @@ -244,13 +267,18 @@ export class ClientInfoComponent implements OnInit { console.error('Client Info Component: No client available to refresh Snapcast status'); return; } + this.isLoading['refreshSnapcast'] = true; const localHostName = this.client.host.name + '.local'; try { const status = await firstValueFrom(this.beatnikSnapcastService.getStatus(localHostName)); console.log(`Client Info Component: Snapcast status for client ${this.client.id}:`, status); this.snapcastServerStatus = { ...status, success: true, message: 'Status retrieved successfully' }; + // this.presentToast('Snapcast Status Refreshed', 'success'); } catch (error) { console.error(`Client Info Component: Failed to get Snapcast status for client ${this.client.id}`, error); + this.presentToast('Failed to Refresh Snapcast Status', 'danger'); + } finally { + this.isLoading['refreshSnapcast'] = false; } } @@ -259,13 +287,18 @@ export class ClientInfoComponent implements OnInit { console.error('Client Info Component: No client available to reboot'); return; } + this.isLoading['reboot'] = true; const localHostName = this.client.host.name + '.local'; this.beatnikHardwareService.reboot(localHostName).subscribe({ next: () => { console.log(`Client Info Component: Successfully triggered reboot for client ${this.client?.id}`); + this.isLoading['reboot'] = false; + this.presentToast('Device Reboot Initiated', 'success'); }, error: (err) => { console.error(`Client Info Component: Failed to trigger reboot for client ${this.client?.id}`, err); + this.isLoading['reboot'] = false; + this.presentToast('Failed to Reboot Device', 'danger'); } }); } @@ -290,48 +323,21 @@ export class ClientInfoComponent implements OnInit { } } - async testGetSystemInfo() { - if (!this.client) return; - const localHostName = await this.getUrl(); - this.beatnikSystemService.getInfo(localHostName).subscribe({ - next: (info) => console.log('System Info:', info), - error: (err) => console.error('Failed to get system info:', err) - }); - } - - async testLedSetColor() { - if (!this.client) return; - const localHostName = await this.getUrl(); - this.beatnikSystemService.setLedState({ command: 'set_color', params: { r: 1, g: 0, b: 0 } }, localHostName).subscribe({ - next: (res) => console.log('LED Set Color:', res), - error: (err) => console.error('Failed to set LED color:', err) - }); - } - - async testLedPulse() { - if (!this.client) return; - const localHostName = await this.getUrl(); - this.beatnikSystemService.setLedState({ command: 'pulse', params: { on_color: [0, 1, 0], off_color: [0, 0, 1], fade_in: 1, fade_out: 1 } }, localHostName).subscribe({ - next: (res) => console.log('LED Pulse:', res), - error: (err) => console.error('Failed to pulse LED:', err) - }); - } - - async testLedBlink() { - if (!this.client) return; - const localHostName = await this.getUrl(); - this.beatnikSystemService.setLedState({ command: 'blink', params: { color: [1, 1, 0], on_time: 0.5, off_time: 0.5 } }, localHostName).subscribe({ - next: (res) => console.log('LED Blink:', res), - error: (err) => console.error('Failed to blink LED:', err) - }); - } - - async testLedOff() { + async getSystemInfo() { if (!this.client) return; + this.isLoading['systemInfo'] = true; const localHostName = await this.getUrl(); - this.beatnikSystemService.setLedState({ command: 'off' }, localHostName).subscribe({ - next: (res) => console.log('LED Off:', res), - error: (err) => console.error('Failed to turn off LED:', err) + this.systemInfo$ = this.beatnikSystemService.getInfo(localHostName); + this.systemInfo$.subscribe({ + next: (info) => { + console.log('System Info:', info); + this.isLoading['systemInfo'] = false; + }, + error: (err) => { + console.error('Failed to get system info:', err); + this.isLoading['systemInfo'] = false; + this.presentToast('Failed to retrieve System Info', 'danger'); + } }); } } diff --git a/src/app/components/player-toolbar/player-toolbar.component.html b/src/app/components/player-toolbar/player-toolbar.component.html index 9b20002..9ceba7a 100644 --- a/src/app/components/player-toolbar/player-toolbar.component.html +++ b/src/app/components/player-toolbar/player-toolbar.component.html @@ -2,7 +2,8 @@ - + + + Placeholder Image +
+ + +
+
+ +

{{stream.uri.query.name}}

+

Unknown Title

+

Unknown Artist

+ +
+
+ + Placeholder Image +
+ + +
@@ -66,7 +76,10 @@

-

No active stream

+ +

{{activeStream.uri.query.name}}

+

No stream data available

+

\ No newline at end of file diff --git a/src/app/components/snapcast-group-preview/snapcast-group-preview.component.scss b/src/app/components/snapcast-group-preview/snapcast-group-preview.component.scss index 5711fcc..7fac036 100644 --- a/src/app/components/snapcast-group-preview/snapcast-group-preview.component.scss +++ b/src/app/components/snapcast-group-preview/snapcast-group-preview.component.scss @@ -1,50 +1,88 @@ -.card{ - box-shadow: none; +.card { + box-shadow: none; + border-radius: 0; + margin-top: 16px; + .cover { + // background-image: url('/assets/mock/2-freewheelin-bob-dylan.webp'); + // background-size: 80%, 80%; + // background-position: top right; + // background-repeat: no-repeat; + width: 90%; + border-radius: 0px; + box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1); + position: absolute; + top: 0; + right: 5%; + z-index: 1; + opacity: 0.7; + // background-color: var(--ion-color-dark); + } + + .img-container { + position: relative; + margin-top: 40%; + margin-left: 15%; + width: 70%; + height: 70%; border-radius: 0; - margin-top: 16px; - .cover{ - // background-image: url('/assets/mock/2-freewheelin-bob-dylan.webp'); - // background-size: 80%, 80%; - // background-position: top right; - // background-repeat: no-repeat; - width: 90%; - border-radius: 0px; - box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1); - position: absolute; - top: 0; - right: 5%; - z-index: 1; - opacity: 0.7; - background-color: var(--ion-color-dark); - } + z-index: 2; + display: flex; + justify-content: center; + align-items: end; + // background-color: rgba(255, 255, 255, 0.05); + // backdrop-filter: blur(1px); + // border-radius: 50%; + } + + .img { + width: 90%; + height: 90%; - .img-container{ - position: relative; - margin-top: 40%; - margin-left: 15%; - width: 70%; - height: 70%; - border-radius: 0; - z-index: 2; - display: flex; - justify-content: center; - align-items: end; - // background-color: rgba(255, 255, 255, 0.05); - // backdrop-filter: blur(1px); - // border-radius: 50%; + // add filter + // filter: brightness(1.4); + } +} - } - +ion-card { + box-shadow: none; +} - .img{ - width: 90%; - height: 90%; - - // add filter - // filter: brightness(1.4); - } +.no-data-label { + font-size: 12px; + color: var(--ion-color-medium); + margin-top: 4px; } -ion-card{ - box-shadow: none; -} \ No newline at end of file +.hof-bar-container { + // width: 200px; + // height: 200px; + // display: flex; + // justify-content: center; + // align-items: center; + width: 90%; + border-radius: 0px; +// box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1); + position: absolute; + top: -30px; +// right: 5%; + z-index: 1; + opacity: 0.8; + height: auto; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + aspect-ratio: 1 / 1; +} +.hof-bar-logo { + // margin-top: 20%; +// width: 90%; +// border-radius: 0px; +// box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1); +// position: absolute; +// top: 0; +// right: 5%; +// z-index: 1; +// opacity: 0.7; +// height: auto; +} diff --git a/src/app/components/snapcast-status/snapcast-status.component.html b/src/app/components/snapcast-status/snapcast-status.component.html index c6798cb..bff3846 100644 --- a/src/app/components/snapcast-status/snapcast-status.component.html +++ b/src/app/components/snapcast-status/snapcast-status.component.html @@ -60,6 +60,10 @@

Streams

+ + Reboot Server + Reboot All Clients + Reboot Server and All Clients
diff --git a/src/app/components/snapcast-status/snapcast-status.component.ts b/src/app/components/snapcast-status/snapcast-status.component.ts index 37f3b76..bdcc734 100644 --- a/src/app/components/snapcast-status/snapcast-status.component.ts +++ b/src/app/components/snapcast-status/snapcast-status.component.ts @@ -3,6 +3,10 @@ import { Observable, Subscription, EMPTY, firstValueFrom } from 'rxjs'; import { catchError, first, tap } from 'rxjs/operators'; import { SnapcastService } from 'src/app/services/snapcast.service'; // Adjust path import { Group, Client, Stream, ServerDetail, SnapCastServerStatusResponse } from 'src/app/model/snapcast.model'; // Adjust path +import { BeatnikHardwareService } from 'src/app/services/beatnik-hardware.service'; +import { UserPreference } from 'src/app/enum/user-preference.enum'; +import { Preferences } from '@capacitor/preferences'; +import { AlertController, ToastController } from '@ionic/angular'; @Component({ selector: 'app-snapcast-status', @@ -12,13 +16,19 @@ import { Group, Client, Stream, ServerDetail, SnapCastServerStatusResponse } fro standalone: false // If using standalone components, set to true }) export class SnapcastStatusComponent implements OnInit, OnDestroy { - + displayState?: Observable; + isLoading: { [key: string]: boolean } = {}; + serverUrl: string = ''; private subscriptions = new Subscription(); - constructor(public snapcastService: SnapcastService) { - + constructor( + public snapcastService: SnapcastService, + private beatnikHardwareService: BeatnikHardwareService, + private toastController: ToastController, + private alertController: AlertController) { + } ngOnInit() { @@ -28,7 +38,7 @@ export class SnapcastStatusComponent implements OnInit, OnDestroy { getClientStats(state: SnapCastServerStatusResponse): { total: number, online: number, offline: number } { if (!state?.server?.groups) return { total: 0, online: 0, offline: 0 }; - + let total = 0; let online = 0; @@ -42,7 +52,7 @@ export class SnapcastStatusComponent implements OnInit, OnDestroy { return { total, online, offline: total - online }; } - + onSetClientVolumePercent(clientId: string, event: Event): void { const inputElement = event.target as HTMLInputElement; @@ -62,8 +72,8 @@ export class SnapcastStatusComponent implements OnInit, OnDestroy { // Clean offline clients and empty groups if needed. Maybe not the best place to do this, but it works for now. async cleanOfflineClients(): Promise { const state = await firstValueFrom(this.displayState!); - if(!state) return; - + if (!state) return; + const offlineClientIds: string[] = []; state.server.groups.forEach(group => { @@ -84,6 +94,176 @@ export class SnapcastStatusComponent implements OnInit, OnDestroy { } } + async rebootServerAndClients(): Promise { + await this.rebootServer(); + // wait 5 seconds before rebooting clients to give the server time to come back online + await new Promise(resolve => setTimeout(resolve, 5000)); + await this.rebootClients(); + } + + // async formatIpAddress(ip: string): Promise { + // // Rebooted client ::ffff:192.168.1.114 + // if (ip.startsWith("::ffff:")) { + // return ip.substring(7); + // } + // return ip; + // } + + async rebootDevice(ip: string): Promise { + + this.isLoading['reboot'] = true; + this.beatnikHardwareService.reboot(ip).subscribe({ + next: () => { + console.log(`Client Info Component: Successfully triggered reboot for client ${ip}`); + this.isLoading['reboot'] = false; + // this.presentToast('Device Reboot Initiated', 'success'); + }, + error: (err) => { + console.error(`Client Info Component: Failed to trigger reboot for client ${ip}`, err); + this.isLoading['reboot'] = false; + this.presentToast('Failed to Reboot Device', 'danger'); + } + }); + } + + presentToast(message: string, color: 'success' | 'danger'): void { + // Implement toast notification logic here, e.g., using Ionic's ToastController + console.log(`Toast: ${message} (color: ${color})`); + this.toastController.create({ + message, + color, + duration: 2000 + }).then(toast => toast.present()); + } + + async getUrl(ip: string): Promise { + + var ipAddress = this.cleanIpAddress(ip); + console.log('Original IP address:', ip); + // if ip adress is 127.0.0.1 or localhost, it's the client running on the server so we get the server ip from user preferences + if (ipAddress === '127.0.0.1' || ipAddress === 'localhost') { + await Preferences.get({ key: UserPreference.SERVER_URL }).then((result) => { + ipAddress = result.value; + }); + } + return ipAddress; + } + + async rebootServer(): Promise { + const state = await firstValueFrom(this.displayState!); + if (!state) return; + + // const serverIp = await this.formatIpAddress(state.server.server.host.ip); + + // get server ip from user preferences and reboot using hardware service + const serverIp = await Preferences.get({ key: UserPreference.SERVER_URL }).then((result) => { + return result.value; + }); + + + console.log(`Rebooting server and ${serverIp}...`); + try { + await this.rebootDevice(serverIp); + console.log(`Rebooted server ${serverIp}`); + this.presentToast('Reboot Initiated for Server', 'success'); + } catch (err) { + console.error('Failed to reboot server and clients', err); + this.presentToast('Failed to Reboot Server', 'danger'); + } + } + + async rebootClients(): Promise { + const state = await firstValueFrom(this.displayState!); + if (!state) return; + + const clientIps = state.server.groups.flatMap(group => group.clients?.map(client => client.host.ip) || []); + for (const ip of clientIps) { + const formattedIp = await this.getUrl(ip); + + + try { + // if ip adress is server ip from user preferences, skip rebooting since it will be handled in rebootServer + const serverIp = await Preferences.get({ key: UserPreference.SERVER_URL }).then(result => result.value); + if (formattedIp === serverIp) { + console.log(`Skipping reboot for client ${formattedIp} since it matches the server IP`); + continue; + } + + await this.rebootDevice(formattedIp); + console.log(`Rebooted client ${formattedIp}`); + } catch (err) { + console.error(`Failed to reboot client ${formattedIp}`, err); + } + } + this.presentToast('Reboot Initiated for All Clients', 'success'); + } + + + cleanIpAddress(ip: string): string { + return ip.replace('::ffff:', ''); + } + + async showRebootServerAndClientsAlert(): Promise { + const alert = await this.alertController.create({ + header: 'Confirm Reboot', + message: 'Are you sure you want to reboot the server and all clients? This will cause temporary disruption of audio playback.', + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + { + text: 'Reboot', + handler: () => { + this.rebootServerAndClients(); + } + } + ] + }); + + await alert.present(); + } + + async showRebootClientsAlert(): Promise { + const alert = await this.alertController.create({ + header: 'Confirm Reboot', + message: 'Are you sure you want to reboot all clients? This will cause temporary disruption of audio playback on all clients.', + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + { + text: 'Reboot', + handler: () => { + this.rebootClients(); + } + } + ] + }); + + await alert.present(); + } + + showRebootServerAlert(): void { + this.alertController.create({ + header: 'Confirm Reboot', + message: 'Are you sure you want to reboot the server? This will cause temporary disruption of audio playback.', + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + { + text: 'Reboot', + handler: () => { + this.rebootServer(); + } + } + ] + }).then(alert => alert.present()); + } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } diff --git a/src/app/enum/user-preference.enum.ts b/src/app/enum/user-preference.enum.ts index 48d7551..2352c5b 100644 --- a/src/app/enum/user-preference.enum.ts +++ b/src/app/enum/user-preference.enum.ts @@ -4,4 +4,5 @@ export enum UserPreference { USERNAME = 'username', SERVER_URL = 'serverUrl', VOLUME_PRESETS = 'volumePresets', + STREAM_PRESETS = 'streamPresets', } \ No newline at end of file diff --git a/src/app/model/stream-presets.model.ts b/src/app/model/stream-presets.model.ts new file mode 100644 index 0000000..08414ae --- /dev/null +++ b/src/app/model/stream-presets.model.ts @@ -0,0 +1,12 @@ +export interface StreamPresetData { + groupId: string; + groupName: string; + streamId: string; +} + +export interface StreamPreset { + id: string; + presetName: string; + presetDescription?: string; + data: StreamPresetData[]; +} \ No newline at end of file diff --git a/src/app/model/volume-presets.model.ts b/src/app/model/volume-presets.model.ts index 4410388..392ecaa 100644 --- a/src/app/model/volume-presets.model.ts +++ b/src/app/model/volume-presets.model.ts @@ -6,6 +6,7 @@ export interface VolumePresetData { } export interface VolumePreset { + id: string; presetName: string; presetDescription?: string; data: VolumePresetData[]; diff --git a/src/app/pages/dashboard/dashboard.page.html b/src/app/pages/dashboard/dashboard.page.html index aeb01d3..0527920 100644 --- a/src/app/pages/dashboard/dashboard.page.html +++ b/src/app/pages/dashboard/dashboard.page.html @@ -40,7 +40,7 @@ date: 'dd.MM.yy HH:mm:ss' }}
- +
{{numberOfPlayingStreams +"/"+ totalStreams}} Stream(s) playing
diff --git a/src/app/pages/streams/stream-details/stream-details.page.html b/src/app/pages/streams/stream-details/stream-details.page.html index f446323..b38f70b 100644 --- a/src/app/pages/streams/stream-details/stream-details.page.html +++ b/src/app/pages/streams/stream-details/stream-details.page.html @@ -17,11 +17,65 @@ + + + + + + + + + Mopidy Player + {{ data.track }} + Ready to Play + + + +

Select a stream or press play to start listening.

+ +
+ + + + + + + + + + + + + + + +
+
+
+ + + + Hof-Bar Radio + + + + + Play Hofbar Radio + + + +
+ -

{{ stream.id }}

+

{{ stream.id }} (JSON)

{{ stream.uri.query.devicename }}

@@ -30,11 +84,10 @@

{{ stream.id }}

- -
+ @@ -46,5 +99,4 @@

{{ stream.id }}

- \ No newline at end of file diff --git a/src/app/pages/streams/stream-details/stream-details.page.ts b/src/app/pages/streams/stream-details/stream-details.page.ts index 83692d3..803ec71 100644 --- a/src/app/pages/streams/stream-details/stream-details.page.ts +++ b/src/app/pages/streams/stream-details/stream-details.page.ts @@ -5,6 +5,7 @@ import { Observable } from 'rxjs'; import { UserPreference } from 'src/app/enum/user-preference.enum'; import { SnapCastServerStatusResponse, Stream } from 'src/app/model/snapcast.model'; import { SnapcastService } from 'src/app/services/snapcast.service'; +import { MopidyService } from 'src/app/services/mopidy.service'; @Component({ selector: 'app-stream-details', @@ -18,16 +19,16 @@ export class StreamDetailsPage implements OnInit { serverState?: Observable; stream?: Stream; - streamCamillaDSPPort: number = 1235; + streamCamillaDSPPort: number = 1235; serverUrl: string = ''; - + currentTrack$ = this.mopidy.currentTrack$; constructor( private activatedRoute: ActivatedRoute, - private snapcastService: SnapcastService - + private snapcastService: SnapcastService, + public mopidy: MopidyService ) { } ngOnInit( @@ -61,7 +62,7 @@ export class StreamDetailsPage implements OnInit { // get serverUrl from UserPreferences and append camillaDSP port async getCamillaDspUrl(): Promise { - let url: string; + let url: string; await Preferences.get({ key: UserPreference.SERVER_URL }).then((result) => { url = result.value || ''; }); @@ -71,4 +72,13 @@ export class StreamDetailsPage implements OnInit { return websocket; } + + async playHofBarRadio() { + if (!this.stream) { + console.error('StreamDetailsPage: No stream available to play'); + return; + } + this.mopidy.playHofbarStream(); + } + } diff --git a/src/app/pages/streams/stream-preset-edit/stream-preset-edit-routing.module.ts b/src/app/pages/streams/stream-preset-edit/stream-preset-edit-routing.module.ts new file mode 100644 index 0000000..2c46673 --- /dev/null +++ b/src/app/pages/streams/stream-preset-edit/stream-preset-edit-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { StreamPresetEditPage } from './stream-preset-edit.page'; + +const routes: Routes = [ + { + path: '', + component: StreamPresetEditPage + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class StreamPresetEditPageRoutingModule {} diff --git a/src/app/pages/streams/stream-preset-edit/stream-preset-edit.module.ts b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.module.ts new file mode 100644 index 0000000..46ce50b --- /dev/null +++ b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { IonicModule } from '@ionic/angular'; + +import { StreamPresetEditPageRoutingModule } from './stream-preset-edit-routing.module'; + +import { StreamPresetEditPage } from './stream-preset-edit.page'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + StreamPresetEditPageRoutingModule + ], + declarations: [StreamPresetEditPage] +}) +export class StreamPresetEditPageModule {} diff --git a/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.html b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.html new file mode 100644 index 0000000..50153fe --- /dev/null +++ b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.html @@ -0,0 +1,49 @@ + + + + + + {{ isEditMode ? 'Edit Preset' : 'Create Preset' }} + + + + + + + {{ isEditMode ? 'Edit Preset' : 'Create Preset' }} + + + + + + {{caputredPreset.presetName}} + Unsaved Preset + + + + +
+ +

{{ preset.groupName }}

+

{{preset.groupId}}

+
+
+ + + + {{ stream.id }} + + +
+
+
+
+
+ + + Save Preset + + + {{ isEditMode ? 'Re-Capture Current Setup' : 'Capture Preset' }} + +
diff --git a/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.scss b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.spec.ts b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.spec.ts new file mode 100644 index 0000000..f56b5f1 --- /dev/null +++ b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.spec.ts @@ -0,0 +1,17 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StreamPresetEditPage } from './stream-preset-edit.page'; + +describe('StreamPresetEditPage', () => { + let component: StreamPresetEditPage; + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(StreamPresetEditPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.ts b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.ts new file mode 100644 index 0000000..7a17261 --- /dev/null +++ b/src/app/pages/streams/stream-preset-edit/stream-preset-edit.page.ts @@ -0,0 +1,126 @@ +import { Component, OnInit } from '@angular/core'; +import { AlertController } from '@ionic/angular'; +import { Router } from '@angular/router'; +import { StreamPresetsService } from '../../../services/stream-presets.service'; +import { StreamPreset } from '../../../model/stream-presets.model'; +import { SnapcastService } from '../../../services/snapcast.service'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'app-stream-preset-edit', + templateUrl: './stream-preset-edit.page.html', + styleUrls: ['./stream-preset-edit.page.scss'], + standalone: false +}) +export class StreamPresetEditPage implements OnInit { + caputredPreset: StreamPreset = { id: crypto.randomUUID(), presetName: '', data: [] }; + isEditMode: boolean = false; + originalPresetName: string = ''; + availableStreams: any[] = []; + + constructor( + private streamPresetsService: StreamPresetsService, + private snapcastService: SnapcastService, + private alertController: AlertController, + private router: Router + ) { + const navigation = this.router.getCurrentNavigation(); + if (navigation?.extras?.state?.['preset']) { + const preset = navigation.extras.state['preset']; + // Deep copy so we don't accidentally mutate the store directly until saved + this.caputredPreset = JSON.parse(JSON.stringify(preset)); + this.isEditMode = true; + this.originalPresetName = this.caputredPreset.presetName; + } + } + + async ngOnInit() { + await this.loadAvailableStreams(); + if (!this.isEditMode) { + await this.capturePreset(); + } + } + + async loadAvailableStreams() { + const currentState = await firstValueFrom(this.snapcastService.getServerStatus()); + if (currentState && currentState.server) { + this.availableStreams = currentState.server.streams; + } + } + + async capturePreset() { + const preset = await this.streamPresetsService.capturePreset(); + if (preset) { + console.log('Captured Stream Preset:', preset); + // Keep existing name/description if re-capturing in edit mode + this.caputredPreset = { + ...this.caputredPreset, + data: preset.data + }; + } + } + + async savePresetInUserPreferences() { + if (!this.isEditMode) { + const name = await this.promtNameAlert(); + if (!name) { + console.warn('StreamPresetEditPage: Preset name is required to save'); + return; + } + this.caputredPreset.presetName = name.presetName; + this.caputredPreset.presetDescription = name.presetDescription || ''; + } else { + // In edit mode we can also allow renaming, or keep it simple + const confirmState = await this.promtNameAlert(this.caputredPreset.presetName, this.caputredPreset.presetDescription); + if (!confirmState) { + return; + } + this.caputredPreset.presetName = confirmState.presetName; + this.caputredPreset.presetDescription = confirmState.presetDescription || ''; + } + + try { + await this.streamPresetsService.savePreset(this.caputredPreset); + console.log('Stream preset saved to user preferences'); + this.router.navigate(['/stream-presets'], { replaceUrl: true }); + } catch (error) { + console.error('StreamPresetEditPage: Failed to save preset', error); + } + } + + async promtNameAlert(defaultName: string = '', defaultDescription: string = ''): Promise<{presetName: string, presetDescription?: string} | null> { + const alert = await this.alertController.create({ + header: 'Save Stream Preset', + inputs: [ + { + name: 'presetName', + type: 'text', + placeholder: 'Enter preset name', + value: defaultName + }, + { + name: 'presetDescription', + type: 'text', + placeholder: 'Enter preset description (optional)', + value: defaultDescription + } + ], + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + { + text: 'Save', + handler: (data: any) => { + return { presetName: data.presetName, presetDescription: data.presetDescription }; + } + } + ] + }); + + await alert.present(); + const { data } = await alert.onDidDismiss(); + return data?.values || null; + } +} diff --git a/src/app/pages/streams/stream-presets/stream-presets-routing.module.ts b/src/app/pages/streams/stream-presets/stream-presets-routing.module.ts new file mode 100644 index 0000000..3a97c9c --- /dev/null +++ b/src/app/pages/streams/stream-presets/stream-presets-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { StreamPresetsPage } from './stream-presets.page'; + +const routes: Routes = [ + { + path: '', + component: StreamPresetsPage + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class StreamPresetsPageRoutingModule {} diff --git a/src/app/pages/streams/stream-presets/stream-presets.module.ts b/src/app/pages/streams/stream-presets/stream-presets.module.ts new file mode 100644 index 0000000..063fcee --- /dev/null +++ b/src/app/pages/streams/stream-presets/stream-presets.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { IonicModule } from '@ionic/angular'; + +import { StreamPresetsPageRoutingModule } from './stream-presets-routing.module'; + +import { StreamPresetsPage } from './stream-presets.page'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + StreamPresetsPageRoutingModule + ], + declarations: [StreamPresetsPage] +}) +export class StreamPresetsPageModule {} diff --git a/src/app/pages/streams/stream-presets/stream-presets.page.html b/src/app/pages/streams/stream-presets/stream-presets.page.html new file mode 100644 index 0000000..23b1873 --- /dev/null +++ b/src/app/pages/streams/stream-presets/stream-presets.page.html @@ -0,0 +1,87 @@ + + + + + + Stream Presets + + + + + + + Stream Presets + + + + +
+ +

No Presets Yet

+

Create a stream preset to quickly recall your assigned streams.

+
+ + + +
+ + Apply your saved streams across all groups with a single tap. Swipe left to edit or delete a preset. + +
+ + + Saved Presets + + + + + + + + +

{{preset.presetName}}

+

{{preset.presetDescription}}

+
+
+ + + + + + + + + + + + +
+
+
+ + + + + Quick Assign All + + + + + + +

{{preset.presetName}}

+

{{preset.presetDescription}}

+
+
+
+
+
+ + + + + + Create / Capture Preset + + + diff --git a/src/app/pages/streams/stream-presets/stream-presets.page.scss b/src/app/pages/streams/stream-presets/stream-presets.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/streams/stream-presets/stream-presets.page.spec.ts b/src/app/pages/streams/stream-presets/stream-presets.page.spec.ts new file mode 100644 index 0000000..8ca2391 --- /dev/null +++ b/src/app/pages/streams/stream-presets/stream-presets.page.spec.ts @@ -0,0 +1,17 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StreamPresetsPage } from './stream-presets.page'; + +describe('StreamPresetsPage', () => { + let component: StreamPresetsPage; + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(StreamPresetsPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/streams/stream-presets/stream-presets.page.ts b/src/app/pages/streams/stream-presets/stream-presets.page.ts new file mode 100644 index 0000000..b3ac3e8 --- /dev/null +++ b/src/app/pages/streams/stream-presets/stream-presets.page.ts @@ -0,0 +1,108 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { ToastController } from '@ionic/angular'; +import { Haptics, ImpactStyle } from '@capacitor/haptics'; +import { StreamPresetsService } from '../../../services/stream-presets.service'; +import { StreamPreset } from '../../../model/stream-presets.model'; +import { SnapcastService } from '../../../services/snapcast.service'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'app-stream-presets', + templateUrl: './stream-presets.page.html', + styleUrls: ['./stream-presets.page.scss'], + standalone: false +}) +export class StreamPresetsPage implements OnInit { + + existingPresets: StreamPreset[] = []; + defaultPresets: StreamPreset[] = []; + + constructor( + private streamPresetsService: StreamPresetsService, + private snapcastService: SnapcastService, + private router: Router, + private toastController: ToastController, + ) { } + + ngOnInit() { + } + + async ionViewWillEnter() { + await this.loadPresetFromUserPreferences(); + await this.generateDefaultPresets(); + } + + async loadPresetFromUserPreferences() { + this.existingPresets = await this.streamPresetsService.loadPresetsFromPreferences(); + console.log('StreamPresetsPage: Retrieved stream presets:', this.existingPresets); + } + + async generateDefaultPresets() { + try { + const currentState = await firstValueFrom(this.snapcastService.getServerStatus()); + if (currentState && currentState.server) { + const streams = currentState.server.streams; + const groups = currentState.server.groups; + + this.defaultPresets = streams.map(stream => { + const presetData = groups.map(group => ({ + groupId: group.id, + groupName: group.name || '', + streamId: stream.id + })); + + return { + id: `default-${stream.id}`, + presetName: `All to ${stream.id}`, + presetDescription: `Assigns ${stream.id} to all groups`, + data: presetData + }; + }); + } + } catch (error) { + console.error('Failed to generate default presets:', error); + } + } + + async applyPreset(preset: StreamPreset) { + try { + await this.streamPresetsService.applyPreset(preset); + + await Haptics.impact({ style: ImpactStyle.Light }); + + const toast = await this.toastController.create({ + message: `Preset "${preset.presetName}" applied`, + duration: 2000, + position: 'bottom', + color: 'success', + icon: 'checkmark-circle-outline' + }); + await toast.present(); + } catch (error) { + console.error('Failed to apply preset', error); + + const errorToast = await this.toastController.create({ + message: `Failed to apply preset "${preset.presetName}"`, + duration: 3000, + position: 'bottom', + color: 'danger', + icon: 'alert-circle-outline' + }); + await errorToast.present(); + } + } + + editPreset(preset: StreamPreset) { + this.router.navigate(['/stream-preset-edit'], { state: { preset } }); + } + + async deletePreset(preset: StreamPreset) { + try { + this.existingPresets = await this.streamPresetsService.deletePreset(preset); + console.log(`Stream preset "${preset.presetName}" deleted`); + } catch (error) { + console.error(`StreamPresetsPage: Failed to delete preset "${preset.presetName}"`, error); + } + } +} diff --git a/src/app/pages/volume-preset-edit/volume-preset-edit.page.ts b/src/app/pages/volume-preset-edit/volume-preset-edit.page.ts index d9eedc4..a3da4e6 100644 --- a/src/app/pages/volume-preset-edit/volume-preset-edit.page.ts +++ b/src/app/pages/volume-preset-edit/volume-preset-edit.page.ts @@ -11,7 +11,7 @@ import { VolumePreset } from '../../model/volume-presets.model'; standalone: false }) export class VolumePresetEditPage implements OnInit { - caputredPreset: VolumePreset = { presetName: '', data: [] }; + caputredPreset: VolumePreset = { id: crypto.randomUUID(), presetName: '', data: [] }; isEditMode: boolean = false; originalPresetName: string = ''; @@ -65,11 +65,6 @@ export class VolumePresetEditPage implements OnInit { } this.caputredPreset.presetName = confirmState.presetName; this.caputredPreset.presetDescription = confirmState.presetDescription || ''; - - // If we renamed it, we should probably delete the old one first - if (this.originalPresetName !== this.caputredPreset.presetName) { - await this.volumePresetsService.deletePreset({ presetName: this.originalPresetName, data: [] }); - } } try { diff --git a/src/app/services/mopidy.service.ts b/src/app/services/mopidy.service.ts new file mode 100644 index 0000000..0eafebc --- /dev/null +++ b/src/app/services/mopidy.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { Preferences } from '@capacitor/preferences'; +import { UserPreference } from 'src/app/enum/user-preference.enum'; +import Mopidy from 'mopidy'; + +@Injectable({ + providedIn: 'root' +}) +export class MopidyService { + private mopidy: any; + private currentTrackSubject = new BehaviorSubject('Idle'); + public currentTrack$ = this.currentTrackSubject.asObservable(); + private hostName = 'beatnik-server.local'; + + constructor() { + this.initMopidy(); + } + + private async initMopidy() { + let url = ''; + await Preferences.get({ key: UserPreference.SERVER_URL }).then((result) => { + url = result.value || 'beatnik-server.local'; + }); + + // remove http:// or https:// from url if present + const cleanUrl = url.replace(/(^\w+:|^)\/\//, ''); + + this.mopidy = new Mopidy({ + webSocketUrl: `ws://${cleanUrl}:6680/mopidy/ws/` + }); + + this.mopidy.on('state:online', () => this.fetchCurrentTrack()); + + this.mopidy.on('event:trackPlaybackStarted', (event: any) => { + this.currentTrackSubject.next(event.tl_track.track.name); + }); + } + + private fetchCurrentTrack() { + this.mopidy.playback.getCurrentTrack().then((track: any) => { + this.currentTrackSubject.next(track ? track.name : 'Idle'); + }); + } + + // --- Controls --- + public play() { this.mopidy.playback.play(); } + public pause() { this.mopidy.playback.pause(); } + public stop() { this.mopidy.playback.stop(); } + public next() { this.mopidy.playback.next(); } + public previous() { this.mopidy.playback.previous(); } + + public playHofbarStream() { + this.mopidy.tracklist.clear().then(() => { + this.mopidy.tracklist.add({ uris: ['https://hofbar.beatnik.ch/stream'] }).then(() => { + this.mopidy.playback.play(); + }); + }); + } +} diff --git a/src/app/services/stream-presets.service.spec.ts b/src/app/services/stream-presets.service.spec.ts new file mode 100644 index 0000000..66ee021 --- /dev/null +++ b/src/app/services/stream-presets.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { StreamPresetsService } from './stream-presets.service'; + +describe('StreamPresetsService', () => { + let service: StreamPresetsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(StreamPresetsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/stream-presets.service.ts b/src/app/services/stream-presets.service.ts new file mode 100644 index 0000000..efabebf --- /dev/null +++ b/src/app/services/stream-presets.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { SnapcastService } from './snapcast.service'; +import { Preferences } from '@capacitor/preferences'; +import { UserPreference } from '../enum/user-preference.enum'; +import { StreamPreset } from '../model/stream-presets.model'; + +@Injectable({ + providedIn: 'root' +}) +export class StreamPresetsService { + + constructor(private snapcastService: SnapcastService) { } + + async capturePreset(): Promise { + const currentState = await firstValueFrom(this.snapcastService.getServerStatus()); + if (!currentState || !currentState.server) { + console.error('StreamPresetsService: Failed to capture preset - invalid server state', currentState); + return null; + } + + const presetData = currentState.server.groups.map((group: any) => ({ + groupId: group.id, + groupName: group.name || '', + streamId: group.stream_id + })); + + return { + id: crypto.randomUUID(), + presetName: 'Stream Preset ' + new Date().toISOString().replace(/[:.]/g, '-'), + data: presetData + }; + } + + async loadPresetsFromPreferences(): Promise { + try { + const result = await Preferences.get({ key: UserPreference.STREAM_PRESETS }); + if (result.value) { + let presets = JSON.parse(result.value); + let presetArray = Array.isArray(presets) ? presets : [presets]; + let needsMigration = false; + + let migratedArray = presetArray.map((p: any) => { + if (!p.id) { + p.id = crypto.randomUUID(); + needsMigration = true; + } + return p as StreamPreset; + }); + + if (needsMigration) { + await Preferences.set({ + key: UserPreference.STREAM_PRESETS, + value: JSON.stringify(migratedArray) + }); + } + + return migratedArray; + } + } catch (error) { + console.error('StreamPresetsService: Failed to load preset from user preferences', error); + } + return []; + } + + async savePreset(newPreset: StreamPreset): Promise { + const existingPresets = await this.loadPresetsFromPreferences(); + const existingIndex = existingPresets.findIndex((p: StreamPreset) => p.id === newPreset.id); + + if (existingIndex >= 0) { + existingPresets[existingIndex] = newPreset; + } else { + existingPresets.push(newPreset); + } + + try { + await Preferences.set({ + key: UserPreference.STREAM_PRESETS, + value: JSON.stringify(existingPresets) + }); + return existingPresets; + } catch (error) { + console.error('StreamPresetsService: Failed to save preset to user preferences', error); + throw error; + } + } + + async applyPreset(preset: StreamPreset): Promise { + for (const group of preset.data) { + try { + await firstValueFrom(this.snapcastService.setGroupStream(group.groupId, group.streamId)); + console.log(`Applied stream ${group.streamId} to group ${group.groupId}`); + } catch (error) { + console.error(`StreamPresetsService: Failed to apply stream for group ${group.groupId}`, error); + } + } + } + + async deletePreset(preset: StreamPreset): Promise { + let existingPresets = await this.loadPresetsFromPreferences(); + existingPresets = existingPresets.filter((p: StreamPreset) => p.id !== preset.id); + try { + await Preferences.set({ + key: UserPreference.STREAM_PRESETS, + value: JSON.stringify(existingPresets) + }); + return existingPresets; + } catch (error) { + console.error(`StreamPresetsService: Failed to delete preset "${preset.presetName}" from user preferences`, error); + throw error; + } + } +} + diff --git a/src/app/services/volume-presets.service.ts b/src/app/services/volume-presets.service.ts index e90dddc..eb88f76 100644 --- a/src/app/services/volume-presets.service.ts +++ b/src/app/services/volume-presets.service.ts @@ -24,15 +24,33 @@ export class VolumePresetsService { groupId: currentState.server.groups.find((group: any) => group.clients?.some((c: any) => c.id === client.id))?.id || '', groupName: currentState.server.groups.find((group: any) => group.clients?.some((c: any) => c.id === client.id))?.name || '', })); - return { presetName: 'Preset_' + new Date().toISOString().replace(/[:.]/g, '-'), data: preset }; + return { id: crypto.randomUUID(), presetName: 'Preset_' + new Date().toISOString().replace(/[:.]/g, '-'), data: preset }; } async loadPresetsFromPreferences(): Promise { try { const result = await Preferences.get({ key: UserPreference.VOLUME_PRESETS }); if (result.value) { - const preset = JSON.parse(result.value); - return Array.isArray(preset) ? preset : [preset]; + let presets = JSON.parse(result.value); + let presetArray = Array.isArray(presets) ? presets : [presets]; + let needsMigration = false; + + let migratedArray = presetArray.map((p: any) => { + if (!p.id) { + p.id = crypto.randomUUID(); + needsMigration = true; + } + return p as VolumePreset; + }); + + if (needsMigration) { + await Preferences.set({ + key: UserPreference.VOLUME_PRESETS, + value: JSON.stringify(migratedArray) + }); + } + + return migratedArray; } } catch (error) { console.error('VolumePresetsService: Failed to load preset from user preferences', error); @@ -42,7 +60,14 @@ export class VolumePresetsService { async savePreset(newPreset: VolumePreset): Promise { const existingPresets = await this.loadPresetsFromPreferences(); - existingPresets.push(newPreset); + const existingIndex = existingPresets.findIndex((p: VolumePreset) => p.id === newPreset.id); + + if (existingIndex >= 0) { + existingPresets[existingIndex] = newPreset; + } else { + existingPresets.push(newPreset); + } + try { await Preferences.set({ key: UserPreference.VOLUME_PRESETS, @@ -68,7 +93,7 @@ export class VolumePresetsService { async deletePreset(preset: VolumePreset): Promise { let existingPresets = await this.loadPresetsFromPreferences(); - existingPresets = existingPresets.filter((p: VolumePreset) => p.presetName !== preset.presetName); + existingPresets = existingPresets.filter((p: VolumePreset) => p.id !== preset.id); try { await Preferences.set({ key: UserPreference.VOLUME_PRESETS,