Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,78 @@ This is an Nx monorepo with the following structure:
- Same schema structure but implemented in IndexedDB
- Limited by browser storage quotas

**Angular Coding Standards**:

This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:

- **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators
```typescript
// ✅ Correct - Signal-based
readonly menu = viewChild.required<MatMenu>('menuRef');
readonly items = viewChildren<ElementRef>('item');

// ❌ Incorrect - Old decorator syntax
@ViewChild('menuRef') menu!: MatMenu;
@ViewChildren('item') items!: QueryList<ElementRef>;
```

**Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:
```html
<!-- ✅ Correct - Unwrap the signal -->
<button [matMenuTriggerFor]="menu()">Open Menu</button>

<!-- ❌ Incorrect - Signal not unwrapped -->
<button [matMenuTriggerFor]="menu">Open Menu</button>
```

- **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators
```typescript
// ✅ Correct - Signal-based
readonly title = input.required<string>();
readonly size = input<number>(10); // with default value
readonly clicked = output<string>();

// ❌ Incorrect - Old decorator syntax
@Input({ required: true }) title!: string;
@Input() size = 10;
@Output() clicked = new EventEmitter<string>();
```

- **Reactive State**: Use signal primitives for reactive state management
```typescript
// ✅ Use signal(), computed(), effect(), linkedSignal()
readonly count = signal(0);
readonly doubled = computed(() => this.count() * 2);

constructor() {
effect(() => {
console.log('Count changed:', this.count());
});
}
```

- **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet)
```typescript
@HostBinding('class.active') get isActive() { return this.active(); }
@HostListener('click') onClick() { /* ... */ }
```

- **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch`
```typescript
// ✅ Correct - Modern syntax
@if (isLoggedIn()) {
<p>Welcome!</p>
}

@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}

// ❌ Incorrect - Old syntax
<p *ngIf="isLoggedIn">Welcome!</p>
<li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
```

### Backend Architecture (Electron)

**Main Entry**: `apps/electron-backend/src/main.ts`
Expand Down
1 change: 1 addition & 0 deletions apps/electron-backend/src/app/api/main.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,5 @@ contextBridge.exposeInMainWorld('electron', {
dbGetContentByXtreamId: (xtreamId: number, playlistId: string) =>
ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId),
dbDeleteAllPlaylists: () => ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS'),
getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'),
});
6 changes: 6 additions & 0 deletions apps/electron-backend/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export default class App {
...savedWindowBounds,
minHeight: 600,
minWidth: 900,
...(process.platform === 'darwin'
? {
titleBarStyle: 'hidden',
titleBarOverlay: true,
}
: {}),
});
App.mainWindow.setMenu(null);
if (!savedWindowBounds) {
Expand Down
18 changes: 18 additions & 0 deletions apps/electron-backend/src/app/events/electron.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { app, ipcMain } from 'electron';
import * as os from 'os';
import { environment } from '../../environments/environment';

export default class ElectronEvents {
Expand All @@ -23,3 +24,20 @@ ipcMain.handle('get-app-version', (event) => {
ipcMain.on('quit', (event, code) => {
app.exit(code);
});

// Get local IP addresses for remote control URL display
ipcMain.handle('get-local-ip-addresses', () => {
const interfaces = os.networkInterfaces();
const addresses: string[] = [];

for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name] || []) {
// Skip internal (loopback) and non-IPv4 addresses
if (iface.family === 'IPv4' && !iface.internal) {
addresses.push(iface.address);
}
}
}

return addresses;
});
18 changes: 13 additions & 5 deletions apps/electron-backend/src/app/server/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ export class HttpServer {
private server: http.Server | null = null;
private port = 8765;
private isEnabled = false;
private distPath: string;
private distPath: string | null = null;
private remoteControlHandlers: Map<
string,
(req: http.IncomingMessage, res: http.ServerResponse) => void
> = new Map();

constructor() {
/**
* Get the path to the remote-control-web static files.
* Lazily computed to avoid calling Electron APIs before app is ready.
*/
private getDistPath(): string {
if (this.distPath) {
return this.distPath;
}

// Path to the built remote-control-web app
// In development: use workspace root
// In production: use app path
Expand All @@ -34,16 +42,16 @@ export class HttpServer {
);
} else {
// Production mode - files are bundled with the app
// electron-builder copies remote-control-web/**/* directly to app root
this.distPath = path.join(
appPath,
'dist',
'apps',
'remote-control-web',
'browser'
);
}

console.log('[HTTP Server] Serving from:', this.distPath);
return this.distPath;
}

/**
Expand Down Expand Up @@ -151,7 +159,7 @@ export class HttpServer {
// Security: prevent directory traversal
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '');

const fullPath = path.join(this.distPath, filePath);
const fullPath = path.join(this.getDistPath(), filePath);

fs.readFile(fullPath, (err, data) => {
if (err) {
Expand Down
20 changes: 16 additions & 4 deletions apps/web/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,16 @@
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
"maximumError": "10kb"
}
],
"outputHashing": "all"
"outputHashing": "all",
"fileReplacements": [
{
"replace": "apps/web/src/environments/environment.ts",
"with": "apps/web/src/environments/environment.prod.ts"
}
]
},
"pwa": {
"baseHref": "/",
Expand All @@ -56,13 +62,19 @@
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
"maximumError": "10kb"
}
],
"outputHashing": "all",
"optimization": true,
"extractLicenses": true,
"sourceMap": false
"sourceMap": false,
"fileReplacements": [
{
"replace": "apps/web/src/environments/environment.ts",
"with": "apps/web/src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, HostBinding, inject, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
Expand Down Expand Up @@ -27,6 +27,9 @@ import { SearchResultsComponent } from './xtream-tauri/search-results/search-res
imports: [RouterOutlet],
})
export class AppComponent implements OnInit {
@HostBinding('class.macos-platform') get isMacOS() {
return window.electron && navigator.platform.toLowerCase().includes('mac');
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private dialog = inject(MatDialog);
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/home/home.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
(searchQuery)="onSearchQueryChange($event)"
/>
<app-recent-playlists
class="recent-playlists"
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
<app-recent-playlists class="recent-playlists" />
2 changes: 1 addition & 1 deletion apps/web/src/app/home/home.component.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.recent-playlists {
height: calc(100vh - 100px);
height: calc(100vh - 71px);
overflow: auto;
}
26 changes: 23 additions & 3 deletions apps/web/src/app/home/home.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Component } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { RecentPlaylistsComponent } from 'components';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
import { HeaderComponent } from '../shared/components/header/header.component';

@Component({
Expand All @@ -9,4 +11,22 @@ import { HeaderComponent } from '../shared/components/header/header.component';
styleUrls: ['./home.component.scss'],
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
})
export class HomeComponent {}
export class HomeComponent {
private readonly dialog = inject(MatDialog);

searchQuery = signal<string>('');

onSearchQueryChange(query: string): void {
this.searchQuery.set(query);
}

onAddPlaylist(playlistType: PlaylistType): void {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type: playlistType },
}
);
}
}
43 changes: 43 additions & 0 deletions apps/web/src/app/settings/settings.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,49 @@ <h2 mat-dialog-title>{{ 'SETTINGS.TITLE' | translate }}</h2>
</mat-form-field>
</div>
</div>
@if (localIpAddresses().length > 0) {
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}
<p>
{{ 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column margin-right remote-control-urls">
@for (ip of localIpAddresses(); track ip) {
<div class="remote-control-url-item">
<div class="url-row">
<a
[href]="'http://' + ip + ':' + settingsForm.value.remoteControlPort"
target="_blank"
class="remote-control-url"
>
http://{{ ip }}:{{ settingsForm.value.remoteControlPort }}
</a>
<button
mat-icon-button
type="button"
(click)="toggleQrCode(ip)"
[matTooltip]="'SETTINGS.SHOW_QR_CODE' | translate"
>
<mat-icon>qr_code_2</mat-icon>
</button>
</div>
@if (visibleQrCodeIp() === ip) {
<div class="qr-code-container">
<qrcode
[qrdata]="'http://' + ip + ':' + settingsForm.value.remoteControlPort"
[width]="150"
errorCorrectionLevel="M"
></qrcode>
</div>
}
</div>
}
</div>
</div>
}
}
<mat-divider />
<div class="row">
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/app/settings/settings.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,34 @@ button {
margin: 0 10px;
}
}

.remote-control-urls {
text-align: right;
}

.remote-control-url-item {
margin-bottom: 8px;
}

.url-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}

.remote-control-url {
font-family: monospace;
color: inherit;
text-decoration: none;

&:hover {
text-decoration: underline;
}
}

.qr-code-container {
display: flex;
justify-content: flex-end;
padding: 8px 0;
}
Loading