Skip to content
Open
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
3 changes: 3 additions & 0 deletions app/app-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { ShortcutsService } from 'services/shortcuts';
export { CustomizationService } from 'services/customization';
export { LayoutService } from 'services/layout';
export { NotificationsService } from 'services/notifications';
export { ObsModuleLoadNotificationsService } from 'services/obs-module-load-notifications-service';
export { OnboardingService } from 'services/onboarding';
export { NavigationService } from 'services/navigation';
export { PerformanceService } from 'services/performance';
Expand Down Expand Up @@ -163,6 +164,7 @@ import { TcpServerService } from './services/api/tcp-server';
import { MagicLinkService } from './services/magic-link';
import { UsageStatisticsService } from './services/usage-statistics';
import { NotificationsService } from './services/notifications';
import { ObsModuleLoadNotificationsService } from 'services/obs-module-load-notifications-service';
import { MediaBackupService } from './services/media-backup';
import { HotkeysService } from './services/hotkeys';
import { WidgetsService } from './services/widgets';
Expand Down Expand Up @@ -257,6 +259,7 @@ export const AppServices = {
MediaGalleryService,
UsageStatisticsService,
NotificationsService,
ObsModuleLoadNotificationsService,
MediaBackupService,
HotkeysService,
WidgetsService,
Expand Down
3 changes: 3 additions & 0 deletions app/services/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { NavigationService } from 'services/navigation';
import { StreamingService } from 'services/streaming';
import { VirtualWebcamService } from 'services/virtual-webcam';
import { WebsocketService } from 'services/websocket';
import { ObsModuleLoadNotificationsService } from 'services/obs-module-load-notifications-service';

interface IAppState {
loading: boolean;
Expand Down Expand Up @@ -106,6 +107,7 @@ export class AppService extends StatefulService<IAppState> {
@Inject() private streamingService: StreamingService;
@Inject() private virtualWebcamService: VirtualWebcamService;
@Inject() private websocketService: WebsocketService;
@Inject() private obsModuleLoadNotificationsService: ObsModuleLoadNotificationsService;

static initialState: IAppState = {
loading: true,
Expand Down Expand Up @@ -157,6 +159,7 @@ export class AppService extends StatefulService<IAppState> {
// TODO: We should come up with a better way to handle this.
await this.sceneCollectionsService.initialize();
}
await this.obsModuleLoadNotificationsService.refreshModuleLoadNotifications();

if (this.userService.isAlphaGroup) {
this.SET_ONBOARDED(this.onboardingService.startOnboardingIfRequired());
Expand Down
1 change: 1 addition & 0 deletions app/services/notifications/notifications-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface INotificationsServiceApi {
restoreDefaultSettings(): void;
markAsRead(id: number): void;
markAllAsRead(): void;
removeByCode(code: string): void;
applyAction(notificationId: number): void;
showNotifications(): void;
}
13 changes: 13 additions & 0 deletions app/services/notifications/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ export class NotificationsService
this.notificationRead.next(unreadNotifies.map(notify => notify.id));
}

removeByCode(code: string) {
const matchingNotifications = this.views.getAll().filter(notify => notify.code === code);
if (!matchingNotifications.length) return;

this.REMOVE_BY_CODE(code);
this.notificationRead.next(matchingNotifications.map(notify => notify.id));
}

setSettings(patch: Partial<INotificationsSettings>) {
this.SET_SETTINGS(patch);
}
Expand Down Expand Up @@ -177,6 +185,11 @@ export class NotificationsService
this.state.notifications = [];
}

@mutation()
private REMOVE_BY_CODE(code: string) {
this.state.notifications = this.state.notifications.filter(notify => notify.code !== code);
}

@mutation()
private MARK_ALL_AS_READ() {
this.state.notifications.forEach(notify => (notify.unread = false));
Expand Down
132 changes: 132 additions & 0 deletions app/services/obs-module-load-notifications-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as remote from '@electron/remote';
import * as obs from '../../obs-api';
import { Inject } from 'services/core/injector';
import { Service } from 'services/core/service';
import { JsonrpcService } from 'services/api/jsonrpc';
import { $t } from 'services/i18n';
import { ENotificationType, NotificationsService } from 'services/notifications';
import { SceneCollectionsService } from 'services/scene-collections';
import {
NDI_RUNTIME_FAILURE_CODES,
findNdiRuntimeLoadFailure,
getNdiRuntimeNotificationMessage,
IObsModuleLoadFailure,
shouldShowNdiRuntimeNotification,
} from 'services/obs-module-load-notifications';

const NDI_RUNTIME_DOWNLOAD_URL = 'https://ndi.video/tools/download';

export class ObsModuleLoadNotificationsService extends Service {
@Inject() private notificationsService: NotificationsService;
@Inject() private jsonrpcService: JsonrpcService;
@Inject() private sceneCollectionsService: SceneCollectionsService;

private moduleLoadFailures: IObsModuleLoadFailure[] | null = null;
private refreshId = 0;

init() {
this.sceneCollectionsService.collectionSwitched.subscribe(() => {
this.refreshAfterActiveCollectionChanged();
});

this.sceneCollectionsService.collectionUpdated.subscribe(collection => {
if (collection.id === this.sceneCollectionsService.activeCollection?.id) {
this.refreshAfterActiveCollectionChanged();
}
});
}

async refreshModuleLoadNotifications(failures?: IObsModuleLoadFailure[]) {
if (failures) {
this.moduleLoadFailures = failures;
} else if (!this.moduleLoadFailures) {
this.moduleLoadFailures = this.getModuleLoadFailures();
}

const refreshId = ++this.refreshId;
const moduleLoadFailures = this.moduleLoadFailures || [];

const ndiRuntimeLoadFailure = findNdiRuntimeLoadFailure(moduleLoadFailures);
if (!ndiRuntimeLoadFailure) {
this.clearNdiRuntimeNotifications();
return;
}

const shouldShowNotification = await this.shouldShowNdiRuntimeNotification(moduleLoadFailures);
if (refreshId !== this.refreshId) {
return;
}

if (!shouldShowNotification) {
this.clearNdiRuntimeNotifications();
return;
}

this.clearNdiRuntimeNotifications(ndiRuntimeLoadFailure.code);
this.notificationsService.push({
action: this.jsonrpcService.createRequest(
Service.getResourceId(this),
'openNdiRuntimeDownloadPage',
),
code: ndiRuntimeLoadFailure.code,
data: ndiRuntimeLoadFailure,
lifeTime: -1,
message: $t(getNdiRuntimeNotificationMessage(ndiRuntimeLoadFailure.code)),
singleton: true,
type: ENotificationType.WARNING,
});
}

openNdiRuntimeDownloadPage() {
remote.shell.openExternal(NDI_RUNTIME_DOWNLOAD_URL);
}

private refreshAfterActiveCollectionChanged() {
if (!this.moduleLoadFailures) return;

this.refreshModuleLoadNotifications().catch((e: unknown) => {
console.warn(
'[ObsModuleLoadNotifications] Failed to refresh module load notifications after scene collection change.',
e,
);
});
}

private getModuleLoadFailures(): IObsModuleLoadFailure[] {
const getModuleLoadFailures = obs.NodeObs.OBS_API_getModuleLoadFailures;
if (typeof getModuleLoadFailures !== 'function') {
console.warn('[ObsModuleLoadNotifications] OBS_API_getModuleLoadFailures is unavailable.');
return [];
}

return getModuleLoadFailures();
}

private async shouldShowNdiRuntimeNotification(
failures: IObsModuleLoadFailure[],
): Promise<boolean> {
try {
const activeCollectionId = this.sceneCollectionsService.activeCollection?.id;
const sceneCollections = await this.sceneCollectionsService.fetchSceneCollectionsSchema();
const shouldShowNotification = shouldShowNdiRuntimeNotification(
failures,
sceneCollections,
activeCollectionId,
);

return shouldShowNotification;
} catch (e: unknown) {
console.warn(
'[ObsModuleLoadNotifications] Failed to inspect active scene collection for NDI sources.',
e,
);
return false;
}
}

private clearNdiRuntimeNotifications(exceptCode?: string) {
NDI_RUNTIME_FAILURE_CODES.forEach(code => {
if (code !== exceptCode) this.notificationsService.removeByCode(code);
});
}
}
75 changes: 75 additions & 0 deletions app/services/obs-module-load-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type * as ObsStudioNode from 'obs-studio-node';
import type { ISceneCollectionSchema } from 'services/scene-collections';

export type IObsModuleLoadFailure = ObsStudioNode.IObsModuleLoadFailure;

export const NDI_RUNTIME_VERSION_MISMATCH: typeof ObsStudioNode.NDI_RUNTIME_VERSION_MISMATCH =
'NDI_RUNTIME_VERSION_MISMATCH';
export const NDI_RUNTIME_NOT_FOUND: typeof ObsStudioNode.NDI_RUNTIME_NOT_FOUND =
'NDI_RUNTIME_NOT_FOUND';

export const NDI_RUNTIME_FAILURE_CODES: readonly string[] = [
NDI_RUNTIME_VERSION_MISMATCH,
NDI_RUNTIME_NOT_FOUND,
];

function normalizeModuleName(moduleName: string): string {
return (moduleName || '').replace(/\.(dll|so|dylib)$/i, '').toLowerCase();
}

function isObsNdiRuntimeFailure(failure: IObsModuleLoadFailure): boolean {
return (
normalizeModuleName(failure.module) === 'obs-ndi' &&
NDI_RUNTIME_FAILURE_CODES.includes(failure.code)
);
}

export function findNdiRuntimeVersionMismatch(
failures: IObsModuleLoadFailure[],
): IObsModuleLoadFailure | null {
return (
failures.find(failure => {
return (
normalizeModuleName(failure.module) === 'obs-ndi' &&
failure.code === NDI_RUNTIME_VERSION_MISMATCH
);
}) || null
);
}

export function findNdiRuntimeLoadFailure(
failures: IObsModuleLoadFailure[],
): IObsModuleLoadFailure | null {
return failures.find(isObsNdiRuntimeFailure) || null;
}

export function getNdiRuntimeNotificationMessage(code: string): string {
if (code === NDI_RUNTIME_NOT_FOUND) {
return 'NDI Runtime was not found. Install NDI Tools to use NDI sources and outputs.';
}

return 'NDI Runtime 6 or newer is required for NDI sources and outputs. Click to download the latest NDI Runtime.';
}

export function activeSceneCollectionHasNdiSources(
sceneCollections: ISceneCollectionSchema[],
activeCollectionId?: string,
): boolean {
if (!activeCollectionId) return false;

const activeCollection = sceneCollections.find(
collection => collection.id === activeCollectionId,
);
return activeCollection?.sources.some(source => source.type === 'ndi_source') || false;
}

export function shouldShowNdiRuntimeNotification(
failures: IObsModuleLoadFailure[],
sceneCollections: ISceneCollectionSchema[],
activeCollectionId?: string,
): boolean {
return (
!!findNdiRuntimeLoadFailure(failures) &&
activeSceneCollectionHasNdiSources(sceneCollections, activeCollectionId)
);
}
2 changes: 1 addition & 1 deletion scripts/repositories.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "obs-studio-node",
"url": "https://s3-us-west-2.amazonaws.com/obsstudionodes3.streamlabs.com/",
"archive": "osn-[VERSION]-release-[OS][ARCH].tar.gz",
"version": "0.26.29b3",
"version": "ndi-error-handling-1",
"win64": true,
"osx": true
},
Expand Down
Loading
Loading