diff --git a/package-lock.json b/package-lock.json
index d2b887e..ca3b1d3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "beatnik",
- "version": "0.5.2",
+ "version": "0.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beatnik",
- "version": "0.5.2",
+ "version": "0.5.3",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",
diff --git a/package.json b/package.json
index 8e72299..4b01258 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "beatnik",
- "version": "0.5.2",
+ "version": "0.5.3",
"author": "byrds & bytes gmbh",
"homepage": "https://beatnik.audio",
"scripts": {
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index 7e3afb1..5d8eb95 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -90,6 +90,14 @@ const routes: Routes = [
path: 'setup-device-group-name/:ip',
loadChildren: () => import('./pages/setup/setup-device-group-name/setup-device-group-name.module').then( m => m.SetupDeviceGroupNamePageModule)
},
+ {
+ path: 'volume-presets',
+ loadChildren: () => import('./pages/volume-presets/volume-presets.module').then( m => m.VolumePresetsPageModule)
+ },
+ {
+ path: 'volume-preset-edit',
+ loadChildren: () => import('./pages/volume-preset-edit/volume-preset-edit.module').then( m => m.VolumePresetEditPageModule)
+ },
];
diff --git a/src/app/components/camilla-dsp/camilla-dsp.component.html b/src/app/components/camilla-dsp/camilla-dsp.component.html
index 69f32b3..f007a81 100644
--- a/src/app/components/camilla-dsp/camilla-dsp.component.html
+++ b/src/app/components/camilla-dsp/camilla-dsp.component.html
@@ -228,7 +228,33 @@
Playback
+
+
+
+
+
+
+ Processors
+
+
+
+
+
+
+ {{ processor.key }}
+
+
+ test
+
+ {{ param.key }}:
+
+
+
+
+
@@ -255,12 +281,12 @@ Playback
Get Capture Signal Levels
Set update Interval
Get Volume -->
-
+
diff --git a/src/app/components/camilla-dsp/camilla-dsp.component.ts b/src/app/components/camilla-dsp/camilla-dsp.component.ts
index e2311c5..da463b7 100644
--- a/src/app/components/camilla-dsp/camilla-dsp.component.ts
+++ b/src/app/components/camilla-dsp/camilla-dsp.component.ts
@@ -275,5 +275,28 @@ export class CamillaDspComponent implements OnInit, OnDestroy {
this.ngOnDestroy();
}
+ updateProcessorParameter(processorKey: string, paramKey: string, newValue: any) {
+ if (!this.parsedConfig) {
+ console.error('No configuration loaded.');
+ return;
+ }
+
+ const processor = this.parsedConfig.processors?.[processorKey];
+ if (!processor) {
+ console.error(`Processor with key ${processorKey} not found.`);
+ return;
+ }
+
+ // Update the parameter locally
+ (processor.parameters as any)[paramKey] = newValue;
+
+ // format the conffigJson to send to CamillaDSP
+ console.log('Updated processor parameter:', processorKey, paramKey, newValue);
+ console.log('Updated configuration to send:', this.parsedConfig);
+
+ // send the full configJson back to CamillaDSP
+ this.camillaService.sendCommand('SetConfigJson', JSON.stringify(this.parsedConfig));
+ }
+
}
diff --git a/src/app/components/client-info/client-info.component.html b/src/app/components/client-info/client-info.component.html
index c1c7c39..88edfa3 100644
--- a/src/app/components/client-info/client-info.component.html
+++ b/src/app/components/client-info/client-info.component.html
@@ -145,6 +145,26 @@ Status: Inactive
Choose Speakers
+
+
+ Hardware API Test
+
+
+
+ Get System Info (Console)
+
+
+ LED: Solid Red
+
+
+ LED: Pulse Green/Blue
+
+
+ LED: Blink Yellow
+
+
+ LED: Turn Off
+
diff --git a/src/app/components/client-info/client-info.component.ts b/src/app/components/client-info/client-info.component.ts
index a29b171..1c2570f 100644
--- a/src/app/components/client-info/client-info.component.ts
+++ b/src/app/components/client-info/client-info.component.ts
@@ -6,6 +6,7 @@ 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 { CamillaDspService } from 'src/app/services/camilla-dsp.service';
import { SnapcastService } from 'src/app/services/snapcast.service';
import { SoundcardPickerComponent } from '../soundcard-picker/soundcard-picker.component';
@@ -36,6 +37,7 @@ export class ClientInfoComponent implements OnInit {
private modalController: ModalController,
private beatnikHardwareService: BeatnikHardwareService,
private beatnikSnapcastService: BeatnikSnapcastService,
+ private beatnikSystemService: BeatnikSystemService,
private camillaService: CamillaDspService,
private alertController: AlertController
) { }
@@ -287,6 +289,51 @@ export class ClientInfoComponent implements OnInit {
console.log('Client Info Component: Speaker selection cancelled or no selection made');
}
}
+
+ 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() {
+ if (!this.client) return;
+ 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)
+ });
+ }
}
diff --git a/src/app/components/player-toolbar/player-toolbar.component.html b/src/app/components/player-toolbar/player-toolbar.component.html
index e9b007c..9b20002 100644
--- a/src/app/components/player-toolbar/player-toolbar.component.html
+++ b/src/app/components/player-toolbar/player-toolbar.component.html
@@ -1,7 +1,7 @@
-
+
{{ stream.properties.metadata.title }}
-->
-
+
@@ -35,10 +35,10 @@ {{ stream.properties.metadata.title }}
Clients: {{ group.clients.length }}
-
+
+ [ngModel]="client.config.volume.percent" (ionKnobMoveStart)="knobMoveStartEvent(client.id, $event)"
+ (ionKnobMoveEnd)="knobMoveEndEvent(client.id, $event)" (ionInput)="changeVolumeForClient(client, $event)">
diff --git a/src/app/components/player-toolbar/player-toolbar.component.ts b/src/app/components/player-toolbar/player-toolbar.component.ts
index f858e02..2ebf3f8 100644
--- a/src/app/components/player-toolbar/player-toolbar.component.ts
+++ b/src/app/components/player-toolbar/player-toolbar.component.ts
@@ -1,6 +1,6 @@
// player-toolbar.component.ts
import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core'; // OnChanges und SimpleChanges hinzugefügt
-import { Observable, Subscription, tap, firstValueFrom } from 'rxjs'; // firstValueFrom hinzugefügt
+import { Observable, Subscription, tap, firstValueFrom, map } from 'rxjs'; // firstValueFrom hinzugefügt
import { Group, Stream, ServerDetail, Client, SnapCastServerStatusResponse } from 'src/app/model/snapcast.model'; // Client importiert für Typisierung
import { SnapcastService } from 'src/app/services/snapcast.service';
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
@@ -21,8 +21,10 @@ export class PlayerToolbarComponent implements OnInit, OnChanges, OnDestroy {
private subscriptions = new Subscription();
// Make sure it's this device that changes the volume. If multiple devices are connected, we want to avoid conflicts.
- private knobMoveStart = false;
- private knobMoveEnd = false;
+ private draggingClients = new Map();
+ private lastDragEndTimes = new Map();
+ private optimisticVolumes = new Map();
+ private readonly VOLUME_COOLDOWN_MS = 5000;
@@ -35,7 +37,35 @@ export class PlayerToolbarComponent implements OnInit, OnChanges, OnDestroy {
}
ngOnInit(): void {
- this.displayState$ = this.snapcastService.state$
+ this.displayState$ = this.snapcastService.state$.pipe(
+ map(state => {
+ if (!state) return state;
+
+ // Deep clone to avoid mutating the service's state
+ const modifiedState = JSON.parse(JSON.stringify(state));
+
+ modifiedState.server.groups.forEach((group: Group) => {
+ group.clients.forEach((client: Client) => {
+ const isDragging = this.draggingClients.get(client.id);
+ const lastDragEnd = this.lastDragEndTimes.get(client.id) || 0;
+ const inCooldown = (Date.now() - lastDragEnd) < this.VOLUME_COOLDOWN_MS;
+
+ if (isDragging || inCooldown) {
+ // Retain optimistic volume to prevent jumping backward
+ const optVol = this.optimisticVolumes.get(client.id);
+ if (optVol !== undefined) {
+ client.config.volume.percent = optVol;
+ }
+ } else {
+ // Store the definitive server truth if we are out of cooldown
+ this.optimisticVolumes.set(client.id, client.config.volume.percent);
+ }
+ });
+ });
+
+ return modifiedState;
+ })
+ );
this.snapcastService.connect();
}
@@ -49,8 +79,8 @@ export class PlayerToolbarComponent implements OnInit, OnChanges, OnDestroy {
* @param event The event emitted from the range slider (e.g., ionChange).
*/
changeVolumeForClient(client: Client, event: any): void {
- if (!this.knobMoveStart) {
- console.warn('PlayerToolbarComponent: changeVolumeForClient called without knobMoveStart. Ignoring event:', event);
+ if (!this.draggingClients.get(client.id)) {
+ console.warn(`PlayerToolbarComponent: changeVolumeForClient called without knobMoveStart for ${client.id}. Ignoring event.`, event);
return;
}
// Step 1: Robustly extract the numerical value from the event.
@@ -70,6 +100,9 @@ export class PlayerToolbarComponent implements OnInit, OnChanges, OnDestroy {
return;
}
+ // Immediately track the optimistic volume so the local view freezes on the current slider value
+ this.optimisticVolumes.set(client.id, newVolume);
+
console.log(`PlayerToolbarComponent: Setting desired volume for client ${client.id} to ${newVolume}`);
// Step 2: Call the service method.
@@ -136,16 +169,15 @@ export class PlayerToolbarComponent implements OnInit, OnChanges, OnDestroy {
return this.coverDateService.convertCoverDataBase64(coverData, extension);
}
- knobMoveStartEvent(event: any): void {
- console.log('Knob move started:', event);
- this.knobMoveStart = true;
- this.knobMoveEnd = false;
+ knobMoveStartEvent(clientId: string, event: any): void {
+ console.log(`Knob move started for client ${clientId}:`, event);
+ this.draggingClients.set(clientId, true);
}
- knobMoveEndEvent(event: any): void {
- console.log('Knob move ended:', event);
- this.knobMoveEnd = true;
- this.knobMoveStart = false;
+ knobMoveEndEvent(clientId: string, event: any): void {
+ console.log(`Knob move ended for client ${clientId}:`, event);
+ this.draggingClients.set(clientId, false);
+ this.lastDragEndTimes.set(clientId, Date.now());
// Optionally, you can add haptic feedback here
}
@@ -153,8 +185,16 @@ export class PlayerToolbarComponent implements OnInit, OnChanges, OnDestroy {
this.coverDateService.onCoverImageError(event);
}
+ trackByStream(index: number, stream: Stream): string {
+ return stream.id;
+ }
+ trackByGroup(index: number, group: Group): string {
+ return group.id;
+ }
-
+ trackByClient(index: number, client: Client): string {
+ return client.id;
+ }
}
\ No newline at end of file
diff --git a/src/app/enum/user-preference.enum.ts b/src/app/enum/user-preference.enum.ts
index 576111c..48d7551 100644
--- a/src/app/enum/user-preference.enum.ts
+++ b/src/app/enum/user-preference.enum.ts
@@ -3,4 +3,5 @@
export enum UserPreference {
USERNAME = 'username',
SERVER_URL = 'serverUrl',
+ VOLUME_PRESETS = 'volumePresets',
}
\ No newline at end of file
diff --git a/src/app/model/camilla-dsp.model.ts b/src/app/model/camilla-dsp.model.ts
index 6c3a65e..709ece1 100644
--- a/src/app/model/camilla-dsp.model.ts
+++ b/src/app/model/camilla-dsp.model.ts
@@ -3,8 +3,8 @@ export interface CamillaDspConfig {
description: string | null;
devices: Devices;
mixers: { [key: string]: Mixer };
- filters: { [key: string]: Filter };
- processors: any;
+ filters: { [key: string]: Filter } | null;
+ processors: { [key: string]: Processor } | null;
pipeline: Pipeline[];
}
@@ -53,10 +53,30 @@ export interface BiquadParameters {
gain: number;
}
+export interface Processor {
+ type: string;
+ description: string | null;
+ parameters: CompressorParameters;
+}
+
+export interface CompressorParameters {
+ channels: number;
+ monitor_channels: number | null;
+ process_channels: number | null;
+ attack: number;
+ release: number;
+ threshold: number;
+ factor: number;
+ makeup_gain: number;
+ soft_clip: boolean | null;
+ clip_limit: number | null;
+}
+
export interface Pipeline {
type: string;
- channel: number;
- names: string[];
+ channel?: number;
+ name?: string;
+ names?: string[];
description: string | null;
bypassed: boolean | null;
}
diff --git a/src/app/model/volume-presets.model.ts b/src/app/model/volume-presets.model.ts
new file mode 100644
index 0000000..4410388
--- /dev/null
+++ b/src/app/model/volume-presets.model.ts
@@ -0,0 +1,12 @@
+export interface VolumePresetData {
+ clientId: string;
+ volumePercent: number;
+ groupId: string;
+ groupName: string;
+}
+
+export interface VolumePreset {
+ 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 ff745ab..aeb01d3 100644
--- a/src/app/pages/dashboard/dashboard.page.html
+++ b/src/app/pages/dashboard/dashboard.page.html
@@ -27,7 +27,7 @@
-
+
0?'volume-high':'volume-high-outline'">
{{numberOfPlayingClients +"/"+ totalClients}} Client(s) playing
diff --git a/src/app/pages/streams/stream-details/stream-details.module.ts b/src/app/pages/streams/stream-details/stream-details.module.ts
index 0fced96..bbcba69 100644
--- a/src/app/pages/streams/stream-details/stream-details.module.ts
+++ b/src/app/pages/streams/stream-details/stream-details.module.ts
@@ -7,13 +7,15 @@ import { IonicModule } from '@ionic/angular';
import { StreamDetailsPageRoutingModule } from './stream-details-routing.module';
import { StreamDetailsPage } from './stream-details.page';
+import { CamillaDspModule } from 'src/app/components/camilla-dsp/camilla-dsp.module';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
- StreamDetailsPageRoutingModule
+ StreamDetailsPageRoutingModule,
+ CamillaDspModule
],
declarations: [StreamDetailsPage]
})
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 5a1b9c8..f446323 100644
--- a/src/app/pages/streams/stream-details/stream-details.page.html
+++ b/src/app/pages/streams/stream-details/stream-details.page.html
@@ -30,6 +30,8 @@ {{ stream.id }}
+
+
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 f10422a..83692d3 100644
--- a/src/app/pages/streams/stream-details/stream-details.page.ts
+++ b/src/app/pages/streams/stream-details/stream-details.page.ts
@@ -1,6 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
+import { Preferences } from '@capacitor/preferences';
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';
@@ -16,6 +18,11 @@ export class StreamDetailsPage implements OnInit {
serverState?: Observable
;
stream?: Stream;
+ streamCamillaDSPPort: number = 1235;
+ serverUrl: string = '';
+
+
+
constructor(
private activatedRoute: ActivatedRoute,
@@ -33,6 +40,7 @@ export class StreamDetailsPage implements OnInit {
console.log('StreamDetailsPage: ID from route parameters:', this.streamId);
this.serverState = this.snapcastService.state$;
this.subscribeToStream(this.streamId);
+ this.getCamillaDspUrl();
}
subscribeToStream(streamId: string): void {
@@ -51,4 +59,16 @@ export class StreamDetailsPage implements OnInit {
});
}
+ // get serverUrl from UserPreferences and append camillaDSP port
+ async getCamillaDspUrl(): Promise {
+ let url: string;
+ await Preferences.get({ key: UserPreference.SERVER_URL }).then((result) => {
+ url = result.value || '';
+ });
+ const camillaPort = this.streamCamillaDSPPort || 1235; // Default port if not set
+ const websocket = "ws://" + url?.replace(/(^\w+:|^)\/\//, '') + `:${camillaPort}`;
+ this.serverUrl = websocket;
+ return websocket;
+ }
+
}
diff --git a/src/app/pages/volume-preset-edit/volume-preset-edit-routing.module.ts b/src/app/pages/volume-preset-edit/volume-preset-edit-routing.module.ts
new file mode 100644
index 0000000..bda7e60
--- /dev/null
+++ b/src/app/pages/volume-preset-edit/volume-preset-edit-routing.module.ts
@@ -0,0 +1,17 @@
+import { NgModule } from '@angular/core';
+import { Routes, RouterModule } from '@angular/router';
+
+import { VolumePresetEditPage } from './volume-preset-edit.page';
+
+const routes: Routes = [
+ {
+ path: '',
+ component: VolumePresetEditPage
+ }
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule],
+})
+export class VolumePresetEditPageRoutingModule {}
diff --git a/src/app/pages/volume-preset-edit/volume-preset-edit.module.ts b/src/app/pages/volume-preset-edit/volume-preset-edit.module.ts
new file mode 100644
index 0000000..c76fbaa
--- /dev/null
+++ b/src/app/pages/volume-preset-edit/volume-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 { VolumePresetEditPageRoutingModule } from './volume-preset-edit-routing.module';
+
+import { VolumePresetEditPage } from './volume-preset-edit.page';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ FormsModule,
+ IonicModule,
+ VolumePresetEditPageRoutingModule
+ ],
+ declarations: [VolumePresetEditPage]
+})
+export class VolumePresetEditPageModule {}
diff --git a/src/app/pages/volume-preset-edit/volume-preset-edit.page.html b/src/app/pages/volume-preset-edit/volume-preset-edit.page.html
new file mode 100644
index 0000000..f55bec1
--- /dev/null
+++ b/src/app/pages/volume-preset-edit/volume-preset-edit.page.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+ {{ isEditMode ? 'Edit Preset' : 'Create Preset' }}
+
+
+
+
+
+
+ {{ isEditMode ? 'Edit Preset' : 'Create Preset' }}
+
+
+
+
+
+ {{caputredPreset.presetName}}
+ Unsaved Preset
+
+
+
+
+
+
+ {{ preset.groupName }}
+ {{preset.clientId}}
+
+
+
+
+
+ {{preset.volumePercent}}%
+
+
+
+
+
+
+
+ Save Preset
+
+
+ {{ isEditMode ? 'Re-Capture Current Setup' : 'Capture Preset' }}
+
+
diff --git a/src/app/pages/volume-preset-edit/volume-preset-edit.page.scss b/src/app/pages/volume-preset-edit/volume-preset-edit.page.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/pages/volume-preset-edit/volume-preset-edit.page.spec.ts b/src/app/pages/volume-preset-edit/volume-preset-edit.page.spec.ts
new file mode 100644
index 0000000..a9fea5c
--- /dev/null
+++ b/src/app/pages/volume-preset-edit/volume-preset-edit.page.spec.ts
@@ -0,0 +1,17 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { VolumePresetEditPage } from './volume-preset-edit.page';
+
+describe('VolumePresetEditPage', () => {
+ let component: VolumePresetEditPage;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(VolumePresetEditPage);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 0000000..d9eedc4
--- /dev/null
+++ b/src/app/pages/volume-preset-edit/volume-preset-edit.page.ts
@@ -0,0 +1,119 @@
+import { Component, OnInit } from '@angular/core';
+import { AlertController } from '@ionic/angular';
+import { Router } from '@angular/router';
+import { VolumePresetsService } from '../../services/volume-presets.service';
+import { VolumePreset } from '../../model/volume-presets.model';
+
+@Component({
+ selector: 'app-volume-preset-edit',
+ templateUrl: './volume-preset-edit.page.html',
+ styleUrls: ['./volume-preset-edit.page.scss'],
+ standalone: false
+})
+export class VolumePresetEditPage implements OnInit {
+ caputredPreset: VolumePreset = { presetName: '', data: [] };
+ isEditMode: boolean = false;
+ originalPresetName: string = '';
+
+ constructor(
+ private volumePresetsService: VolumePresetsService,
+ 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() {
+ if (!this.isEditMode) {
+ await this.capturePreset();
+ }
+ }
+
+ async capturePreset() {
+ const preset = await this.volumePresetsService.capturePreset();
+ if (preset) {
+ console.log('Captured Volume 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('VolumePresetEditPage: 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 || '';
+
+ // 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 {
+ await this.volumePresetsService.savePreset(this.caputredPreset);
+ console.log('Volume preset saved to user preferences');
+ this.router.navigate(['/volume-presets'], { replaceUrl: true });
+ } catch (error) {
+ console.error('VolumePresetEditPage: 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 Volume 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/volume-presets/volume-presets-routing.module.ts b/src/app/pages/volume-presets/volume-presets-routing.module.ts
new file mode 100644
index 0000000..f1def7a
--- /dev/null
+++ b/src/app/pages/volume-presets/volume-presets-routing.module.ts
@@ -0,0 +1,17 @@
+import { NgModule } from '@angular/core';
+import { Routes, RouterModule } from '@angular/router';
+
+import { VolumePresetsPage } from './volume-presets.page';
+
+const routes: Routes = [
+ {
+ path: '',
+ component: VolumePresetsPage
+ }
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule],
+})
+export class VolumePresetsPageRoutingModule {}
diff --git a/src/app/pages/volume-presets/volume-presets.module.ts b/src/app/pages/volume-presets/volume-presets.module.ts
new file mode 100644
index 0000000..4c25e4b
--- /dev/null
+++ b/src/app/pages/volume-presets/volume-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 { VolumePresetsPageRoutingModule } from './volume-presets-routing.module';
+
+import { VolumePresetsPage } from './volume-presets.page';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ FormsModule,
+ IonicModule,
+ VolumePresetsPageRoutingModule
+ ],
+ declarations: [VolumePresetsPage]
+})
+export class VolumePresetsPageModule {}
diff --git a/src/app/pages/volume-presets/volume-presets.page.html b/src/app/pages/volume-presets/volume-presets.page.html
new file mode 100644
index 0000000..6cb701c
--- /dev/null
+++ b/src/app/pages/volume-presets/volume-presets.page.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ Volume Presets
+
+
+
+
+
+
+ Volume Presets
+
+
+
+
+
+
+
No Presets Yet
+
Create a volume preset to quickly recall your group levels.
+
+
+
+ 0">
+
+
+ Apply your saved volume levels across all groups with a single tap. Swipe left to edit or delete a preset.
+
+
+
+
+ Saved Presets
+
+
+
+
+
+
+
+
+ {{preset.presetName}}
+ {{preset.presetDescription}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create / Capture Preset
+
+
+
\ No newline at end of file
diff --git a/src/app/pages/volume-presets/volume-presets.page.scss b/src/app/pages/volume-presets/volume-presets.page.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/pages/volume-presets/volume-presets.page.spec.ts b/src/app/pages/volume-presets/volume-presets.page.spec.ts
new file mode 100644
index 0000000..0a03516
--- /dev/null
+++ b/src/app/pages/volume-presets/volume-presets.page.spec.ts
@@ -0,0 +1,17 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { VolumePresetsPage } from './volume-presets.page';
+
+describe('VolumePresetsPage', () => {
+ let component: VolumePresetsPage;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(VolumePresetsPage);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/pages/volume-presets/volume-presets.page.ts b/src/app/pages/volume-presets/volume-presets.page.ts
new file mode 100644
index 0000000..c6bee04
--- /dev/null
+++ b/src/app/pages/volume-presets/volume-presets.page.ts
@@ -0,0 +1,77 @@
+import { Component, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { ToastController } from '@ionic/angular';
+import { Haptics, ImpactStyle } from '@capacitor/haptics';
+import { VolumePresetsService } from '../../services/volume-presets.service';
+import { VolumePreset } from '../../model/volume-presets.model';
+
+@Component({
+ selector: 'app-volume-presets',
+ templateUrl: './volume-presets.page.html',
+ styleUrls: ['./volume-presets.page.scss'],
+ standalone: false
+})
+export class VolumePresetsPage implements OnInit {
+
+ existingPresets: VolumePreset[] = [];
+
+ constructor(
+ private volumePresetsService: VolumePresetsService,
+ private router: Router,
+ private toastController: ToastController,
+ ) { }
+
+ ngOnInit() {
+ }
+
+ async ionViewWillEnter() {
+ await this.loadPresetFromUserPreferences();
+ }
+
+ async loadPresetFromUserPreferences() {
+ this.existingPresets = await this.volumePresetsService.loadPresetsFromPreferences();
+ console.log('VolumePresetsPage: Retrieved volume presets:', this.existingPresets);
+ }
+
+ async applyPreset(preset: VolumePreset) {
+ try {
+ await this.volumePresetsService.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: VolumePreset) {
+ this.router.navigate(['/volume-preset-edit'], { state: { preset } });
+ }
+
+ async deletePreset(preset: VolumePreset) {
+ try {
+ this.existingPresets = await this.volumePresetsService.deletePreset(preset);
+ console.log(`Volume preset "${preset.presetName}" deleted`);
+ } catch (error) {
+ console.error(`VolumePresetsPage: Failed to delete preset "${preset.presetName}"`, error);
+ }
+ }
+
+}
diff --git a/src/app/pages/zeroconf/zeroconf.page.ts b/src/app/pages/zeroconf/zeroconf.page.ts
index bdf48cb..93f9041 100644
--- a/src/app/pages/zeroconf/zeroconf.page.ts
+++ b/src/app/pages/zeroconf/zeroconf.page.ts
@@ -33,11 +33,8 @@ export class ZeroconfPage implements OnDestroy {
async scanForServices(): Promise {
this.isScanning = true;
try {
-
- await this.zeroconf.watch(this.SERVICE_SNAPCAST);
- console.log(`Started scanning for services of type: ${this.SERVICE_SNAPCAST}`);
- await this.zeroconf.watch(this.SERVICE_BEATNIK);
- console.log(`Started scanning for services of type: ${this.SERVICE_BEATNIK}`);
+ await this.zeroconf.watchMultiple([this.SERVICE_SNAPCAST, this.SERVICE_BEATNIK]);
+ console.log(`Started scanning for services of types: ${this.SERVICE_SNAPCAST}, ${this.SERVICE_BEATNIK}`);
}
catch (error) {
console.error('Error starting service scan:', error);
diff --git a/src/app/services/beatnik-system.service.ts b/src/app/services/beatnik-system.service.ts
new file mode 100644
index 0000000..a05cddc
--- /dev/null
+++ b/src/app/services/beatnik-system.service.ts
@@ -0,0 +1,63 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+export interface SystemInfo {
+ hostname: string;
+ ipAddresses: string[];
+ totalRam: number;
+ freeRam: number;
+ temperature: number | null;
+}
+
+export type LedCommand =
+ | { command: 'set_color'; params: { r: number; g: number; b: number } }
+ | {
+ command: 'pulse';
+ params: {
+ on_color: [number, number, number];
+ off_color?: [number, number, number];
+ fade_in?: number;
+ fade_out?: number;
+ };
+ }
+ | { command: 'blink'; params: { color: [number, number, number]; on_time?: number; off_time?: number } }
+ | { command: 'off' };
+
+export interface GenericResponse {
+ message?: string;
+ error?: string;
+ success?: boolean;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class BeatnikSystemService {
+ constructor(private http: HttpClient) {}
+
+ private getApiUrl(host: string): string {
+ return `http://${host}:3000/api/system`;
+ }
+
+ /**
+ * Get current system information (hostname, IP, RAM, temp)
+ */
+ getInfo(host: string): Observable {
+ return this.http.get(`${this.getApiUrl(host)}/info`);
+ }
+
+ /**
+ * Send a command to the LED
+ */
+ setLedState(payload: LedCommand, host: string): Observable {
+ return this.http.post(`${this.getApiUrl(host)}/led`, payload);
+ }
+
+ /**
+ * Reboot the system via the system API
+ */
+ reboot(host: string): Observable {
+ return this.http.post(`${this.getApiUrl(host)}/reboot`, {});
+ }
+}
diff --git a/src/app/services/snapcast.service.ts b/src/app/services/snapcast.service.ts
index 18a3dc3..25614a6 100644
--- a/src/app/services/snapcast.service.ts
+++ b/src/app/services/snapcast.service.ts
@@ -423,6 +423,78 @@ export class SnapcastService implements OnDestroy {
return this.socket.request('Client.SetVolume', { id, volume });
}
+ /**
+ * Smoothly transitions the volume from the current level to a target percentage.
+ * This sends multiple requests incrementally spaced apart so the Server doesn't get flooded.
+ */
+ public smoothClientVolumeTransition(id: string, targetPercent: number, durationMs: number = 800): Observable {
+ return new Observable((subscriber) => {
+ if (targetPercent < 0 || targetPercent > 100) {
+ subscriber.error(new Error('Target volume must be 0-100'));
+ return () => {};
+ }
+
+ const client = this.findClientInState(id);
+ if (!client) {
+ subscriber.error(new Error(`Client ${id} not found locally`));
+ return () => {};
+ }
+
+ const startPercent = client.config.volume.percent;
+ const difference = targetPercent - startPercent;
+
+ // If we are already there, just complete.
+ if (difference === 0) {
+ subscriber.next();
+ subscriber.complete();
+ return () => {};
+ }
+
+ // Time per step to avoid flooding the websocket (e.g., 50ms)
+ const stepIntervalMs = 50;
+ const totalSteps = Math.max(1, Math.floor(durationMs / stepIntervalMs));
+ const stepPercent = difference / totalSteps;
+
+ let currentStep = 0;
+ let subscription: Subscription | null = null;
+ let timeoutId: any;
+
+ const performStep = () => {
+ currentStep++;
+
+ let nextPercent = startPercent + (stepPercent * currentStep);
+
+ // Snap to exactly the target level on the last step
+ if (currentStep >= totalSteps) {
+ nextPercent = targetPercent;
+ }
+
+ // Send the request
+ subscription = this.setClientVolumePercent(id, Math.round(nextPercent)).subscribe({
+ next: () => {
+ if (currentStep < totalSteps) {
+ // Schedule next step
+ timeoutId = setTimeout(performStep, stepIntervalMs);
+ } else {
+ subscriber.next();
+ subscriber.complete();
+ }
+ },
+ error: (err) => subscriber.error(err)
+ });
+ };
+
+ // Kick off the first step
+ performStep();
+
+ // Clean up if the user unsubscribes midway through
+ return () => {
+ if (timeoutId) clearTimeout(timeoutId);
+ if (subscription) subscription.unsubscribe();
+ };
+ });
+ }
+
public setClientName(id: string, name: string) {
return this.socket.request('Client.SetName', { id, name });
}
diff --git a/src/app/services/volume-presets.service.spec.ts b/src/app/services/volume-presets.service.spec.ts
new file mode 100644
index 0000000..23f9b43
--- /dev/null
+++ b/src/app/services/volume-presets.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { VolumePresetsService } from './volume-presets.service';
+
+describe('VolumePresetsService', () => {
+ let service: VolumePresetsService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(VolumePresetsService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/services/volume-presets.service.ts b/src/app/services/volume-presets.service.ts
new file mode 100644
index 0000000..e90dddc
--- /dev/null
+++ b/src/app/services/volume-presets.service.ts
@@ -0,0 +1,83 @@
+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 { VolumePreset } from '../model/volume-presets.model';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class VolumePresetsService {
+
+ constructor(private snapcastService: SnapcastService) { }
+
+ async capturePreset(): Promise {
+ const currentState = await firstValueFrom(this.snapcastService.getServerStatus());
+ if (!currentState || !currentState.server) {
+ console.error('VolumePresetsService: Failed to capture preset - invalid server state', currentState);
+ return null;
+ }
+ const preset = currentState.server.groups.flatMap((group: any) => group.clients).map((client: any) => ({
+ clientId: client.id,
+ volumePercent: client.config.volume.percent,
+ 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 };
+ }
+
+ 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];
+ }
+ } catch (error) {
+ console.error('VolumePresetsService: Failed to load preset from user preferences', error);
+ }
+ return [];
+ }
+
+ async savePreset(newPreset: VolumePreset): Promise {
+ const existingPresets = await this.loadPresetsFromPreferences();
+ existingPresets.push(newPreset);
+ try {
+ await Preferences.set({
+ key: UserPreference.VOLUME_PRESETS,
+ value: JSON.stringify(existingPresets)
+ });
+ return existingPresets;
+ } catch (error) {
+ console.error('VolumePresetsService: Failed to save preset to user preferences', error);
+ throw error;
+ }
+ }
+
+ async applyPreset(preset: VolumePreset): Promise {
+ for (const client of preset.data) {
+ try {
+ await this.snapcastService.smoothClientVolumeTransition(client.clientId, client.volumePercent).toPromise();
+ console.log(`Applied volume ${client.volumePercent}% to client ${client.clientId}`);
+ } catch (error) {
+ console.error(`VolumePresetsService: Failed to apply volume for client ${client.clientId}`, error);
+ }
+ }
+ }
+
+ async deletePreset(preset: VolumePreset): Promise {
+ let existingPresets = await this.loadPresetsFromPreferences();
+ existingPresets = existingPresets.filter((p: VolumePreset) => p.presetName !== preset.presetName);
+ try {
+ await Preferences.set({
+ key: UserPreference.VOLUME_PRESETS,
+ value: JSON.stringify(existingPresets)
+ });
+ return existingPresets;
+ } catch (error) {
+ console.error(`VolumePresetsService: Failed to delete preset "${preset.presetName}" from user preferences`, error);
+ throw error;
+ }
+ }
+}
diff --git a/src/app/services/zero-conf.service.spec.ts b/src/app/services/zero-conf.service.spec.ts
index 23c8255..a0dad6c 100644
--- a/src/app/services/zero-conf.service.spec.ts
+++ b/src/app/services/zero-conf.service.spec.ts
@@ -1,13 +1,13 @@
import { TestBed } from '@angular/core/testing';
-import { ZeroConfService } from './zero-conf.service';
+import { ZeroconfService } from './zero-conf.service';
-describe('ZeroConfService', () => {
- let service: ZeroConfService;
+describe('ZeroconfService', () => {
+ let service: ZeroconfService;
beforeEach(() => {
TestBed.configureTestingModule({});
- service = TestBed.inject(ZeroConfService);
+ service = TestBed.inject(ZeroconfService);
});
it('should be created', () => {
diff --git a/src/app/services/zero-conf.service.ts b/src/app/services/zero-conf.service.ts
index 93ac0c6..c859df0 100644
--- a/src/app/services/zero-conf.service.ts
+++ b/src/app/services/zero-conf.service.ts
@@ -11,7 +11,7 @@ import { distinctUntilChanged, scan } from 'rxjs/operators';
})
export class ZeroconfService implements OnDestroy {
private readonly servicesSubject = new BehaviorSubject([]);
-
+
// Expose the list of services as an observable
public readonly services$: Observable = this.servicesSubject.asObservable();
@@ -19,7 +19,8 @@ export class ZeroconfService implements OnDestroy {
// Listen for discovery events and update the services list
ZeroConf.addListener('discover', (result: any) => {
this.ngZone.run(() => {
- console.log('[ZeroConf] Discover event:', result);
+ // Force the object into a readable string for Capacitor's console
+ console.log('[ZeroConf] Discover event:', JSON.stringify(result, null, 2));
this.handleDiscoveryEvent(result);
});
});
@@ -28,7 +29,7 @@ export class ZeroconfService implements OnDestroy {
private handleDiscoveryEvent(result: ZeroConfWatchResult) {
const currentServices = this.servicesSubject.getValue();
const service = result.service;
-
+
switch (result.action) {
case 'added':
// The service has been discovered but not yet resolved.
@@ -62,6 +63,16 @@ export class ZeroconfService implements OnDestroy {
await ZeroConf.watch({ type, domain });
}
+ // Start watching for multiple service types
+ async watchMultiple(types: string[], domain = 'local.') {
+ this.servicesSubject.next([]);
+ const promises = types.map(type => {
+ console.log(`[ZeroConf] Watching for type: ${type}`);
+ return ZeroConf.watch({ type, domain });
+ });
+ await Promise.all(promises);
+ }
+
// Publish a new service
// async publish(service: { type: string; name: string; port: number; props?: { [key: string]: string; } }) {
// await ZeroConf.register(service);