mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
A movie that exists in several imported Xtream playlists now shows a "Sources N" chip on its detail page and in the player. Switching playlist mid-film keeps the timecode, a preferred source can be pinned per movie, and a failed stream offers the alternatives instead of a dead end. The governing rule is that a guess is never presented as a fact. Every metadata value carries where it came from — `api` (the provider said so), `parsed` (inferred from the title) or `probe` (we contacted the stream). Facts render as plain tags, guesses are prefixed `~` in a warning colour, and an unknown value renders no tag at all plus a "check" affordance. Ranking and failover read through `factualOnly()`, so a filename claiming 4K is structurally unable to outrank a source that was actually reached. A probe that could not complete reports "unknown", never "unavailable". Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces live content; both are additive later, since the candidate type already carries all three portal kinds. In the PWA every entry point is gated off and the chip renders nothing. Auto-failover is opt-in and off by default. Each source is tried at most once per session, so it terminates structurally, and the switch is never silent — the toast names the new playlist, offers an undo, and warns that the dub may differ only when both sides state an audio track as fact. Notable details: - Playlist names are routinely the pasted URL, credentials included. They are never rendered raw; a short host-only label is derived instead. - Quality is derived from pixel width, not height: a 2.39:1 1080p master is 1920x800, and bucketing that by height would publish "720p" as a fact. - Switching is a single `inlinePlayback.set()` so the player and engine survive and re-seek; the carried position is read before the 15s persistence throttle so it does not rewind. - Sources from one playlist collapse into a group, since the same film often appears there several times under different stream ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
9.4 KiB
TypeScript
263 lines
9.4 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import {
|
|
Component,
|
|
computed,
|
|
inject,
|
|
Input,
|
|
OnDestroy,
|
|
OnInit,
|
|
ViewEncapsulation,
|
|
} from '@angular/core';
|
|
import { ReactiveFormsModule } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import {
|
|
MAT_DIALOG_DATA,
|
|
MatDialog,
|
|
MatDialogModule,
|
|
} from '@angular/material/dialog';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { Router } from '@angular/router';
|
|
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
|
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
|
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
|
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
|
|
import { BUILD_COMMIT } from '../../environments/build-commit';
|
|
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
|
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
|
|
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
|
import { SettingsDashboardSectionComponent } from './settings-dashboard-section.component';
|
|
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
|
|
import { SettingsEpgFacade } from './settings-epg.facade';
|
|
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
|
import { SettingsFormFacade } from './settings-form.facade';
|
|
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
|
|
import { SettingsSection } from './settings.models';
|
|
import {
|
|
buildSettingsPlayerOptions,
|
|
buildSettingsSectionNavItems,
|
|
SETTINGS_COVER_SIZE_OPTIONS,
|
|
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
|
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
|
SETTINGS_THEME_OPTIONS,
|
|
} from './settings-options';
|
|
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
|
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
|
|
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
|
import { SettingsResetSectionComponent } from './settings-reset-section.component';
|
|
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
|
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
|
|
import { SettingsBackupFacade } from './settings-backup.facade';
|
|
import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
|
|
import { SettingsSnackbarService } from './settings-snackbar.service';
|
|
|
|
/**
|
|
* Thin coordinator for the settings page. The behaviour of each section lives
|
|
* in a dedicated facade the template binds to directly; what stays here is the
|
|
* page-level wiring: environment capabilities, section navigation, and the
|
|
* save/close flow that spans several facades.
|
|
*/
|
|
@Component({
|
|
templateUrl: './settings.component.html',
|
|
styleUrls: ['./settings.component.scss'],
|
|
host: {
|
|
class: 'settings-page-host',
|
|
},
|
|
encapsulation: ViewEncapsulation.None,
|
|
imports: [
|
|
CommonModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
ReactiveFormsModule,
|
|
TranslateModule,
|
|
MatDialogModule,
|
|
SettingsAboutSectionComponent,
|
|
SettingsBackupSectionComponent,
|
|
SettingsDashboardSectionComponent,
|
|
SettingsEpgSectionComponent,
|
|
SettingsGeneralSectionComponent,
|
|
SettingsPlaybackSectionComponent,
|
|
SettingsRemoteControlSectionComponent,
|
|
SettingsResetSectionComponent,
|
|
SettingsSectionScrollDirective,
|
|
SettingsTmdbSectionComponent,
|
|
],
|
|
providers: [
|
|
SettingsAppUpdateFacade,
|
|
SettingsBackupFacade,
|
|
SettingsEmbeddedMpvFacade,
|
|
SettingsEpgFacade,
|
|
SettingsFormFacade,
|
|
SettingsPlaylistResetFacade,
|
|
SettingsRemoteControlFacade,
|
|
SettingsSnackbarService,
|
|
],
|
|
})
|
|
export class SettingsComponent implements OnInit, OnDestroy {
|
|
readonly appUpdate = inject(SettingsAppUpdateFacade);
|
|
readonly backup = inject(SettingsBackupFacade);
|
|
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
|
|
readonly epg = inject(SettingsEpgFacade);
|
|
readonly form = inject(SettingsFormFacade);
|
|
readonly playlistReset = inject(SettingsPlaylistResetFacade);
|
|
readonly remoteControl = inject(SettingsRemoteControlFacade);
|
|
|
|
private readonly settingsCtx = inject(SettingsContextService);
|
|
private readonly settingsSnackbar = inject(SettingsSnackbarService);
|
|
private readonly runtime = inject(RuntimeCapabilitiesService);
|
|
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
|
|
private readonly matDialog = inject(MatDialog);
|
|
private readonly router = inject(Router);
|
|
private readonly translate = inject(TranslateService);
|
|
private readonly dialogData = inject<{ isDialog: boolean } | null>(
|
|
MAT_DIALOG_DATA,
|
|
{ optional: true }
|
|
);
|
|
|
|
@Input() isDialog = this.dialogData?.isDialog ?? false;
|
|
|
|
/** List with available languages as enum */
|
|
readonly languageEnum = Language;
|
|
|
|
/** List with allowed formats as enum */
|
|
readonly streamFormatEnum = StreamFormat;
|
|
|
|
/** Flag that indicates whether the app runs in electron environment */
|
|
readonly isDesktop = this.runtime.isElectron;
|
|
readonly isPwa = this.runtime.isPwa;
|
|
readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave;
|
|
readonly supportsEpg = this.form.supportsEpg;
|
|
readonly supportsManagedExternalPlayers =
|
|
this.runtime.supportsManagedExternalPlayers;
|
|
readonly supportsExternalPlayerPathSettings =
|
|
this.runtime.supportsExternalPlayerPathSettings;
|
|
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
|
|
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
|
|
|
|
readonly activeSection = this.settingsCtx.activeSection;
|
|
|
|
/** Settings form object */
|
|
readonly settingsForm = this.form.form;
|
|
|
|
/** Player options */
|
|
readonly players = computed(() =>
|
|
buildSettingsPlayerOptions({
|
|
supportsEmbeddedMpv: this.embeddedMpv.supported(),
|
|
supportsManagedExternalPlayers: this.supportsManagedExternalPlayers,
|
|
})
|
|
);
|
|
|
|
/** Git commit the app was built from (CI builds only) */
|
|
readonly buildCommit = BUILD_COMMIT;
|
|
|
|
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
|
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
|
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
|
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
|
|
|
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
|
|
supportsEpg: this.supportsEpg,
|
|
supportsRemoteControl: this.supportsRemoteControl,
|
|
});
|
|
|
|
get sectionNav(): SettingsSection[] {
|
|
return this.sectionNavItems.filter((section) => section.visible);
|
|
}
|
|
|
|
/**
|
|
* Reads the config object from the browsers
|
|
* storage (indexed db)
|
|
*/
|
|
async ngOnInit(): Promise<void> {
|
|
// Wait for settings to load before setting the form
|
|
await this.form.loadSettings();
|
|
this.form.hydrateFromStore();
|
|
this.form.bindDashboardControlsEnabledState();
|
|
void this.embeddedMpv.load();
|
|
this.appUpdate.checkAppVersion();
|
|
this.appUpdate.init();
|
|
void this.remoteControl.fetchLocalIpAddresses();
|
|
|
|
if (!this.isDialog) {
|
|
this.settingsCtx.setSections(this.sectionNav);
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.appUpdate.dispose();
|
|
this.settingsCtx.reset();
|
|
}
|
|
|
|
/** Picks a recording folder in the desktop shell and stages it in the form */
|
|
async selectRecordingFolder(): Promise<void> {
|
|
const folder = await this.embeddedMpv.selectRecordingFolder();
|
|
|
|
if (folder) {
|
|
this.form.setRecordingFolder(folder);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggers on form submit and saves the config object to
|
|
* the indexed db store
|
|
*/
|
|
onSubmit(): void {
|
|
void this.form.save(() => this.applyChangedSettings());
|
|
|
|
if (this.isDialog) {
|
|
this.matDialog.closeAll();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the changed settings to the app
|
|
*/
|
|
applyChangedSettings(): void {
|
|
this.form.applySavedSettings();
|
|
this.epg.fetchConfiguredEpg();
|
|
this.settingsSnackbar.open(
|
|
this.translate.instant('SETTINGS.SETTINGS_SAVED')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Navigates back to the applications homepage
|
|
*/
|
|
backToHome(): void {
|
|
if (this.isDialog) {
|
|
this.matDialog.closeAll();
|
|
} else {
|
|
this.router.navigateByUrl('/');
|
|
}
|
|
}
|
|
|
|
async exportData(): Promise<void> {
|
|
await this.backup.exportData(() => this.waitForUiFeedbackFrame());
|
|
}
|
|
|
|
importData(): void {
|
|
this.backup.importData(() => this.form.hydrateFromStore());
|
|
}
|
|
|
|
removeAll(): void {
|
|
this.playlistReset.confirmAndRemoveAll(() =>
|
|
this.waitForUiFeedbackFrame()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lets the browser paint the pending busy state before a long running
|
|
* task blocks the main thread.
|
|
*/
|
|
private async waitForUiFeedbackFrame(): Promise<void> {
|
|
if (typeof window.requestAnimationFrame !== 'function') {
|
|
await Promise.resolve();
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
window.requestAnimationFrame(() => resolve());
|
|
});
|
|
}
|
|
}
|