diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 617c727..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - schedule: - - cron: '30 2 * * 1' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript-typescript' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index c0a0099..59d7a19 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,7 @@ testem.log # System files .DS_Store Thumbs.db + +# Platform directories +android/* ios/* -.vscode/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b0847c2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +## [0.2.1] - 2025-08-01 + +### Added +- Add changelog + +## [0.2.0] - 2025-07-23 + +### Added +- Add group, server and stream requests +- Add client mutation functions +- Connect to state in app component, add triggers for foreground and background events +- Handle mobile page lifecycles +- Add client name to selectors and list +- Add client detail page +- Add indicator fab logic +- Add first version of github workflows +- Add user preference hostname +- Key value store for user peferences, variable hostname, menu page, settings page +- Add preview screen +- Add basic readme +- Add device details page +- Add devices page +- Extend snapcast service with group functions +- Add haptic feedback +- Add capacitor and compile iOS POC +- Add font +- Add icons + +### Changed +- Update docker-compose.yml +- Improve readme +- Update readme with docker setup +- Standardize server naming +- Rename project +- Shift snapcast server url to env +- Improve change detection +- Decrease playertoolbar size +- Refactoring state management +- Handling states +- Setting up snapcast connection +- First hacky version of services and ui +- Tab routing +- Handle websocket notifications +- Improve realtiminess +- Clean up naming +- Routing +- Player toolbar formatting + +### Fixed +- Fix desktop slider issues +- Fix broken link +- Fix title +- Fix disappearing range +- Hacky fix for overlapping tabbar + +### Removed +- Remove double linting +- Remove old user preference service +- Remove deprecated service impor +- Remove old server stauts +- Remove old imports +- Clean up old service +- Remove auto generated assets, pages and components +- Removed over-engineered "desired" and "reported" state logic + +## [0.1.0] - 2025-07-14 + +### Added +- Add contributing and app compile infos +- Add docker installation guide +- Add docker setup +- Add favicon + +### Changed +- Update readme + +## [0.0.3] - 2025-07-13 + +### Added +- User preferences in dashboard, loading and error handling + +### Changed +- Clean up naming +- Routing + +### Fixed +- Prevent range jitter if multiple apps are connected + +## [0.0.2] - 2025-06-22 + +### Added +- Create LICENSE + +### Changed +- Rename project +- Shift snapcast server url to env + +### Removed +- Clean up old service +- Remove auto generated assets, pages and components + +### Changed +- Refactored state management +- Use base64 for art data +- Add android platform to gitignore configuration + +### Added +- Add mock server for testing +- Add proof-of-concept for speaker selection diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..c3de2b0 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,42 @@ +# Privacy Policy for Beatnik Controller + +**Last Updated:** August 1, 2025 + +This Privacy Policy describes how Beatnik Controller ("we," "us," or "our") handles your information when you use our web application and mobile applications (the "App"). + +## Introduction + +Your privacy is important to us. Beatnik Controller is designed to be a local remote control for your Snapcast server. Our policy is simple: we do not collect, store, or share any of your personal information. All data required for the app to function is stored locally on your device. + +## Information We Collect + +Beatnik Controller does **not** collect any personally identifiable information. The App handles the following data, which is stored exclusively on your device (e.g., in your browser's local storage or on your mobile device): + +* **Snapcast Server Address:** The hostname or IP address of your Snapcast server that you provide to connect the App to your server. +* **Application Settings:** User-specific settings such as client names, group configurations, and other preferences you set within the App. + +This information is necessary for the core functionality of the App and is never transmitted to us or any third party. + +## How We Use Your Information + +The data stored by the App is used solely to: + +* Establish and maintain a connection with your Snapcast server. +* Remember your settings and preferences for a better user experience. +* Allow the App to function as a remote control for your audio system. + +## Data Storage and Security + +All data entered into the App is stored locally on your device. We do not have access to this data. You are in full control of your information, and you can clear it at any time by clearing your browser's cache or the App's data on your mobile device. + +## Third-Party Services + +Beatnik Controller does not use any third-party services for analytics, advertising, or data collection. + +## Changes to This Privacy Policy + +We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. You are advised to review this Privacy Policy periodically for any changes. + +## Contact Us + +If you have any questions about this Privacy Policy, please open an issue on our [GitHub repository](https://github.com/byrdsandbytes/beatnik-controller/issues). diff --git a/TERMS_OF_SERVICE.md b/TERMS_OF_SERVICE.md new file mode 100644 index 0000000..28bb71e --- /dev/null +++ b/TERMS_OF_SERVICE.md @@ -0,0 +1,45 @@ +# Terms of Service for Beatnik Controller + +**Last Updated:** August 1, 2025 + +Please read these Terms of Service ("Terms", "Terms of Service") carefully before using the Beatnik Controller web application and mobile applications (the "Service") operated by the Beatnik Controller project contributors ("us", "we", or "our"). + +Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These Terms apply to all visitors, users, and others who access or use the Service. + +By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the terms, then you may not access the Service. + +## 1. License + +The Beatnik Controller application is free and open-source software licensed under the [AGPL v3 License](https://www.gnu.org/licenses/agpl-3.0.en.html). Your use of the software is subject to the terms of this license. + +## 2. Use of the Service + +You are responsible for your use of the Service and for any consequences thereof. The Service is a tool to control a [Snapcast](https://github.com/badaix/snapcast) server that you operate. You are responsible for the setup, configuration, and maintenance of your own Snapcast server. + +You agree not to use the Service for any purpose that is illegal or prohibited by these Terms. + +## 3. Disclaimer of Warranties + +The Service is provided on an "AS IS" and "AS AVAILABLE" basis. The Service is provided without warranties of any kind, whether express or implied, including, but not limited to, implied warranties of merchantability, fitness for a particular purpose, non-infringement, or course of performance. + +We do not warrant that the Service will function uninterrupted, secure, or available at any particular time or location; that any errors or defects will be corrected; or that the Service is free of viruses or other harmful components. + +## 4. Limitation of Liability + +In no event shall the project contributors, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from (i) your access to or use of or inability to access or use the Service; (ii) any conduct or content of any third party on the Service; (iii) any content obtained from the Service; and (iv) unauthorized access, use, or alteration of your transmissions or content, whether based on warranty, contract, tort (including negligence), or any other legal theory, whether or not we have been informed of the possibility of such damage, and even if a remedy set forth herein is found to have failed of its essential purpose. + +## 5. Governing Law + +These Terms shall be governed and construed in accordance with the laws of Switzerland, without regard to its conflict of law provisions. + +Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect. + +## 6. Changes + +We reserve the right, at our sole discretion, to modify or replace these Terms at any time. We will try to provide at least 30 days' notice prior to any new terms taking effect. What constitutes a material change will be determined at our sole discretion. + +By continuing to access or use our Service after those revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new terms, please stop using the Service. + +## 7. Contact Us + +If you have any questions about these Terms, please open an issue on our [GitHub repository](https://github.com/byrdsandbytes/beatnik-controller/issues). diff --git a/package-lock.json b/package-lock.json index dbb19b3..799dd79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beatnik", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beatnik", - "version": "0.1.0", + "version": "0.2.1", "dependencies": { "@angular/animations": "^19.0.0", "@angular/common": "^19.0.0", diff --git a/package.json b/package.json index e8011e5..f831572 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "beatnik", - "version": "0.1.0", + "version": "0.2.1", "author": "byrds & bytes gmbh", "homepage": "https://beatnik.ch", "scripts": { diff --git a/src/app/components/choose-speakers/choose-speakers.component.html b/src/app/components/choose-speakers/choose-speakers.component.html new file mode 100644 index 0000000..8c522fb --- /dev/null +++ b/src/app/components/choose-speakers/choose-speakers.component.html @@ -0,0 +1,32 @@ + + + + + Choose Speakers + + + + + + + + + +
+

Select the speakers you want to use:

+ +
+
+ {{ speaker.manufacturer }} {{ speaker.model }} + + +

{{ speaker.manufacturer }} {{ speaker.model }}

+
+
+
+ + + Save Selection + +
+
diff --git a/src/app/components/choose-speakers/choose-speakers.component.scss b/src/app/components/choose-speakers/choose-speakers.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/choose-speakers/choose-speakers.component.spec.ts b/src/app/components/choose-speakers/choose-speakers.component.spec.ts new file mode 100644 index 0000000..1e69cde --- /dev/null +++ b/src/app/components/choose-speakers/choose-speakers.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IonicModule } from '@ionic/angular'; + +import { ChooseSpeakersComponent } from './choose-speakers.component'; + +describe('ChooseSpeakersComponent', () => { + let component: ChooseSpeakersComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ ChooseSpeakersComponent ], + imports: [IonicModule.forRoot()] + }).compileComponents(); + + fixture = TestBed.createComponent(ChooseSpeakersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/choose-speakers/choose-speakers.component.ts b/src/app/components/choose-speakers/choose-speakers.component.ts new file mode 100644 index 0000000..97b94af --- /dev/null +++ b/src/app/components/choose-speakers/choose-speakers.component.ts @@ -0,0 +1,82 @@ +import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { Component, Input, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { IonicModule, ModalController } from '@ionic/angular'; +import { first, firstValueFrom } from 'rxjs'; +import { Speaker } from 'src/app/model/speaker.model'; +import { SnapcastService } from 'src/app/services/snapcast.service'; + +@Component({ + selector: 'app-choose-speakers', + templateUrl: './choose-speakers.component.html', + styleUrls: ['./choose-speakers.component.scss'], + // import ionic module here if needed + imports: [ + IonicModule, + FormsModule, + CommonModule + ], + standalone: true +}) +export class ChooseSpeakersComponent implements OnInit { + + @Input() clientId?: string; + + selectedId: string | undefined; + speakers: Speaker[] = []; + + constructor( + private modalController: ModalController, + private http: HttpClient, + private snapcastService: SnapcastService + ) { } + + ngOnInit() { + this.loadSpeakerJson(); + } + + closeModal() { + this.modalController.dismiss(null, null, 'choose-speakers-modal'); + } + + saveSelection() { + if (this.selectedId) { + console.log('Selected speaker ID:', this.selectedId); + // Here you would typically save the selection to the server or state + this.modalController.dismiss({ selectedId: this.selectedId }, 'save', 'choose-speakers-modal'); + } else { + console.error('No speaker selected'); + } + } + + async loadSpeakerJson() { + // get speaker data as promise + try { + const response = await firstValueFrom( + this.http.get<{ speakers: Speaker[] }>('assets/speakers/speakers-data.json') + ); + this.speakers = response.speakers; + console.log('Speakers loaded:', this.speakers); + return this.speakers; + } catch (error) { + console.error('Error loading speakers:', error); + return []; + } + } + + async selectSpeaker(speakerId: string) { + // set speaker id as client name + this.selectedId = speakerId; + console.log('Selected speaker:', this.selectedId); + this.snapcastService.setClientName(this.clientId, speakerId).subscribe({ + next: (response) => { + console.log(`Successfully set speaker for client ${this.clientId} to ${speakerId}`, response); + }, + error: (error) => { + console.error(`Failed to set speaker for client ${this.clientId}`, error); + } + }); + } + +} diff --git a/src/app/components/snapcast-group-preview/snapcast-group-preview.component.html b/src/app/components/snapcast-group-preview/snapcast-group-preview.component.html index 41aa0d3..7cd4da0 100644 --- a/src/app/components/snapcast-group-preview/snapcast-group-preview.component.html +++ b/src/app/components/snapcast-group-preview/snapcast-group-preview.component.html @@ -1,11 +1,17 @@
- Placeholder Image + + {{ activeSpeaker.manufacturer }} {{ activeSpeaker.model }} + + + Placeholder Image +
- + diff --git a/src/app/components/snapcast-group-preview/snapcast-group-preview.component.ts b/src/app/components/snapcast-group-preview/snapcast-group-preview.component.ts index 256f7e8..d8c5ba1 100644 --- a/src/app/components/snapcast-group-preview/snapcast-group-preview.component.ts +++ b/src/app/components/snapcast-group-preview/snapcast-group-preview.component.ts @@ -1,6 +1,7 @@ import { Component, Input, OnChanges, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Group, Stream } from 'src/app/model/snapcast.model'; +import { Speaker } from 'src/app/model/speaker.model'; @Component({ selector: 'app-snapcast-group-preview', @@ -12,8 +13,10 @@ export class SnapcastGroupPreviewComponent implements OnInit, OnChanges { @Input() group?: Group; @Input() streams?: Stream[] | null; + @Input() speakerData?: Speaker[] | null; activeStream?: Stream; + activeSpeaker?: Speaker; constructor( private router: Router @@ -21,6 +24,7 @@ export class SnapcastGroupPreviewComponent implements OnInit, OnChanges { ngOnInit() { this.getActiveStream(); + this.getActiveSpeaker(); } ngOnChanges() { @@ -68,4 +72,19 @@ export class SnapcastGroupPreviewComponent implements OnInit, OnChanges { return `data:image/${extension};base64,${coverData}`; } + getActiveSpeaker(): Speaker | undefined { + if (!this.group || !this.speakerData) { + return undefined; + } + // Hacky implementation of speaker selection + this.activeSpeaker = this.speakerData.find(speaker => speaker.id === this.group.clients[0].config.name); + if (!this.activeSpeaker) { + console.warn('No active speaker found for the group:', this.group.id); + return undefined; + } + console.log('Active speaker for group:', this.group.id, this.activeSpeaker); + + return this.activeSpeaker; + } + } diff --git a/src/app/components/snapcast-status/snapcast-status.component.ts b/src/app/components/snapcast-status/snapcast-status.component.ts index 807d296..60037c8 100644 --- a/src/app/components/snapcast-status/snapcast-status.component.ts +++ b/src/app/components/snapcast-status/snapcast-status.component.ts @@ -26,9 +26,7 @@ export class SnapcastStatusComponent implements OnInit, OnDestroy { } - getClientDetails(clientId: string): Observable { - return this.snapcastService.getClient(clientId); - } + onSetClientVolumePercent(clientId: string, event: Event): void { const inputElement = event.target as HTMLInputElement; @@ -44,31 +42,6 @@ export class SnapcastStatusComponent implements OnInit, OnDestroy { ).subscribe() ); } - - onToggleClientMute(client: Client): void { - // const newMuteState = !client.config.volume.muted; - // this.subscriptions.add( - // this.snapcastService.setClientMute(client.id, newMuteState).pipe( - // catchError(err => { - // console.error(`Component: Failed to toggle mute for ${client.id}`, err); - // return EMPTY; - // }) - // ).subscribe() - // ); - } - - onChangeGroupName(groupId: string, newName: string): void { - // if (!newName || !newName.trim()) return; - // this.subscriptions.add( - // this.snapcastService.setGroupName(groupId, newName.trim()).pipe( - // catchError(err => { - // console.error(`Component: Failed to change group name for ${groupId}`, err); - // return EMPTY; - // }) - // ).subscribe() - // ); - } - ngOnDestroy(): void { this.subscriptions.unsubscribe(); } diff --git a/src/app/model/speaker.model.ts b/src/app/model/speaker.model.ts new file mode 100644 index 0000000..e818671 --- /dev/null +++ b/src/app/model/speaker.model.ts @@ -0,0 +1,7 @@ +export interface Speaker { + id: string; + image: string; + manufacturer: string; + model: string; + year: number; +} \ No newline at end of file diff --git a/src/app/pages/clients/client-details/client-details.page.html b/src/app/pages/clients/client-details/client-details.page.html index 5671c61..cb26a39 100644 --- a/src/app/pages/clients/client-details/client-details.page.html +++ b/src/app/pages/clients/client-details/client-details.page.html @@ -57,6 +57,10 @@

Connection Status

+ + Choose Speakers + + diff --git a/src/app/pages/clients/client-details/client-details.page.ts b/src/app/pages/clients/client-details/client-details.page.ts index fa074ae..68eb8bf 100644 --- a/src/app/pages/clients/client-details/client-details.page.ts +++ b/src/app/pages/clients/client-details/client-details.page.ts @@ -1,6 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ModalController } from '@ionic/angular'; import { Observable } from 'rxjs'; +import { ChooseSpeakersComponent } from 'src/app/components/choose-speakers/choose-speakers.component'; import { Client, SnapCastServerStatusResponse } from 'src/app/model/snapcast.model'; import { SnapcastService } from 'src/app/services/snapcast.service'; @@ -21,7 +23,8 @@ export class ClientDetailsPage implements OnInit { constructor( private avtivateRoute: ActivatedRoute, - private snapcastService: SnapcastService + private snapcastService: SnapcastService, + private modalController: ModalController ) { } async ngOnInit() { @@ -112,6 +115,20 @@ export class ClientDetailsPage implements OnInit { }); } + chooseSpeakers() { + console.log('Choose speakers for client:', this.client?.id); + // Here you would typically open a modal to select speakers + this.modalController.create({ + component: ChooseSpeakersComponent, + id: 'choose-speakers-modal', + componentProps: { clientId: this.client?.id } + }).then(modal => { + modal.present(); + }).catch(err => { + console.error('Error opening speaker selection modal:', err); + }); + } + } diff --git a/src/app/pages/dashboard/dashboard.page.html b/src/app/pages/dashboard/dashboard.page.html index 3ea0ec2..b076a12 100644 --- a/src/app/pages/dashboard/dashboard.page.html +++ b/src/app/pages/dashboard/dashboard.page.html @@ -16,22 +16,21 @@ -
-
- - - -
{{numberOfPlayingClients +"/"+ totalClients}} Client(s) playing
- +
+
+ + + +
{{numberOfPlayingClients +"/"+ totalClients}} Client(s) playing
-
+
{{lastServerResponseDeltaInSeconds < 240?'Online':'Offline'}}: {{lastServerResponseTime | date: 'dd.MM.yy HH:mm' }}
-
+
@@ -57,12 +56,12 @@
-
+
- + @@ -91,8 +90,8 @@ -
-
+ diff --git a/src/app/pages/dashboard/dashboard.page.scss b/src/app/pages/dashboard/dashboard.page.scss index b48bb0c..bd6dc29 100644 --- a/src/app/pages/dashboard/dashboard.page.scss +++ b/src/app/pages/dashboard/dashboard.page.scss @@ -43,4 +43,11 @@ font-size: 8px; width: 80%; text-align: center; + } + + .fab-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; } \ No newline at end of file diff --git a/src/app/pages/dashboard/dashboard.page.ts b/src/app/pages/dashboard/dashboard.page.ts index 2627a78..60f2612 100644 --- a/src/app/pages/dashboard/dashboard.page.ts +++ b/src/app/pages/dashboard/dashboard.page.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, HostListener, OnInit } from '@angular/core'; import { ModalController } from '@ionic/angular'; import { first, firstValueFrom, interval, Observable } from 'rxjs'; import { Client, Group, ServerDetail, SnapCastServerStatusResponse, Stream } from 'src/app/model/snapcast.model'; @@ -8,6 +8,8 @@ import { environment } from 'src/environments/environment'; import { omit } from 'lodash-es'; import { Preferences } from '@capacitor/preferences'; import { UserPreference } from 'src/app/enum/user-preference.enum'; +import { Speaker } from 'src/app/model/speaker.model'; +import { HttpClient } from '@angular/common/http'; @@ -19,6 +21,31 @@ import { UserPreference } from 'src/app/enum/user-preference.enum'; }) export class DashboardPage implements OnInit { + @HostListener('window:resize', ['$event']) + getScreenSize(event: any) { + this.scrHeight = window.innerHeight; + this.scrWidth = window.innerWidth; + // console.log(this.scrHeight, this.scrWidth); + if (this.scrWidth < 1024) { + this.swiperConfig = this.defaultConfigMd + } else { + this.swiperConfig = this.defaulConfigXl + this.swiperConfig.pagination = { clickable: true }; + } + } + + defaultConfigMd: SwiperOptions = { + slidesPerView: 1.6, + spaceBetween: 10, + }; + + defaulConfigXl: SwiperOptions = { + slidesPerView: 3.2, + spaceBetween: 10, + } + scrHeight: number; + scrWidth: number; + swiperConfig: SwiperOptions = { slidesPerView: 1.3, @@ -42,10 +69,13 @@ export class DashboardPage implements OnInit { lastServerResponseTime?: Date; lastServerResponseDeltaInSeconds?: number; + speakerData: Speaker[] = []; + constructor( private snapcastService: SnapcastService, + private http: HttpClient ) { // this.groups$ = this.snapcastService.groups$; // this.streams$ = this.snapcastService.streams$; @@ -54,6 +84,8 @@ export class DashboardPage implements OnInit { async ngOnInit() { // this.snapcastService.connect(); + this.getScreenSize(null); // Initialize screen size + this.loadSpeakerData(); this.userPreferenceServerUrl = await this.getUserPreferenceServerUrl(); this.userPreeferenceUsername = await this.getUserName(); @@ -198,7 +230,19 @@ export class DashboardPage implements OnInit { this.isLoading = false; } - + loadSpeakerData(): void { + this.http.get<{ speakers: Speaker[] }>('assets/speakers/speakers-data.json').subscribe({ + next: (response) => { + this.speakerData = response.speakers; + console.log('Speaker data loaded:', this.speakerData); + }, + error: (error) => { + console.error('Error loading speaker data:', error); + } + }); + } + + diff --git a/src/app/services/snapcast.service.ts b/src/app/services/snapcast.service.ts index f892c26..f1bac09 100644 --- a/src/app/services/snapcast.service.ts +++ b/src/app/services/snapcast.service.ts @@ -379,6 +379,9 @@ export class SnapcastService implements OnDestroy { } + // GROUP ACTIONS + + setGroupName(groupId: string, name: string): Observable { @@ -409,16 +412,109 @@ export class SnapcastService implements OnDestroy { } + setGroupClients(groupId: string, clientIds: string[]): Observable { + return this.rpc('Group.SetClients', { id: groupId, clients: clientIds }).pipe( + map((): void => void 0), + catchError(err => { + console.error(`SnapcastService: Failed to set clients for group ${groupId}`, err); + return throwError(() => err); + }) + ); + } + + setGroupMute(groupId: string, mute: boolean): Observable { + return this.rpc('Group.SetMute', { id: groupId, mute }).pipe( + map((): void => void 0), + catchError(err => { + console.error(`SnapcastService: Failed to set mute for group ${groupId}`, err); + return throwError(() => err); + }) + ); + } + + getGroupStatus(groupId: string): Observable { + return this.rpc('Group.GetStatus', { id: groupId }).pipe( + map(response => response.result as Group | undefined), + catchError(err => { + console.error(`SnapcastService: Failed to get status for group ${groupId}`, err); + return throwError(() => err); + }) + ); + } + + + // SERVER ACTIONS + + getServerStatus(): Observable { + return this.rpc('Server.GetStatus').pipe( + map(response => response.result), + catchError(err => { + console.error('SnapcastService: Failed to get server status', err); + return throwError(() => err); + }) + ); + } + + getServerRpcVersion(): Observable { + return this.rpc('Server.GetRpcVersion').pipe( + map(response => response.result?.version), + catchError(err => { + console.error('SnapcastService: Failed to get server RPC version', err); + return throwError(() => err); + }) + ); + + } + deleteServerClient(clientId: string): Observable { + return this.rpc('Server.DeleteClient', { id: clientId }).pipe( + map((): void => void 0), + catchError(err => { + console.error(`SnapcastService: Failed to delete client ${clientId}`, err); + return throwError(() => err); + }) + ); + } + // Stream Actions + setStreamProperty(streamId: string, property: keyof Stream, value: any): Observable { + const params: StreamSetPropertyRpcPayloadParams = { id: streamId, property, value }; + return this.rpc('Stream.SetProperty', params).pipe( + map((): void => void 0), + catchError(err => { + console.error(`SnapcastService: Failed to set property ${property} for stream ${streamId}`, err); + return throwError(() => err); + }) + ); + } + addStream(stream: Stream): Observable { + return this.rpc('Stream.Add', { stream }).pipe( + map((): void => void 0), + catchError(err => { + console.error(`SnapcastService: Failed to add stream`, err); + return throwError(() => err); + }) + ); + } - // --- Data Access Helpers --- - public getClient(clientId: string): Observable { - return this.state$.pipe(map(state => state?.server?.groups.flatMap(g => g.clients).find(c => c.id === clientId))); + removeStream(streamId: string): Observable { + return this.rpc('Stream.Remove', { id: streamId }).pipe( + map((): void => void 0), + catchError(err => { + console.error(`SnapcastService: Failed to remove stream ${streamId}`, err); + return throwError(() => err); + }) + ); } + + + + + // Mock server state for testing purposes + public mockServerState(): void { const url = "assets/mock/json/server-state.json" this.http.get(url).subscribe({ diff --git a/src/app/tabs/tabs.page.html b/src/app/tabs/tabs.page.html index 47e8b9e..e3a919b 100644 --- a/src/app/tabs/tabs.page.html +++ b/src/app/tabs/tabs.page.html @@ -5,8 +5,12 @@
--> +
+
+ + [breakpoints]="[0.05,0.25, 0.5, 0.75]" [backdropDismiss]="false" [backdropBreakpoint]="0.5" + [cssClass]="'player-modal'" [animated]="true" [keyboardClose]="true" [showBackdrop]="true"> @@ -35,5 +39,5 @@ menu - - \ No newline at end of file + + diff --git a/src/app/tabs/tabs.page.scss b/src/app/tabs/tabs.page.scss index df9328a..95f8857 100644 --- a/src/app/tabs/tabs.page.scss +++ b/src/app/tabs/tabs.page.scss @@ -28,4 +28,8 @@ background: rgba(0,0,0,.3); z-index: 5; } + + .page-padding-bottom { + margin-bottom: 300px; // Adjust as needed to ensure content is not obscured by the tab bar + } \ No newline at end of file diff --git a/src/assets/mock/json/server-state.json b/src/assets/mock/json/server-state.json index 59b5b48..8709163 100644 --- a/src/assets/mock/json/server-state.json +++ b/src/assets/mock/json/server-state.json @@ -7,7 +7,7 @@ "config": { "instance": 1, "latency": 0, - "name": "Beans", + "name": "sp.1", "volume": { "muted": false, "percent": 100 @@ -37,6 +37,43 @@ "muted": false, "name": "Office", "stream_id": "AirPlay" + }, + { + "clients": [ + { + "config": { + "instance": 1, + "latency": 0, + "name": "sp.2", + "volume": { + "muted": false, + "percent": 100 + } + }, + "connected": true, + "host": { + "arch": "aarch64", + "ip": "::ffff:192.168.1.151", + "mac": "2c:cf:67:f0:f9:4a", + "name": "beatnik-server", + "os": "Debian GNU/Linux 12 (bookworm)" + }, + "id": "2c:cf:67:f0:f9:4b", + "lastSeen": { + "sec": 1752491174, + "usec": 663848 + }, + "snapclient": { + "name": "Snapclient", + "protocolVersion": 2, + "version": "0.31.0" + } + } + ], + "id": "k23057aa-ba82-449a-41cd-12144c04dfd3", + "muted": false, + "name": "Living Room", + "stream_id": "AirPlay" } ], "server": { diff --git a/src/assets/speakers/img/JSE_Infinite_Slope.webp b/src/assets/speakers/img/JSE_Infinite_Slope.webp new file mode 100644 index 0000000..3aa543f Binary files /dev/null and b/src/assets/speakers/img/JSE_Infinite_Slope.webp differ diff --git a/src/assets/speakers/img/KEF_Concerto_1971.webp b/src/assets/speakers/img/KEF_Concerto_1971.webp new file mode 100644 index 0000000..0c6be38 Binary files /dev/null and b/src/assets/speakers/img/KEF_Concerto_1971.webp differ diff --git a/src/assets/speakers/speakers-data.json b/src/assets/speakers/speakers-data.json new file mode 100644 index 0000000..80b3cc8 --- /dev/null +++ b/src/assets/speakers/speakers-data.json @@ -0,0 +1,25 @@ +{ + "speakers": [ + { + "id": "sp.1", + "image": "assets/speakers/img/KEF_Concerto_1971.webp", + "manufacturer": "KEF", + "model": "Concerto", + "year": 1971 + }, + { + "id": "sp.2", + "image": "assets/speakers/img/JSE_Infinite_Slope.webp", + "manufacturer": "JSE", + "model": "Infinite Slope", + "year": 1980 + }, + { + "id": "sp.3", + "image": "assets/speakers/klipsch_kg4.jpg", + "manufacturer": "Pioneer", + "model": "CS-E500", + "year": 1980 + } + ] +} diff --git a/src/global.scss b/src/global.scss index 3bcc4ae..095753b 100644 --- a/src/global.scss +++ b/src/global.scss @@ -51,7 +51,7 @@ @import "swiper/scss/effect-cards"; -ion-modal { +.player-modal { // --height: 50%; --border-radius: 16px; // --box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);