From f5931caa4b5928277ec84f8d6ea7e6f7307f84ee Mon Sep 17 00:00:00 2001 From: Khavin Shankar Date: Wed, 18 Sep 2024 18:02:27 +0530 Subject: [PATCH] Revert "Merge Develop to Staging v24.39.0 (#8559)" This reverts commit 6d1b2c232aedebc33955398a5cb98c898c0dbad9. --- .../e2e/patient_spec/PatientDoctorNotes.cy.ts | 61 -- .../e2e/patient_spec/PatientLogUpdate.cy.ts | 20 +- cypress/e2e/patient_spec/PatientManage.cy.ts | 48 ++ cypress/pageobject/Asset/AssetFilters.ts | 2 +- cypress/pageobject/Asset/AssetSearch.ts | 4 +- cypress/pageobject/Login/LoginPage.ts | 6 - .../pageobject/Patient/PatientConsultation.ts | 26 + cypress/pageobject/Patient/PatientCreation.ts | 1 - .../pageobject/Patient/PatientDoctorNotes.ts | 29 - .../pageobject/Patient/PatientInsurance.ts | 2 +- .../Patient/PatientMedicalHistory.ts | 4 +- package-lock.json | 54 +- package.json | 1 + scripts/generate-build-version.js | 2 +- src/Common/constants.tsx | 75 +- .../CameraFeed => Common/hooks}/useFeedPTZ.ts | 41 +- src/Common/hooks/useHLSPlayer.ts | 17 + src/Common/hooks/useMSEplayer.ts | 236 ++++++ .../Assets/AssetType/ONVIFCamera.tsx | 12 +- .../Assets/configure/CameraConfigure.tsx | 4 +- src/Components/CameraFeed/CameraFeed.tsx | 176 +++-- src/Components/CameraFeed/FeedAlert.tsx | 6 +- .../CameraFeed/FeedNetworkSignal.tsx | 6 +- src/Components/CameraFeed/routes.ts | 10 +- src/Components/CameraFeed/useOperateCamera.ts | 5 - src/Components/CameraFeed/usePlayer.tsx | 58 ++ src/Components/CameraFeed/utils.ts | 7 +- src/Components/CameraFeed/videoPlayer.tsx | 198 ----- .../ExternalResult/ResultUpdate.tsx | 2 +- .../ConsultationNursingTab.tsx | 224 +----- .../Facility/ConsultationDetails/index.tsx | 23 +- .../ConsultationDoctorNotes/index.tsx | 16 +- .../Facility/Consultations/ABGPlots.tsx | 16 +- .../DailyRounds/DefaultLogUpdateCard.tsx | 31 +- .../Consultations/DailyRoundsList.tsx | 13 + .../Facility/Consultations/DialysisPlots.tsx | 6 +- .../Facility/Consultations/Feed.tsx | 704 ++++++++++++++++++ .../Facility/Consultations/FeedButton.tsx | 63 ++ .../Consultations/LiveFeed.tsx} | 210 +++--- .../Consultations/NeurologicalTables.tsx | 24 +- .../Facility/Consultations/NursingPlot.tsx | 8 +- .../Facility/Consultations/NutritionPlots.tsx | 13 +- .../Facility/Consultations/PainDiagrams.tsx | 5 +- .../Consultations/PressureSoreDiagrams.tsx | 7 +- .../Consultations/PrimaryParametersPlot.tsx | 15 +- .../Facility/Consultations/VentilatorPlot.tsx | 19 +- src/Components/Facility/FacilityCreate.tsx | 2 +- src/Components/Facility/FacilityHome.tsx | 2 +- .../Investigations/InvestigationTable.tsx | 36 +- .../Investigations/ShowInvestigation.tsx | 38 +- .../Facility/PatientNotesSlideover.tsx | 18 +- src/Components/Facility/TreatmentSummary.tsx | 69 +- src/Components/Facility/models.tsx | 178 ++--- .../Form/FormFields/RadioFormField.tsx | 4 +- .../Form/FormFields/SelectFormField.tsx | 2 +- src/Components/Form/MultiSelectMenuV2.tsx | 2 +- .../LogUpdate/CriticalCarePreview.tsx | 212 ++---- .../LogUpdate/Sections/BloodSugar.tsx | 2 +- .../Sections/NeurologicalMonitoring.tsx | 12 +- .../LogUpdate/Sections/NursingCare.tsx | 110 +-- .../RespiratorySupport/OxygenSupport.tsx | 2 +- .../Sections/RespiratorySupport/index.tsx | 4 +- src/Components/LogUpdate/Sections/Vitals.tsx | 16 +- src/Components/LogUpdate/Sections/index.tsx | 1 - src/Components/Patient/DailyRounds.tsx | 311 ++------ src/Components/Patient/PatientNotes.tsx | 16 +- src/Components/Patient/PatientRegister.tsx | 10 +- src/Components/Patient/models.tsx | 20 - src/Components/Resource/ResourceCreate.tsx | 2 +- .../Resource/ResourceDetailsUpdate.tsx | 2 +- .../Shifting/ShiftDetailsUpdate.tsx | 6 +- src/Locale/en/Common.json | 6 +- src/Locale/en/Consultation.json | 24 +- src/Locale/en/LogUpdate.json | 82 +- src/Locale/hi/LogUpdate.json | 8 +- src/Locale/kn/LogUpdate.json | 8 +- src/Locale/ml/LogUpdate.json | 8 +- src/Locale/ta/LogUpdate.json | 8 +- src/Utils/utils.ts | 7 - src/style/index.css | 3 +- 80 files changed, 2028 insertions(+), 1713 deletions(-) delete mode 100644 cypress/e2e/patient_spec/PatientDoctorNotes.cy.ts create mode 100644 cypress/e2e/patient_spec/PatientManage.cy.ts delete mode 100644 cypress/pageobject/Patient/PatientDoctorNotes.ts rename src/{Components/CameraFeed => Common/hooks}/useFeedPTZ.ts (89%) create mode 100644 src/Common/hooks/useHLSPlayer.ts create mode 100644 src/Common/hooks/useMSEplayer.ts create mode 100644 src/Components/CameraFeed/usePlayer.tsx delete mode 100644 src/Components/CameraFeed/videoPlayer.tsx create mode 100644 src/Components/Facility/Consultations/Feed.tsx create mode 100644 src/Components/Facility/Consultations/FeedButton.tsx rename src/Components/{CameraFeed/CameraFeedOld.tsx => Facility/Consultations/LiveFeed.tsx} (83%) diff --git a/cypress/e2e/patient_spec/PatientDoctorNotes.cy.ts b/cypress/e2e/patient_spec/PatientDoctorNotes.cy.ts deleted file mode 100644 index 5b0ceac0c8e..00000000000 --- a/cypress/e2e/patient_spec/PatientDoctorNotes.cy.ts +++ /dev/null @@ -1,61 +0,0 @@ -import LoginPage from "../../pageobject/Login/LoginPage"; -import { PatientPage } from "../../pageobject/Patient/PatientCreation"; -import { PatientDoctorNotes } from "../../pageobject/Patient/PatientDoctorNotes"; - -describe("Patient Discussion notes in the consultation page", () => { - const loginPage = new LoginPage(); - const patientPage = new PatientPage(); - const patientDoctorNotes = new PatientDoctorNotes(); - const patientName = "Dummy Patient 4"; - const patientNurseNote = "Test nurse Notes"; - const patientNurseReplyNote = "Test nurse reply Notes"; - const discussionNotesSubscribeWarning = - "Please subscribe to notifications to get live updates on discussion notes."; - const discussionNotesSuccessMessage = "Note added successfully"; - - before(() => { - loginPage.loginAsDisctrictAdmin(); - cy.saveLocalStorage(); - }); - - beforeEach(() => { - cy.restoreLocalStorage(); - cy.clearLocalStorage(/filters--.+/); - cy.awaitUrl("/patients"); - }); - - it("Create a nurse note for a patient and verify both ID received the messages", () => { - // Create a doctor notes a with a district admin - patientPage.visitPatient(patientName); - patientDoctorNotes.visitDiscussionNotesPage(); - cy.verifyNotification(discussionNotesSubscribeWarning); - cy.closeNotification(); - // switch the switch to nurse note, as the bydefault is doctornotes - patientDoctorNotes.selectNurseDiscussion(); - patientDoctorNotes.addDiscussionNotes(patientNurseNote); - patientDoctorNotes.postDiscussionNotes(); - cy.verifyNotification(discussionNotesSuccessMessage); - cy.closeNotification(); - // verify the auto-switching of tab to nurse notes if the user is a nurse - cy.get("p").contains("Sign Out").click(); - loginPage.loginManuallyAsNurse(); - loginPage.ensureLoggedIn(); - cy.visit("/patients"); - patientPage.visitPatient(patientName); - patientDoctorNotes.visitDiscussionNotesPage(); - // verify the message is received from admin - cy.verifyNotification(discussionNotesSubscribeWarning); - cy.closeNotification(); - patientDoctorNotes.verifyDiscussionMessage(patientNurseNote); - // Post a reply comment to the message - patientDoctorNotes.addDiscussionNotes(patientNurseReplyNote); - patientDoctorNotes.postDiscussionNotes(); - cy.verifyNotification(discussionNotesSuccessMessage); - cy.closeNotification(); - patientDoctorNotes.verifyDiscussionMessage(patientNurseReplyNote); - }); - - afterEach(() => { - cy.saveLocalStorage(); - }); -}); diff --git a/cypress/e2e/patient_spec/PatientLogUpdate.cy.ts b/cypress/e2e/patient_spec/PatientLogUpdate.cy.ts index 923d9410657..773b0087d80 100644 --- a/cypress/e2e/patient_spec/PatientLogUpdate.cy.ts +++ b/cypress/e2e/patient_spec/PatientLogUpdate.cy.ts @@ -55,7 +55,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.selectPatientCategory(patientCategory); patientLogupdate.selectRoundType("Detailed Update"); cy.submitButton("Save and Continue"); - cy.verifyNotification("Detailed Update created successfully"); + cy.verifyNotification("Detailed Update log created successfully"); cy.closeNotification(); // Select two Section - First One is Respiratory Support patientLogupdate.selectCriticalCareSection("Respiratory Support"); @@ -162,7 +162,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { cy.closeNotification(); // Submit the doctors log update cy.submitButton("Save and Continue"); - cy.verifyNotification("Progress Note created successfully"); + cy.verifyNotification("Progress Note log created successfully"); cy.closeNotification(); // modify the relevant critical care log update patientLogupdate.selectCriticalCareSection("Neurological Monitoring"); @@ -192,7 +192,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.typeSystolic(patientModifiedSystolic); patientLogupdate.typeDiastolic(patientModifiedDiastolic); cy.submitButton("Continue"); - cy.verifyNotification("Progress Note updated successfully"); + cy.verifyNotification("Progress Note log updated successfully"); }); it("Create a new TeleIcu log update for a domicilary care patient", () => { @@ -204,7 +204,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { cy.closeNotification(); patientLogupdate.clickLogupdate(); patientLogupdate.typePhysicalExamination(physicalExamination); - patientLogupdate.selectRoundType("Tele-medicine Log"); + patientLogupdate.selectRoundType("Telemedicine"); patientLogupdate.typeOtherDetails(otherExamination); patientLogupdate.selectSymptomsDate("01012024"); patientLogupdate.typeAndMultiSelectSymptoms("fe", ["Fever"]); @@ -219,7 +219,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.typeRhythm(patientRhythm); cy.get("#consciousness_level-option-RESPONDS_TO_PAIN").click(); cy.submitButton("Save"); - cy.verifyNotification("Tele-medicine Log created successfully"); + cy.verifyNotification("Telemedicine log created successfully"); }); it("Create a new Normal Log update for a domicilary care patient and edit it", () => { @@ -245,7 +245,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.typeRhythm(patientRhythm); cy.get("#consciousness_level-option-RESPONDS_TO_PAIN").click(); cy.submitButton("Save"); - cy.verifyNotification("Brief Update created successfully"); + cy.verifyNotification("Brief Update log created successfully"); cy.closeNotification(); // edit the card and verify the data. cy.contains("button", "Daily Rounds").click(); @@ -271,7 +271,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.clickClearButtonInElement("#diastolic"); patientLogupdate.typeDiastolic(patientModifiedDiastolic); cy.submitButton("Continue"); - cy.verifyNotification("Brief Update updated successfully"); + cy.verifyNotification("Brief Update log updated successfully"); cy.contains("button", "Daily Rounds").click(); patientLogupdate.clickLogUpdateViewDetails( "#dailyround-entry", @@ -307,7 +307,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { cy.get("#consciousness_level-option-RESPONDS_TO_PAIN").click(); cy.submitButton("Save"); cy.wait(2000); - cy.verifyNotification("Brief Update created successfully"); + cy.verifyNotification("Brief Update log created successfully"); // Verify the card content cy.get("#basic-information").scrollIntoView(); cy.verifyContentPresence("#encounter-symptoms", [additionalSymptoms]); @@ -330,7 +330,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.typeRespiratory(patientRespiratory); cy.get("#consciousness_level-option-RESPONDS_TO_PAIN").click(); cy.submitButton("Save"); - cy.verifyNotification("Brief Update created successfully"); + cy.verifyNotification("Brief Update log created successfully"); cy.closeNotification(); cy.verifyContentPresence("#consultation-buttons", ["9"]); // Verify the Incomplete data will give blank info @@ -340,7 +340,7 @@ describe("Patient Log Update in Normal, Critical and TeleIcu", () => { patientLogupdate.typeDiastolic(patientDiastolic); patientLogupdate.typePulse(patientPulse); cy.submitButton("Save"); - cy.verifyNotification("Brief Update created successfully"); + cy.verifyNotification("Brief Update log created successfully"); cy.closeNotification(); cy.verifyContentPresence("#consultation-buttons", ["-"]); }); diff --git a/cypress/e2e/patient_spec/PatientManage.cy.ts b/cypress/e2e/patient_spec/PatientManage.cy.ts new file mode 100644 index 00000000000..22d77aa2fda --- /dev/null +++ b/cypress/e2e/patient_spec/PatientManage.cy.ts @@ -0,0 +1,48 @@ +import LoginPage from "../../pageobject/Login/LoginPage"; +import { PatientConsultationPage } from "../../pageobject/Patient/PatientConsultation"; +import { PatientPage } from "../../pageobject/Patient/PatientCreation"; + +describe("Patient", () => { + const loginPage = new LoginPage(); + const patientPage = new PatientPage(); + const patientConsultationPage = new PatientConsultationPage(); + + before(() => { + loginPage.loginAsDisctrictAdmin(); + cy.saveLocalStorage(); + }); + + beforeEach(() => { + cy.restoreLocalStorage(); + cy.clearLocalStorage(/filters--.+/); + cy.awaitUrl("/patients"); + }); + + // it("Create Patient shift requests.", () => { + // patientPage.visitPatient(); + // patientConsultationPage.visitShiftRequestPage(); + // patientConsultationPage.enterPatientShiftDetails( + // "Test User", + // phone_number, + // "Dummy Shifting", + // "Reason" + // ); + // patientConsultationPage.createShiftRequest(); + // patientConsultationPage.verifySuccessNotification( + // "Shift request created successfully" + // ); + // }); + // commented out the shifting request, as logic need to be re-visited + + it("Post discussion notes for an already created patient", () => { + patientPage.visitPatient("Dummy Patient 3"); + patientConsultationPage.visitDoctorNotesPage(); + patientConsultationPage.addDoctorsNotes("Test Doctor Notes"); + patientConsultationPage.postDoctorNotes(); + cy.verifyNotification("Note added successfully"); + }); + + afterEach(() => { + cy.saveLocalStorage(); + }); +}); diff --git a/cypress/pageobject/Asset/AssetFilters.ts b/cypress/pageobject/Asset/AssetFilters.ts index ac83ca97c0b..33363f2d161 100644 --- a/cypress/pageobject/Asset/AssetFilters.ts +++ b/cypress/pageobject/Asset/AssetFilters.ts @@ -3,7 +3,7 @@ export class AssetFilters { facilityName: string, assetStatus: string, assetClass: string, - assetLocation: string, + assetLocation: string ) { cy.contains("Advanced Filters").click(); cy.get("input[name='Facilities']") diff --git a/cypress/pageobject/Asset/AssetSearch.ts b/cypress/pageobject/Asset/AssetSearch.ts index 653c7bdd89c..b1ccb2f71c9 100644 --- a/cypress/pageobject/Asset/AssetSearch.ts +++ b/cypress/pageobject/Asset/AssetSearch.ts @@ -17,7 +17,7 @@ export class AssetSearchPage { verifyBadgeContent(expectedText: string) { cy.get("[data-testid='Name/Serial No./QR ID']").should( "contain", - expectedText, + expectedText ); } @@ -54,7 +54,7 @@ export class AssetSearchPage { verifyAssetListContains(dummyCameraText: string) { cy.get("[data-testid='created-asset-list']").should( "contain", - dummyCameraText, + dummyCameraText ); } } diff --git a/cypress/pageobject/Login/LoginPage.ts b/cypress/pageobject/Login/LoginPage.ts index 3dfd267fab2..c75a024ae03 100644 --- a/cypress/pageobject/Login/LoginPage.ts +++ b/cypress/pageobject/Login/LoginPage.ts @@ -19,12 +19,6 @@ class LoginPage { cy.get("button").contains("Login").click(); } - loginManuallyAsNurse(): void { - cy.get("input[id='username']").click().type("dummynurse1"); - cy.get("input[id='password']").click().type("Coronasafe@123"); - cy.get("button").contains("Login").click(); - } - login(username: string, password: string): void { cy.loginByApi(username, password); } diff --git a/cypress/pageobject/Patient/PatientConsultation.ts b/cypress/pageobject/Patient/PatientConsultation.ts index cc5b8e19465..e4a9810141e 100644 --- a/cypress/pageobject/Patient/PatientConsultation.ts +++ b/cypress/pageobject/Patient/PatientConsultation.ts @@ -110,4 +110,30 @@ export class PatientConsultationPage { ); cy.wait(3000); } + + visitShiftRequestPage() { + cy.get("#create_shift_request").click(); + } + + createShiftRequest() { + cy.intercept("POST", "**/api/v1/shift/").as("createShiftRequest"); + cy.get("#submit").click(); + cy.wait("@createShiftRequest").its("response.statusCode").should("eq", 201); + } + + visitDoctorNotesPage() { + cy.get("#patient_doctor_notes").scrollIntoView(); + cy.get("#patient_doctor_notes").click(); + } + + addDoctorsNotes(notes: string) { + cy.get("#doctor_notes_textarea").scrollIntoView(); + cy.get("#doctor_notes_textarea").click().type(notes); + } + + postDoctorNotes() { + cy.intercept("POST", "**/api/v1/patient/*/notes").as("postDoctorNotes"); + cy.get("#add_doctor_note_button").click(); + cy.wait("@postDoctorNotes").its("response.statusCode").should("eq", 201); + } } diff --git a/cypress/pageobject/Patient/PatientCreation.ts b/cypress/pageobject/Patient/PatientCreation.ts index db3c10fdcb5..739601d5b56 100644 --- a/cypress/pageobject/Patient/PatientCreation.ts +++ b/cypress/pageobject/Patient/PatientCreation.ts @@ -14,7 +14,6 @@ export class PatientPage { cy.get("#name").click().type(patientName); cy.intercept("GET", "**/api/v1/consultation/**").as("getPatient"); cy.get("#patient-name-list").contains(patientName).click(); - cy.wait(2000); cy.wait("@getPatient").its("response.statusCode").should("eq", 200); cy.get("#patient-name-consultation") .should("be.visible") diff --git a/cypress/pageobject/Patient/PatientDoctorNotes.ts b/cypress/pageobject/Patient/PatientDoctorNotes.ts deleted file mode 100644 index 9538b0eed3b..00000000000 --- a/cypress/pageobject/Patient/PatientDoctorNotes.ts +++ /dev/null @@ -1,29 +0,0 @@ -export class PatientDoctorNotes { - visitDiscussionNotesPage() { - cy.get("#patient_discussion_notes").scrollIntoView(); - cy.get("#patient_discussion_notes").click(); - } - - addDiscussionNotes(notes: string) { - cy.wait(2000); - cy.get("#discussion_notes_textarea").scrollIntoView(); - cy.get("#discussion_notes_textarea").click().type(notes); - } - - selectNurseDiscussion() { - cy.get("#patient-note-tab-Nurses").scrollIntoView(); - cy.get("#patient-note-tab-Nurses").click(); - } - - verifyDiscussionMessage(text: string) { - cy.get("#patient-notes-list").contains(text); - } - - postDiscussionNotes() { - cy.intercept("POST", "**/api/v1/patient/*/notes").as("postDiscussionNotes"); - cy.get("#add_doctor_note_button").click(); - cy.wait("@postDiscussionNotes") - .its("response.statusCode") - .should("eq", 201); - } -} diff --git a/cypress/pageobject/Patient/PatientInsurance.ts b/cypress/pageobject/Patient/PatientInsurance.ts index 60eaefffa44..be4c25c5535 100644 --- a/cypress/pageobject/Patient/PatientInsurance.ts +++ b/cypress/pageobject/Patient/PatientInsurance.ts @@ -2,7 +2,7 @@ class PatientInsurance { typePatientInsuranceDetail( containerId: string, fieldId: string, - value: string, + value: string ) { cy.get(`#${containerId}`).within(() => { cy.get(`#${fieldId}`).click().type(value); diff --git a/cypress/pageobject/Patient/PatientMedicalHistory.ts b/cypress/pageobject/Patient/PatientMedicalHistory.ts index a0dbdd0131a..1c9b733f3ba 100644 --- a/cypress/pageobject/Patient/PatientMedicalHistory.ts +++ b/cypress/pageobject/Patient/PatientMedicalHistory.ts @@ -31,7 +31,7 @@ class PatientMedicalHistory { patientSymptoms4, patientSymptoms5, patientSymptoms6, - patientSymptoms7, + patientSymptoms7 ) { cy.get("[data-testid=patient-details]").then(($dashboard) => { cy.url().should("include", "/facility/"); @@ -51,7 +51,7 @@ class PatientMedicalHistory { verifyNoSymptosPresent(patientSymptoms1: string) { cy.get("[data-testid=patient-details]").should( "not.contain", - patientSymptoms1, + patientSymptoms1 ); } } diff --git a/package-lock.json b/package-lock.json index 553224ffc27..dec462f006f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-markdown": "^8.0.7", "react-pdf": "^9.1.0", + "react-player": "^2.16.0", "react-redux": "^8.1.1", "react-webcam": "^7.2.0", "redux": "^4.2.1", @@ -5936,12 +5937,12 @@ } }, "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "dependencies": { - "fill-range": "^7.1.1" + "fill-range": "^7.0.1" }, "engines": { "node": ">=8" @@ -6946,7 +6947,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8377,9 +8377,9 @@ } }, "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -10942,6 +10942,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/load-script": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", + "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==" + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -12071,6 +12076,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge-refs": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz", @@ -14320,6 +14330,11 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-google-recaptcha": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", @@ -14977,6 +14992,21 @@ } } }, + "node_modules/react-player": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz", + "integrity": "sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==", + "dependencies": { + "deepmerge": "^4.0.0", + "load-script": "^1.0.0", + "memoize-one": "^5.1.1", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.0.1" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", @@ -15622,9 +15652,9 @@ } }, "node_modules/requirejs": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.7.tgz", - "integrity": "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz", + "integrity": "sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==", "dev": true, "bin": { "r_js": "bin/r.js", @@ -19228,4 +19258,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index cfc0b3389a2..8dcd4e13954 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-markdown": "^8.0.7", "react-pdf": "^9.1.0", + "react-player": "^2.16.0", "react-redux": "^8.1.1", "react-webcam": "^7.2.0", "redux": "^4.2.1", diff --git a/scripts/generate-build-version.js b/scripts/generate-build-version.js index 84b8e0e5928..36e7ea1162a 100644 --- a/scripts/generate-build-version.js +++ b/scripts/generate-build-version.js @@ -20,5 +20,5 @@ fs.writeFile( return console.log(err); } return null; - }, + } ); diff --git a/src/Common/constants.tsx b/src/Common/constants.tsx index 74150a53e4f..681fe874475 100644 --- a/src/Common/constants.tsx +++ b/src/Common/constants.tsx @@ -773,71 +773,28 @@ export const MOTOR_RESPONSE_SCALE = [ { value: 5, text: "Moves to localized pain" }, { value: 6, text: "Obeying commands/Normal acrivity" }, ]; +export const CONSULTATION_TABS = [ + { text: "UPDATES", desc: "Overview" }, + { text: "FEED", desc: "Feed" }, + { text: "SUMMARY", desc: "Vitals" }, + { text: "ABG", desc: "ABG" }, + { text: "MEDICINES", desc: "Medicines" }, + { text: "FILES", desc: "Files" }, + { text: "INVESTIGATIONS", desc: "Investigations" }, + { text: "NEUROLOGICAL_MONITORING", desc: "Neuro" }, + { text: "VENTILATOR", desc: "Ventilation" }, + { text: "NUTRITION", desc: "Nutrition" }, + { text: "PRESSURE_SORE", desc: "Pressure Sore" }, + { text: "NURSING", desc: "Nursing" }, + { text: "DIALYSIS", desc: "Dialysis" }, + { text: "ABDM", desc: "ABDM Records" }, +]; export const RHYTHM_CHOICES = [ { id: 5, text: "REGULAR", desc: "Regular" }, { id: 10, text: "IRREGULAR", desc: "Irregular" }, ] as const; -export const BOWEL_ISSUE_CHOICES = [ - "NO_DIFFICULTY", - "CONSTIPATION", - "DIARRHOEA", -] as const; - -export const BLADDER_DRAINAGE_CHOICES = [ - "NORMAL", - "CONDOM_CATHETER", - "DIAPER", - "INTERMITTENT_CATHETER", - "CONTINUOUS_INDWELLING_CATHETER", - "CONTINUOUS_SUPRAPUBIC_CATHETER", - "UROSTOMY", -] as const; - -export const BLADDER_ISSUE_CHOICES = [ - "NO_ISSUES", - "INCONTINENCE", - "RETENTION", - "HESITANCY", -] as const; - -export const URINATION_FREQUENCY_CHOICES = [ - "NORMAL", - "DECREASED", - "INCREASED", -] as const; - -export const SLEEP_CHOICES = [ - "EXCESSIVE", - "SATISFACTORY", - "UNSATISFACTORY", - "NO_SLEEP", -] as const; - -export const NUTRITION_ROUTE_CHOICES = [ - "ORAL", - "RYLES_TUBE", - "GASTROSTOMY_OR_JEJUNOSTOMY", - "PEG", - "PARENTERAL_TUBING_FLUID", - "PARENTERAL_TUBING_TPN", -] as const; - -export const ORAL_ISSUE_CHOICES = [ - "NO_ISSUE", - "DYSPHAGIA", - "ODYNOPHAGIA", -] as const; - -export const APPETITE_CHOICES = [ - "INCREASED", - "SATISFACTORY", - "REDUCED", - "NO_TASTE_FOR_FOOD", - "CANNOT_BE_ASSESSED", -] as const; - export const LOCATION_BED_TYPES = [ { id: "ISOLATION", name: "Isolation" }, { id: "ICU", name: "ICU" }, diff --git a/src/Components/CameraFeed/useFeedPTZ.ts b/src/Common/hooks/useFeedPTZ.ts similarity index 89% rename from src/Components/CameraFeed/useFeedPTZ.ts rename to src/Common/hooks/useFeedPTZ.ts index fb704baf972..a393edc5922 100644 --- a/src/Components/CameraFeed/useFeedPTZ.ts +++ b/src/Common/hooks/useFeedPTZ.ts @@ -17,11 +17,33 @@ interface PTZPayload { zoom: number; } +export interface PTZState { + x: number; + y: number; + zoom: number; + precision: number; +} + interface UseMSEMediaPlayerOption { config: IAsset; dispatch: any; } +export interface ICameraAssetState { + id: string; + username: string; + password: string; + hostname: string; + port: number; +} + +export enum StreamStatus { + Playing, + Stop, + Loading, + Offline, +} + interface UseMSEMediaPlayerReturnType { absoluteMove: (payload: PTZPayload, options: IOptions) => void; relativeMove: (payload: PTZPayload, options: IOptions) => void; @@ -31,7 +53,6 @@ interface UseMSEMediaPlayerReturnType { value?: number, ) => PTZPayload; getCameraStatus: (options: IOptions) => void; - getStreamToken: (options: IOptions) => void; getPresets: (options: IOptions) => void; gotoPreset: (payload: IGotoPresetPayload, options: IOptions) => void; } @@ -67,23 +88,6 @@ const getCameraStatus = : options?.onError && options.onError(resp)); }; -const getStreamToken = - (config: IAsset, dispatch: any) => - async (options: IOptions = {}) => { - if (!config.id) return; - const resp = await dispatch( - operateAsset(config.id, { - action: { - type: "get_stream_token", - }, - }), - ); - resp && - (resp.status === 200 - ? options?.onSuccess && options.onSuccess(resp.data.result) - : options?.onError && options.onError(resp)); - }; - const getPresets = (config: IAsset, dispatch: any) => async (options: IOptions = {}) => { @@ -201,7 +205,6 @@ export const useFeedPTZ = ({ relativeMove: relativeMove(config, dispatch), getPTZPayload, getCameraStatus: getCameraStatus(config, dispatch), - getStreamToken: getStreamToken(config, dispatch), getPresets: getPresets(config, dispatch), gotoPreset: gotoPreset(config, dispatch), }; diff --git a/src/Common/hooks/useHLSPlayer.ts b/src/Common/hooks/useHLSPlayer.ts new file mode 100644 index 00000000000..32a52043fe7 --- /dev/null +++ b/src/Common/hooks/useHLSPlayer.ts @@ -0,0 +1,17 @@ +import ReactPlayer from "react-player"; +import { IOptions } from "./useMSEplayer"; + +export const useHLSPLayer = (ref: ReactPlayer | null) => { + const startStream = ({ onSuccess, onError }: IOptions = {}) => { + try { + ref?.setState({ url: ref?.props.url + "&t=" + Date.now() }); + onSuccess && onSuccess(undefined); + } catch (err) { + onError && onError(err); + } + }; + return { + startStream, + stopStream: undefined, + }; +}; diff --git a/src/Common/hooks/useMSEplayer.ts b/src/Common/hooks/useMSEplayer.ts new file mode 100644 index 00000000000..5271c08fd56 --- /dev/null +++ b/src/Common/hooks/useMSEplayer.ts @@ -0,0 +1,236 @@ +import { useEffect, useRef } from "react"; + +export interface IAsset { + middlewareHostname: string; +} + +interface UseMSEMediaPlayerOption { + config: IAsset; + url?: string; + videoEl: HTMLVideoElement | null; +} + +export interface ICameraAssetState { + id: string; + accessKey: string; + middleware_address: string; + location_middleware: string; +} + +export enum StreamStatus { + Playing, + Stop, + Loading, + Offline, +} + +interface UseMSEMediaPlayerReturnType { + stopStream: (config: { id: string }, options: IOptions) => void; + startStream: (options?: IOptions) => void; +} + +export interface IOptions { + onSuccess?: (resp: any) => void; + onError?: (err: any) => void; +} +const stopStream = + ({ + middlewareHostname, + ws, + }: { + middlewareHostname: string; + ws?: WebSocket; + }) => + (payload: { id: string }, options: IOptions) => { + const { id } = payload; + ws?.close(); + fetch(`https://${middlewareHostname}/stop`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id }), + }) + .then((res) => { + if (!res.ok) { + throw new Error("network response was not ok"); + } + return res.json(); + }) + .then((res) => options?.onSuccess && options.onSuccess(res)) + .catch((err) => options.onError && options.onError(err)); + }; + +/** + * MSE player utility + */ +const Utf8ArrayToStr = (array: string | any[] | Uint8Array) => { + let out, i, c; + let char2, char3; + out = ""; + const len = array.length; + i = 0; + while (i < len) { + c = array[i++]; + switch (c >> 4) { + case 7: + out += String.fromCharCode(c); + break; + case 13: + char2 = array[i++]; + out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f)); + break; + case 14: + char2 = array[i++]; + char3 = array[i++]; + out += String.fromCharCode( + ((c & 0x0f) << 12) | ((char2 & 0x3f) << 6) | ((char3 & 0x3f) << 0), + ); + break; + } + } + return out; +}; + +export const useMSEMediaPlayer = ({ + config, + url, + videoEl, +}: UseMSEMediaPlayerOption): UseMSEMediaPlayerReturnType => { + const mseQueue: any[] = []; + let mseStreamingStarted = false; + const wsRef = useRef(); + let mseSourceBuffer: any; + + const pushPacket = () => { + if (!mseSourceBuffer.updating) { + if (mseQueue.length > 0) { + const packet = mseQueue.shift(); + // Check if SourceBuffer has been removed before appending buffer + if (mseSourceBuffer.removed) { + console.error("Attempted to append to a removed SourceBuffer."); + return; + } + mseSourceBuffer.appendBuffer(packet); + } else { + mseStreamingStarted = false; + } + } + if (videoEl && videoEl.buffered.length > 0) { + if (typeof document.hidden !== "undefined" && document.hidden) { + //no sound, browser paused video without sound in background + videoEl.currentTime = + videoEl.buffered.end(videoEl.buffered.length - 1) - 0.5; + } + } + }; + + const readPacket = (packet: any) => { + if (!mseStreamingStarted) { + // Check if SourceBuffer has been removed before appending buffer + if (mseSourceBuffer.removed) { + console.error("Attempted to append to a removed SourceBuffer."); + return; + } + mseSourceBuffer.appendBuffer(packet); + mseStreamingStarted = true; + return; + } + mseQueue.push(packet); + if (!mseSourceBuffer.updating) { + pushPacket(); + } + }; + + const startStream = ({ onError, onSuccess }: IOptions = {}) => { + // location.protocol == 'https:' ? protocol = 'wss' : protocol = 'ws'; + try { + wsRef.current?.close(); + const mse = new MediaSource(); + if (videoEl) { + videoEl.src = window.URL.createObjectURL(mse); + } + + if (url) { + mse.addEventListener( + "sourceopen", + function () { + wsRef.current = new WebSocket(url); + const ws = wsRef.current; + ws.binaryType = "arraybuffer"; + ws.onopen = function (_event) { + onSuccess && onSuccess(undefined); + }; + ws.onmessage = function (event) { + const data = new Uint8Array(event.data); + if (+data[0] === 9) { + const decoded_arr = data.slice(1); + let mimeCodec; + if (window.TextDecoder) { + mimeCodec = new TextDecoder("utf-8").decode(decoded_arr); + } else { + mimeCodec = Utf8ArrayToStr(decoded_arr); + } + try { + mseSourceBuffer = mse.addSourceBuffer( + `video/mp4; codecs="${mimeCodec}"`, + ); + } catch (error) { + onError?.(error); + return; + } + mseSourceBuffer.mode = "segments"; + if (mseQueue.length > 0 && !mseSourceBuffer.updating) { + mseSourceBuffer.addEventListener("updateend", pushPacket); + } + } else { + readPacket(event.data); + } + }; + ws.onerror = function (event) { + onError && onError(event); + }; + }, + false, + ); + } + } catch (e) { + onError && onError(e); + } + }; + + document.addEventListener("DOMContentLoaded", function () { + if (videoEl) { + videoEl.addEventListener("loadeddata", () => { + videoEl.play(); + }); + + //fix stalled video in safari + videoEl.addEventListener("pause", () => { + if ( + videoEl.currentTime > + videoEl.buffered.end(videoEl.buffered.length - 1) + ) { + videoEl.currentTime = + videoEl.buffered.end(videoEl.buffered.length - 1) - 0.1; + videoEl.play(); + } + }); + + videoEl.addEventListener("error", (e) => { + console.log("video_error", e); + }); + } + }); + + useEffect(() => { + return () => { + wsRef.current?.close(); + }; + }, []); + + return { + startStream: startStream, + stopStream: stopStream({ ...config, ws: wsRef.current }), + }; +}; diff --git a/src/Components/Assets/AssetType/ONVIFCamera.tsx b/src/Components/Assets/AssetType/ONVIFCamera.tsx index e49fcad549d..21beb5f7bf8 100644 --- a/src/Components/Assets/AssetType/ONVIFCamera.tsx +++ b/src/Components/Assets/AssetType/ONVIFCamera.tsx @@ -16,7 +16,6 @@ import routes from "../../../Redux/api"; import useQuery from "../../../Utils/request/useQuery"; import CareIcon from "../../../CAREUI/icons/CareIcon"; -import useOperateCamera from "../../CameraFeed/useOperateCamera"; interface Props { assetId: string; @@ -48,8 +47,6 @@ const ONVIFCamera = ({ assetId, facilityId, asset, onUpdated }: Props) => { }); const authUser = useAuthUser(); - const { operate } = useOperateCamera(assetId ?? "", true); - useEffect(() => { if (asset) { setAssetType(asset?.asset_class); @@ -95,6 +92,7 @@ const ONVIFCamera = ({ assetId, facilityId, asset, onUpdated }: Props) => { const addPreset = async (e: SyntheticEvent) => { e.preventDefault(); + const config = getCameraConfig(asset as AssetData); const data = { bed_id: bed.id, preset_name: newPreset, @@ -102,7 +100,13 @@ const ONVIFCamera = ({ assetId, facilityId, asset, onUpdated }: Props) => { try { setLoadingAddPreset(true); - const { data: presetData } = await operate({ type: "get_status" }); + const response = await fetch( + `https://${resolvedMiddleware?.hostname}/status?hostname=${config.hostname}&port=${config.port}&username=${config.username}&password=${config.password}`, + ); + if (!response.ok) { + throw new Error("Network error"); + } + const presetData = await response.json(); const { res } = await request(routes.createAssetBed, { body: { diff --git a/src/Components/Assets/configure/CameraConfigure.tsx b/src/Components/Assets/configure/CameraConfigure.tsx index e5e017db8dc..5a8ccd5c184 100644 --- a/src/Components/Assets/configure/CameraConfigure.tsx +++ b/src/Components/Assets/configure/CameraConfigure.tsx @@ -1,6 +1,6 @@ import { SyntheticEvent } from "react"; import { AssetData } from "../AssetTypes"; -import CameraFeedOld from "../../CameraFeed/CameraFeedOld"; +import LiveFeed from "../../Facility/Consultations/LiveFeed"; import { BedSelect } from "../../Common/BedSelect"; import { BedModel } from "../../Facility/models"; import { getCameraConfig } from "../../../Utils/transformUtils"; @@ -76,7 +76,7 @@ export default function CameraConfigure(props: CameraConfigureProps) { - (null); + const playerRef = useRef(null); const playerWrapperRef = useRef(null); - const [streamUrl, setStreamUrl] = useState(""); + const streamUrl = getStreamUrl(props.asset); const inlineControls = useBreakpoints({ default: false, sm: true }); + const player = usePlayer(streamUrl, playerRef); + const [isFullscreen, setFullscreen] = useFullscreen(); const [state, setState] = useState(); - const [playedOn, setPlayedOn] = useState(); - const [playerStatus, setPlayerStatus] = useState("stop"); + useEffect(() => setState(player.status), [player.status, setState]); + // Move camera when selected preset has changed useEffect(() => { async function move(preset: PTZPayload) { @@ -70,29 +73,21 @@ export default function CameraFeed(props: Props) { getPresets(props.onCameraPresetsObtained); }, [props.operate, props.onCameraPresetsObtained]); - const initializeStream = useCallback(async () => { - if (!playerRef.current) return; - setPlayerStatus("loading"); - await props - .operate({ type: "get_stream_token" }) - .then(({ res, data }) => { - if (res?.status != 200) { - setState("authentication_error"); - return props.onStreamError?.(); + const initializeStream = useCallback(() => { + player.initializeStream({ + onSuccess: async () => { + props.onStreamSuccess?.(); + const { res } = await props.operate({ type: "get_status" }); + if (res?.status === 500) { + setState("host_unreachable"); } - const result = data?.result as { token: string }; - return setStreamUrl(getStreamUrl(props.asset, result.token)); - }) - .catch(() => { - setState("host_unreachable"); - return props.onStreamError?.(); - }); - }, []); + }, + onError: props.onStreamError, + }); + }, [player.initializeStream]); // Start stream on mount - useEffect(() => { - initializeStream(); - }, []); + useEffect(() => initializeStream(), [initializeStream]); const resetStream = () => { setState("loading"); @@ -159,7 +154,7 @@ export default function CameraFeed(props: Props) { isFullscreen ? "hidden lg:flex" : "flex", "items-center justify-between px-4 py-0.5 transition-all duration-500 ease-in-out lg:py-1", (() => { - if (playerStatus !== "playing") { + if (player.status !== "playing") { return "bg-black text-zinc-400"; } @@ -173,7 +168,7 @@ export default function CameraFeed(props: Props) { >
@@ -205,74 +200,75 @@ export default function CameraFeed(props: Props) {
{/* Notifications */} - {playerStatus === "playing" && } + {player.status === "playing" && } {/* No Feed informations */} - {(() => { - switch (state) { - case "host_unreachable": - return ( - - ); - case "authentication_error": - return ( - - ); - case "offline": - return ( - - ); - } - })()} + {state === "host_unreachable" && ( + + )} + {player.status === "offline" && ( + + )} {/* Video Player */} - { - setPlayedOn(new Date()); - setState("playing"); - setPlayerStatus("playing"); - }} - onEnded={() => setPlayerStatus("stop")} - onSuccess={async () => { - props.onStreamSuccess?.(); - const { res } = await props.operate({ type: "get_status" }); - if (res?.status === 500) { - setState("host_unreachable"); - } - }} - onError={props.onStreamError} - /> - {inlineControls && playerStatus === "playing" && controls} + {isIOS ? ( +
+ } + controls={false} + pip={false} + playsinline + playing + muted + width="100%" + height="100%" + onPlay={player.onPlayCB} + onEnded={() => player.setStatus("stop")} + onError={(e, _, hlsInstance) => { + if (e === "hlsError") { + const recovered = hlsInstance.recoverMediaError(); + console.info(recovered); + } + }} + /> +
+ ) : ( +
{!inlineControls && (
> = { zooming: "l-search", saving_preset: "l-save", host_unreachable: "l-exclamation-triangle", - authentication_error: "l-exclamation-triangle", }; export default function FeedAlert({ state }: Props) { diff --git a/src/Components/CameraFeed/FeedNetworkSignal.tsx b/src/Components/CameraFeed/FeedNetworkSignal.tsx index 4b1e6c7fbe3..68df86bb4d5 100644 --- a/src/Components/CameraFeed/FeedNetworkSignal.tsx +++ b/src/Components/CameraFeed/FeedNetworkSignal.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { calculateVideoDelay } from "./utils"; import NetworkSignal from "../../CAREUI/display/NetworkSignal"; -import { StreamStatus } from "./FeedAlert"; +import { StreamStatus } from "./usePlayer"; interface Props { playerRef: React.RefObject; @@ -23,9 +23,7 @@ export default function FeedNetworkSignal(props: Props) { // 2. This value may become negative when the web-socket stream // disconnects while the tab was not in focus. if (-5 > delay || delay > 5) { - if (document.hasFocus() && props.status !== "loading") { - props.onReset(); - } + props.onReset(); } }, 1000); diff --git a/src/Components/CameraFeed/routes.ts b/src/Components/CameraFeed/routes.ts index aecbdc655fa..482dc515eb3 100644 --- a/src/Components/CameraFeed/routes.ts +++ b/src/Components/CameraFeed/routes.ts @@ -13,12 +13,6 @@ export type GetStatusResponse = { }; }; -export type GetStreamTokenResponse = { - result: { - token: string; - }; -}; - export type GetPresetsResponse = { result: Record; }; @@ -27,9 +21,7 @@ export const FeedRoutes = { operateAsset: { path: "/api/v1/asset/{id}/operate_assets/", method: "POST", - TRes: Type< - GetStreamTokenResponse | GetStatusResponse | GetPresetsResponse - >(), + TRes: Type(), TBody: Type<{ action: OperationAction }>(), }, } as const; diff --git a/src/Components/CameraFeed/useOperateCamera.ts b/src/Components/CameraFeed/useOperateCamera.ts index bfddbf5b887..c0e35f90bf2 100644 --- a/src/Components/CameraFeed/useOperateCamera.ts +++ b/src/Components/CameraFeed/useOperateCamera.ts @@ -33,10 +33,6 @@ interface RelativeMoveOperation { data: PTZPayload; } -interface GetStreamToken { - type: "get_stream_token"; -} - interface ResetFeedOperation { type: "reset"; } @@ -47,7 +43,6 @@ export type OperationAction = | GoToPresetOperation | AbsoluteMoveOperation | RelativeMoveOperation - | GetStreamToken | ResetFeedOperation; /** diff --git a/src/Components/CameraFeed/usePlayer.tsx b/src/Components/CameraFeed/usePlayer.tsx new file mode 100644 index 00000000000..7f2dc088739 --- /dev/null +++ b/src/Components/CameraFeed/usePlayer.tsx @@ -0,0 +1,58 @@ +import { MutableRefObject, useCallback, useState } from "react"; +import ReactPlayer from "react-player"; +import { isIOS } from "../../Utils/utils"; +import { useHLSPLayer } from "../../Common/hooks/useHLSPlayer"; +import { IOptions, useMSEMediaPlayer } from "../../Common/hooks/useMSEplayer"; + +export type StreamStatus = "playing" | "stop" | "loading" | "offline"; + +export default function usePlayer( + streamUrl: string, + ref: MutableRefObject, +) { + const [playedOn, setPlayedOn] = useState(); + const [status, setStatus] = useState("stop"); + + // Voluntarily disabling react-hooks/rules-of-hooks for this line as order of + // hooks is maintained (since platform won't change in runtime) + const _start = isIOS + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useHLSPLayer(ref.current as ReactPlayer).startStream + : // eslint-disable-next-line react-hooks/rules-of-hooks + useMSEMediaPlayer({ + // Voluntarily set to "" as it's used by `stopStream` only (which is not + // used by this hook) + config: { middlewareHostname: "" }, + url: streamUrl, + videoEl: ref.current as HTMLVideoElement, + }).startStream; + + const initializeStream = useCallback( + ({ onSuccess, onError }: IOptions) => { + setPlayedOn(undefined); + setStatus("loading"); + _start({ + onSuccess, + onError: (args) => { + setStatus("offline"); + onError?.(args); + }, + }); + }, + [ref.current, streamUrl], + ); + + const onPlayCB = () => { + // Voluntarily updating only if previously undefined (as this method may be invoked by the HTML video element on tab re-focus) + setPlayedOn((prev) => (prev === undefined ? new Date() : prev)); + setStatus("playing"); + }; + + return { + status, + setStatus, + initializeStream, + playedOn, + onPlayCB, + }; +} diff --git a/src/Components/CameraFeed/utils.ts b/src/Components/CameraFeed/utils.ts index 5556237d579..f4c55f315ad 100644 --- a/src/Components/CameraFeed/utils.ts +++ b/src/Components/CameraFeed/utils.ts @@ -1,6 +1,7 @@ import { MutableRefObject } from "react"; import { AssetClass, AssetData } from "../Assets/AssetTypes"; import { getCameraConfig } from "../../Utils/transformUtils"; +import { isIOS } from "../../Utils/utils"; export const calculateVideoDelay = ( ref: MutableRefObject, @@ -16,7 +17,7 @@ export const calculateVideoDelay = ( return playedDuration - video.currentTime; }; -export const getStreamUrl = (asset: AssetData, token?: string) => { +export const getStreamUrl = (asset: AssetData) => { if (asset.asset_class !== AssetClass.ONVIF) { throw "getStreamUrl can be invoked only for ONVIF Assets"; } @@ -25,5 +26,7 @@ export const getStreamUrl = (asset: AssetData, token?: string) => { const host = asset.resolved_middleware?.hostname; const uuid = config.accessKey; - return `wss://${host}/stream/${uuid}/channel/0/mse?uuid=${uuid}&channel=0${token ? `&token=${token}` : ""}`; + return isIOS + ? `https://${host}/stream/${uuid}/channel/0/hls/live/index.m3u8?uuid=${uuid}&channel=0` + : `wss://${host}/stream/${uuid}/channel/0/mse?uuid=${uuid}&channel=0`; }; diff --git a/src/Components/CameraFeed/videoPlayer.tsx b/src/Components/CameraFeed/videoPlayer.tsx deleted file mode 100644 index 9b6ad378d9f..00000000000 --- a/src/Components/CameraFeed/videoPlayer.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useEffect, useRef } from "react"; - -declare const ManagedMediaSource: typeof MediaSource; - -function isIOSVersionLessThan18() { - const ua = navigator.userAgent; - if (/iPad|iPhone|iPod/.test(ua)) { - const iOSVersionMatch = ua.match(/OS (\d+)_?(\d+)?/); - if (iOSVersionMatch && parseInt(iOSVersionMatch[1], 10) < 18) { - return true; - } - } - return false; -} - -function isSafariVersionLessThan17() { - const ua = navigator.userAgent; - if (/^((?!chrome|android).)*safari/i.test(ua)) { - const safariVersionMatch = ua.match(/Version\/(\d+)\.(\d+)/); - if (safariVersionMatch && parseInt(safariVersionMatch[1], 10) < 17) { - return true; - } - } - return false; -} - -interface VideoPlayerProps { - playerRef: React.RefObject; - streamUrl: string; - className?: string; - onPlay?: () => void; - onEnded?: () => void; - onWaiting?: () => void; - onSuccess?: (resp: any) => void; - onError?: (err: any) => void; -} - -export default function VideoPlayer(props: VideoPlayerProps) { - const wsRef = useRef(); - const playerRef = props.playerRef; - let mediaSource: MediaSource; - let mseSourceBuffer: SourceBuffer; - let buf: Uint8Array; - let bufLen = 0; - - const pushPacket = () => { - if (mseSourceBuffer.updating) return; - - try { - if (bufLen > 0) { - // If there's data in the buffer to append - const data = buf.slice(0, bufLen); - bufLen = 0; // Reset buffer length - mseSourceBuffer.appendBuffer(data); // Append data to SourceBuffer - } else if (mseSourceBuffer.buffered && mseSourceBuffer.buffered.length) { - // If no new data to append, check if there's buffered data in SourceBuffer - const end = - mseSourceBuffer.buffered.end(mseSourceBuffer.buffered.length - 1) - - 15; - const start = mseSourceBuffer.buffered.start(0); - if (end > start) { - // Remove older data from the SourceBuffer to free up space - mseSourceBuffer.remove(start, end); - mediaSource.setLiveSeekableRange(end, end + 15); - } - } - } catch (e) { - console.debug(e); - props.onError?.(e); - } - }; - - const readPacket = (event: MessageEvent) => { - if (mseSourceBuffer.updating || bufLen > 0) { - // Buffer data if SourceBuffer is updating or buffer has data - const b = new Uint8Array(event.data); - buf.set(b, bufLen); - bufLen += b.byteLength; - } else { - try { - // Append data directly if SourceBuffer is ready - mseSourceBuffer.appendBuffer(event.data); - } catch (e) { - console.debug(e); - props.onError?.(e); - } - } - }; - - const cleanup = () => { - console.debug("Cleaning up video player"); - if (wsRef.current) { - wsRef.current.close(); - } - if (mseSourceBuffer) { - mseSourceBuffer.abort(); - } - if (playerRef.current) { - playerRef.current.pause(); - playerRef.current.src = ""; - playerRef.current.srcObject = null; - } - }; - - const startHLS = () => { - console.debug("Broken os/browser, falling back to hls"); - try { - if (!playerRef.current || !props.streamUrl) return; - const url = new URL(props.streamUrl); - if (url.protocol === "wss:") { - url.protocol = "https:"; - } - url.pathname = url.pathname.replace("mse", "hls/live/index.m3u8"); - playerRef.current.src = url.toString(); - playerRef.current.onplaying = () => { - props.onSuccess?.(undefined); - }; - } catch (err) { - console.debug(err); - props.onError?.(err); - } - }; - - const startMSE = () => { - try { - if (!playerRef.current || !props.streamUrl) return; - if (typeof ManagedMediaSource !== "undefined") { - mediaSource = new ManagedMediaSource(); - playerRef.current.disableRemotePlayback = true; - playerRef.current.srcObject = mediaSource; - } else { - mediaSource = new MediaSource(); - playerRef.current.src = URL.createObjectURL(mediaSource); - } - mediaSource.onsourceopen = function () { - const ws = new WebSocket(props.streamUrl); - wsRef.current = ws; - ws.binaryType = "arraybuffer"; - ws.onopen = (_) => props.onSuccess?.(undefined); - ws.onerror = (event) => props.onError?.(event); - ws.onmessage = function (event) { - const data = new Uint8Array(event.data); - // First packet is the codec type - if (+data[0] === 9) { - const mimeCodec = new TextDecoder("utf-8").decode(data.slice(1)); - try { - mseSourceBuffer = mediaSource.addSourceBuffer( - `video/mp4; codecs="${mimeCodec}"`, - ); - } catch (error) { - props.onError?.(error); - return; - } - buf = new Uint8Array(2 * 1024 * 1024); - mseSourceBuffer.mode = "segments"; - mseSourceBuffer.onupdateend = pushPacket; - // switch to readPacket after creating SourceBuffer - ws.onmessage = readPacket; - } else { - readPacket(event); - } - }; - }; - } catch (err) { - console.debug(err); - } - }; - - useEffect(() => { - // if the device is ios < 18 or safari < 17 then fallback to hls - if (isIOSVersionLessThan18() || isSafariVersionLessThan17()) { - startHLS(); - } else { - startMSE(); - } - return () => { - cleanup(); - }; - }, [props.streamUrl]); - - return ( - <> -