mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
chore: tighten validation and runtime logging
* chore: tighten validation and runtime logging * fix(i18n): localize new settings labels
This commit is contained in:
1 parent
d24c77a143
commit
2d5c4fa4f9
52 files changed
+767
-214
No files matched your search
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'epg-data-access',
|
||||
preset: '../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../coverage/libs/epg/data-access',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:data-access"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/epg/data-access/jest.config.ts",
|
||||
"tsConfig": "libs/epg/data-access/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
@@ -1,119 +1,45 @@
|
||||
/* import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MockModule, MockProviders } from 'ng-mocks';
|
||||
import { DataService } from './data.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
jest.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: jest.fn(),
|
||||
isTauri: () => true,
|
||||
}));
|
||||
|
||||
describe('EpgService', () => {
|
||||
let service: EpgService;
|
||||
let snackBar: MatSnackBar;
|
||||
let store: MockStore;
|
||||
let translateService: TranslateService;
|
||||
let dispatchSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgService,
|
||||
MockProviders(DataService, TranslateService, MatSnackBar),
|
||||
provideMockStore(),
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [MockModule(MatSnackBarModule)],
|
||||
});
|
||||
|
||||
service = TestBed.inject(EpgService);
|
||||
snackBar = TestBed.inject(MatSnackBar);
|
||||
store = TestBed.inject(MockStore);
|
||||
translateService = TestBed.inject(TranslateService);
|
||||
});
|
||||
|
||||
jest.spyOn(translateService, 'instant').mockImplementation(
|
||||
(key) => key
|
||||
it('returns an empty batch result when the desktop bridge is unavailable', async () => {
|
||||
const result = await firstValueFrom(
|
||||
service.getCurrentProgramsForChannels(['channel-1'])
|
||||
);
|
||||
jest.spyOn(snackBar, 'open');
|
||||
dispatchSpy = jest.spyOn(store, 'dispatch');
|
||||
|
||||
// Suppress console.error during tests
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a service instance', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('fetchEpg', () => {
|
||||
it('should show success notification on successful fetch', async () => {
|
||||
(invoke as jest.Mock).mockResolvedValueOnce({});
|
||||
service.fetchEpg(['http://example.com/epg.xml']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'EPG.FETCH_SUCCESS',
|
||||
null,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error notification on fetch failure', async () => {
|
||||
(invoke as jest.Mock).mockRejectedValueOnce(new Error('Failed'));
|
||||
service.fetchEpg(['http://example.com/epg.xml']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'EPG.ERROR',
|
||||
'CLOSE',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChannelPrograms', () => {
|
||||
it('should update programs and set EPG flag to true when programs exist', (done) => {
|
||||
const mockPrograms = [
|
||||
{
|
||||
start: new Date().toISOString(),
|
||||
stop: new Date().toISOString(),
|
||||
title: 'Test',
|
||||
},
|
||||
];
|
||||
(invoke as jest.Mock).mockResolvedValueOnce(mockPrograms);
|
||||
|
||||
service.getChannelPrograms('test-channel');
|
||||
|
||||
service.currentEpgPrograms$.subscribe((programs) => {
|
||||
expect(programs).toHaveLength(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: true })
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set EPG flag to false when no programs found', (done) => {
|
||||
(invoke as jest.Mock).mockResolvedValueOnce([]);
|
||||
|
||||
service.getChannelPrograms('test-channel');
|
||||
|
||||
service.currentEpgPrograms$.subscribe((programs) => {
|
||||
expect(programs).toHaveLength(0);
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: false })
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
it('returns null for current program lookup when the desktop bridge is unavailable', async () => {
|
||||
await expect(
|
||||
firstValueFrom(service.getCurrentProgramForChannel('channel-1'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -3,7 +3,11 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
|
||||
import { catchError, map, tap, timeout } from 'rxjs/operators';
|
||||
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createDevLogger,
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { normalizeEpgPrograms } from './epg-program-normalization.util';
|
||||
|
||||
interface CachedProgram {
|
||||
@@ -17,6 +21,8 @@ type EpgChannelMetadataApi = {
|
||||
) => Promise<Record<string, EpgChannelMetadata | null>>;
|
||||
};
|
||||
|
||||
const debugEpgService = createDevLogger('EpgService');
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -71,7 +77,7 @@ export class EpgService {
|
||||
*/
|
||||
getChannelPrograms(channelId: string): void {
|
||||
if (!this.isDesktop) return;
|
||||
console.log('Fetching EPG for channel ID:', channelId);
|
||||
debugEpgService('Fetching EPG for channel ID:', channelId);
|
||||
|
||||
from(window.electron.getChannelPrograms(channelId))
|
||||
.pipe(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'database',
|
||||
preset: '../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
coverageDirectory: '../../../coverage/libs/shared/database',
|
||||
};
|
||||
@@ -16,6 +16,14 @@
|
||||
"format": ["cjs"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/database/jest.config.ts",
|
||||
"tsConfig": "libs/shared/database/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"command": "eslint libs/shared/database/**/*.ts"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { __databaseConnectionTestHooks } from './connection';
|
||||
|
||||
function compactSql(statement: string): string {
|
||||
return statement.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function createdObjectNames(prefix: string, statements: readonly string[]) {
|
||||
return statements
|
||||
.map(compactSql)
|
||||
.filter((statement) => statement.startsWith(prefix))
|
||||
.map((statement) => {
|
||||
const match = statement.match(
|
||||
/^(?:CREATE\s+(?:UNIQUE\s+)?(?:VIRTUAL\s+)?(?:TABLE|INDEX|TRIGGER)\s+IF\s+NOT\s+EXISTS)\s+([^\s(]+)/i
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
})
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
describe('database schema statements', () => {
|
||||
const {
|
||||
createTableStatements,
|
||||
columnMigrationStatements,
|
||||
indexMigrationStatements,
|
||||
} = __databaseConnectionTestHooks;
|
||||
|
||||
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
||||
const schemaSql = createTableStatements.map(compactSql).join('\n');
|
||||
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists');
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories');
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS content');
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_channels');
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_programs');
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS playback_positions'
|
||||
);
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS epg_programs_ai'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS epg_programs_ad'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS epg_programs_au'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy column migrations idempotent ALTER TABLE statements', () => {
|
||||
expect(columnMigrationStatements.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
columnMigrationStatements
|
||||
.map(compactSql)
|
||||
.every((statement) => statement.startsWith('ALTER TABLE '))
|
||||
).toBe(true);
|
||||
expect(columnMigrationStatements.map(compactSql)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0',
|
||||
'ALTER TABLE playlists ADD COLUMN payload TEXT',
|
||||
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
|
||||
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
|
||||
expect(indexMigrationStatements.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
indexMigrationStatements
|
||||
.map(compactSql)
|
||||
.every((statement) =>
|
||||
/^CREATE (?:UNIQUE )?INDEX IF NOT EXISTS /i.test(statement)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(indexMigrationStatements.map(compactSql)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)',
|
||||
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('does not define duplicate fresh-install schema object names', () => {
|
||||
const objectNames = [
|
||||
...createdObjectNames(
|
||||
'CREATE TABLE IF NOT EXISTS',
|
||||
createTableStatements
|
||||
),
|
||||
...createdObjectNames(
|
||||
'CREATE VIRTUAL TABLE IF NOT EXISTS',
|
||||
createTableStatements
|
||||
),
|
||||
...createdObjectNames(
|
||||
'CREATE INDEX IF NOT EXISTS',
|
||||
createTableStatements
|
||||
),
|
||||
...createdObjectNames(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS',
|
||||
createTableStatements
|
||||
),
|
||||
...createdObjectNames(
|
||||
'CREATE TRIGGER IF NOT EXISTS',
|
||||
createTableStatements
|
||||
),
|
||||
];
|
||||
|
||||
expect(objectNames).toHaveLength(new Set(objectNames).size);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
"jestConfig": "libs/shared/interfaces/jest.config.ts",
|
||||
"tsConfig": "libs/shared/interfaces/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/channel.interface';
|
||||
export * from './lib/channel.model';
|
||||
export * from './lib/dev-logger.util';
|
||||
export * from './lib/embedded-mpv-session.interface';
|
||||
export * from './lib/epg-channel-metadata.model';
|
||||
export * from './lib/epg-channel-with-programs.interface';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createDevLogger } from './dev-logger.util';
|
||||
|
||||
describe('createDevLogger', () => {
|
||||
const globalWithNgDevMode = globalThis as typeof globalThis & {
|
||||
ngDevMode?: boolean;
|
||||
};
|
||||
|
||||
const originalNgDevMode = globalWithNgDevMode.ngDevMode;
|
||||
const originalNodeEnv = process.env['NODE_ENV'];
|
||||
let debugSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
globalWithNgDevMode.ngDevMode = true;
|
||||
process.env['NODE_ENV'] = 'development';
|
||||
debugSpy = jest.spyOn(console, 'debug').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalWithNgDevMode.ngDevMode = originalNgDevMode;
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env['NODE_ENV'];
|
||||
} else {
|
||||
process.env['NODE_ENV'] = originalNodeEnv;
|
||||
}
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs scoped debug output outside production and test mode', () => {
|
||||
const debug = createDevLogger('Scope');
|
||||
|
||||
debug('message', { id: 1 });
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith('[Scope]', 'message', {
|
||||
id: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses debug output in Angular production mode', () => {
|
||||
globalWithNgDevMode.ngDevMode = false;
|
||||
const debug = createDevLogger('Scope');
|
||||
|
||||
debug('message');
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses debug output during tests', () => {
|
||||
process.env['NODE_ENV'] = 'test';
|
||||
const debug = createDevLogger('Scope');
|
||||
|
||||
debug('message');
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export type DevLogFn = (...args: unknown[]) => void;
|
||||
|
||||
function isDevLoggingEnabled(): boolean {
|
||||
const global = globalThis as typeof globalThis & {
|
||||
ngDevMode?: boolean;
|
||||
process?: { env?: { NODE_ENV?: string } };
|
||||
};
|
||||
|
||||
return (
|
||||
global.ngDevMode !== false && global.process?.env?.NODE_ENV !== 'test'
|
||||
);
|
||||
}
|
||||
|
||||
export function createDevLogger(scope: string): DevLogFn {
|
||||
const prefix = `[${scope}]`;
|
||||
|
||||
return (...args: unknown[]) => {
|
||||
if (isDevLoggingEnabled()) {
|
||||
console.debug(prefix, ...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,9 @@
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/m3u-utils/jest.config.ts"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,7 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
@if (displayIcon() && !logoError()) {
|
||||
<div
|
||||
class="backdrop"
|
||||
[style.backgroundImage]="
|
||||
'url(' + displayIcon() + ')'
|
||||
"
|
||||
[style.backgroundImage]="'url(' + displayIcon() + ')'"
|
||||
></div>
|
||||
}
|
||||
<div class="vignette"></div>
|
||||
@@ -54,7 +52,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
<h2 class="station-name">
|
||||
{{ channelName() || 'Radio' }}
|
||||
</h2>
|
||||
<span class="station-badge" [class.live]="playState() === 'play'">
|
||||
<span
|
||||
class="station-badge"
|
||||
[class.live]="playState() === 'play'"
|
||||
>
|
||||
@if (playState() === 'play') {
|
||||
<span class="pulse"></span> LIVE
|
||||
} @else {
|
||||
@@ -74,14 +75,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
<button
|
||||
class="play-btn"
|
||||
mat-fab
|
||||
(click)="
|
||||
playState() === 'play' ? stop() : play()
|
||||
"
|
||||
(click)="playState() === 'play' ? stop() : play()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
playState() === 'play'
|
||||
? 'pause'
|
||||
: 'play_arrow'
|
||||
playState() === 'play' ? 'pause' : 'play_arrow'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
|
||||
@@ -95,19 +92,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
</div>
|
||||
|
||||
<div class="volume-row">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="vol-icon"
|
||||
(click)="mute()"
|
||||
>
|
||||
<button mat-icon-button class="vol-icon" (click)="mute()">
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
</button>
|
||||
<mat-slider
|
||||
class="vol-slider"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
>
|
||||
<mat-slider class="vol-slider" min="0" max="1" step="0.05">
|
||||
<input
|
||||
matSliderThumb
|
||||
[ngModel]="volume()"
|
||||
@@ -209,7 +197,9 @@ export class AudioPlayerComponent {
|
||||
play() {
|
||||
const audio = this.audioRef()?.nativeElement;
|
||||
if (!audio) return;
|
||||
audio.play().catch((err) => console.log(err));
|
||||
audio.play().catch((err) => {
|
||||
console.warn('[AudioPlayer] Audio playback failed:', err);
|
||||
});
|
||||
this.playState.set('play');
|
||||
}
|
||||
|
||||
@@ -219,8 +209,7 @@ export class AudioPlayerComponent {
|
||||
}
|
||||
|
||||
setVolume(value: number) {
|
||||
const clamped =
|
||||
Math.round(Math.max(0, Math.min(1, value)) * 100) / 100;
|
||||
const clamped = Math.round(Math.max(0, Math.min(1, value)) * 100) / 100;
|
||||
this.volume.set(clamped);
|
||||
const audio = this.audioRef()?.nativeElement;
|
||||
if (audio) audio.volume = clamped;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
|
||||
/**
|
||||
* This component contains the implementation of HTML5 based video player
|
||||
*/
|
||||
@@ -121,7 +123,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.playChannel(changes['channel'].currentValue);
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined) {
|
||||
console.log(
|
||||
debugHtmlPlayer(
|
||||
'Setting HTML5 player volume to:',
|
||||
changes['volume'].currentValue
|
||||
);
|
||||
@@ -157,7 +159,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
if ((extension === 'ts' || !extension) && mpegts.isSupported()) {
|
||||
console.log(
|
||||
debugHtmlPlayer(
|
||||
'Using mpegts.js for TS stream:',
|
||||
channel.name,
|
||||
url
|
||||
@@ -193,7 +195,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
Hls &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
console.log('... switching channel to ', channel.name, url);
|
||||
debugHtmlPlayer('Switching channel to:', channel.name, url);
|
||||
this.hls = new Hls();
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
this.handleHlsManifestParsed(url, data);
|
||||
@@ -205,7 +207,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.hls.loadSource(url);
|
||||
this.handlePlayOperation();
|
||||
} else {
|
||||
console.log('Using native video player...');
|
||||
debugHtmlPlayer('Using native video player');
|
||||
this.addSourceToVideo(
|
||||
this.videoPlayer.nativeElement,
|
||||
url,
|
||||
@@ -315,7 +317,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
onVolumeChange(): void {
|
||||
const currentVolume = this.videoPlayer.nativeElement.volume;
|
||||
console.log('Volume changed to:', currentVolume);
|
||||
debugHtmlPlayer('Volume changed to:', currentVolume);
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import '@yangkghjh/videojs-aspect-ratio-panel';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import videoJs from 'video.js';
|
||||
import 'videojs-contrib-quality-levels';
|
||||
import 'videojs-quality-selector-hls';
|
||||
@@ -89,6 +90,8 @@ type VideoJsPlayer = Omit<
|
||||
error: () => { code?: number; message?: string } | null;
|
||||
};
|
||||
|
||||
const debugVjsPlayer = createDevLogger('VjsPlayer');
|
||||
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
templateUrl: './vjs-player.component.html',
|
||||
@@ -145,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
: { ...this.options(), autoplay: true };
|
||||
|
||||
this.player = videoJs(this.target().nativeElement, vjsOptions, () => {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'Setting VideoJS player initial volume to:',
|
||||
this.volume()
|
||||
);
|
||||
@@ -168,7 +171,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const audioTracks = this.player.audioTracks();
|
||||
if (audioTracks) {
|
||||
audioTracks.addEventListener('addtrack', () => {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] addtrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
@@ -176,7 +179,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
audioTracks.addEventListener('removetrack', () => {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] removetrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
@@ -246,7 +249,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined && this.player) {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'Setting VideoJS player volume to:',
|
||||
changes['volume'].currentValue
|
||||
);
|
||||
@@ -300,7 +303,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
?.el();
|
||||
if (!videoEl) return;
|
||||
|
||||
console.log('Using mpegts.js for TS stream:', url);
|
||||
debugVjsPlayer('Using mpegts.js for TS stream:', url);
|
||||
const isLive = this.options().isLive !== false;
|
||||
this.mpegtsPlayer = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
@@ -455,13 +458,13 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
private logAudioTracks(): void {
|
||||
const audioTracks = this.player.audioTracks();
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] Audio tracks count:',
|
||||
audioTracks?.length ?? 0
|
||||
);
|
||||
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
|
||||
const t = audioTracks[i];
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
|
||||
);
|
||||
}
|
||||
@@ -476,12 +479,12 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO;
|
||||
|
||||
if (audioMediaGroups) {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] HLS AUDIO media groups:',
|
||||
JSON.stringify(Object.keys(audioMediaGroups))
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] HLS AUDIO media groups: none found in playlist metadata'
|
||||
);
|
||||
}
|
||||
@@ -494,16 +497,16 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
private setupAudioTrackMenu(): void {
|
||||
const audioTracks = this.player.audioTracks();
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] setupAudioTrackMenu called, tracks:',
|
||||
audioTracks?.length ?? 0
|
||||
);
|
||||
if (!audioTracks || audioTracks.length <= 1) {
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] Skipping menu: need >1 tracks, have',
|
||||
audioTracks?.length ?? 0
|
||||
);
|
||||
console.log(
|
||||
debugVjsPlayer(
|
||||
'[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA'
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/workspace/shell/util/jest.config.ts",
|
||||
"tsConfig": "libs/workspace/shell/util/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user