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 @@
-
+
{{ stream.properties.metadata.title }}
+
+
+
+
+
+

+
+
+
+
+ {{stream.uri.query.name}}
+ Unknown Title
+ Unknown Artist
+
+
+
+
+
+
+

+
+
@@ -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' }}
-
+
0?'musical-note':'musical-note-outline'">
{{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.
+
+
+
+ 0">
+
+
+ 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}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0">
+
+ 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,