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 @@ - + --> - + @@ -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 @@
- +
{{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.

+
+ + + +
+ + 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);