committed by
dermotduffy
parent
591f466b92
commit
31d0c9322f
@@ -6,7 +6,6 @@ import {
|
|||||||
EventViewMedia,
|
EventViewMedia,
|
||||||
RecordingViewMedia,
|
RecordingViewMedia,
|
||||||
ReviewViewMedia,
|
ReviewViewMedia,
|
||||||
VideoContentType,
|
|
||||||
ViewMedia,
|
ViewMedia,
|
||||||
ViewMediaType,
|
ViewMediaType,
|
||||||
} from '../../view/item';
|
} from '../../view/item';
|
||||||
@@ -60,9 +59,6 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
|||||||
// progress.
|
// progress.
|
||||||
return !this.getEndTime();
|
return !this.getEndTime();
|
||||||
}
|
}
|
||||||
public getVideoContentType(): VideoContentType | null {
|
|
||||||
return VideoContentType.HLS;
|
|
||||||
}
|
|
||||||
public getID(): string {
|
public getID(): string {
|
||||||
return this._event.id;
|
return this._event.id;
|
||||||
}
|
}
|
||||||
@@ -141,9 +137,6 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
|||||||
// progress.
|
// progress.
|
||||||
return !this.getEndTime();
|
return !this.getEndTime();
|
||||||
}
|
}
|
||||||
public getVideoContentType(): VideoContentType | null {
|
|
||||||
return VideoContentType.HLS;
|
|
||||||
}
|
|
||||||
public getContentID(): string | null {
|
public getContentID(): string | null {
|
||||||
return this._contentID;
|
return this._contentID;
|
||||||
}
|
}
|
||||||
@@ -186,9 +179,6 @@ export class FrigateReviewViewMedia extends ViewMedia implements ReviewViewMedia
|
|||||||
public inProgress(): boolean | null {
|
public inProgress(): boolean | null {
|
||||||
return !this.getEndTime();
|
return !this.getEndTime();
|
||||||
}
|
}
|
||||||
public getVideoContentType(): VideoContentType | null {
|
|
||||||
return VideoContentType.HLS;
|
|
||||||
}
|
|
||||||
public getContentID(): string | null {
|
public getContentID(): string | null {
|
||||||
return this._contentID;
|
return this._contentID;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ import { ViewerConfig } from '../../config/schema/viewer.js';
|
|||||||
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
|
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
|
||||||
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
|
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import '../../patches/ha-hls-player.js';
|
import '../../patches/ha-hls-player.js';
|
||||||
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
||||||
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
||||||
|
import { classifyMimeType } from '../../utils/mime-type.js';
|
||||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||||
import { VideoContentType, ViewMedia } from '../../view/item.js';
|
import { ViewMedia } from '../../view/item.js';
|
||||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
|
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
|
||||||
import '../image-player.js';
|
import '../image-player.js';
|
||||||
import { renderNotificationBlockFromText } from '../notification/block.js';
|
import { renderNotificationBlockFromText } from '../notification/block.js';
|
||||||
@@ -62,23 +63,23 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||||
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
||||||
|
|
||||||
private _resolvedMediaURL: string | null = null;
|
private _resolvedMedia: ResolvedMedia | null = null;
|
||||||
|
|
||||||
private _signedURLController = new SignedURLController(this, () => {
|
private _signedURLController = new SignedURLController(this, () => {
|
||||||
if (!this.hass || !this._resolvedMediaURL) {
|
if (!this.hass || !this._resolvedMedia) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
// HA-relative URLs need no proxying or signing.
|
// HA-relative URLs need no proxying or signing.
|
||||||
if (isHARelativeURL(this._resolvedMediaURL)) {
|
if (isHARelativeURL(this._resolvedMedia.url)) {
|
||||||
return {
|
return {
|
||||||
endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMediaURL) },
|
endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMedia.url) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const cameraID = this.media?.getCameraID();
|
const cameraID = this.media?.getCameraID();
|
||||||
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
|
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
|
||||||
return {
|
return {
|
||||||
hass: this.hass,
|
hass: this.hass,
|
||||||
endpoint: { endpoint: this._resolvedMediaURL },
|
endpoint: { endpoint: this._resolvedMedia.url },
|
||||||
proxyConfig: camera?.getMediaProxyConfig(),
|
proxyConfig: camera?.getMediaProxyConfig(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -128,19 +129,18 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
private async _resolveURL(): Promise<void> {
|
private async _resolveURL(): Promise<void> {
|
||||||
const contentID = this.media?.getContentID();
|
const contentID = this.media?.getContentID();
|
||||||
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
|
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
|
||||||
this._resolvedMediaURL = null;
|
this._resolvedMedia = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear immediately so the SignedURLController doesn't see a stale URL
|
// Clear immediately so the SignedURLController doesn't see a stale URL
|
||||||
// from the previous media item during the async gap.
|
// from the previous media item during the async gap.
|
||||||
this._resolvedMediaURL = null;
|
this._resolvedMedia = null;
|
||||||
|
|
||||||
const resolved =
|
this._resolvedMedia =
|
||||||
this.resolvedMediaCache?.get(contentID) ??
|
this.resolvedMediaCache?.get(contentID) ??
|
||||||
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache));
|
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ??
|
||||||
|
null;
|
||||||
this._resolvedMediaURL = resolved?.url ?? null;
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +251,11 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
// Note: crossorigin="anonymous" is required on <video> below in order to
|
// Note: crossorigin="anonymous" is required on <video> below in order to
|
||||||
// allow screenshot of motionEye videos which currently go cross-origin.
|
// allow screenshot of motionEye videos which currently go cross-origin.
|
||||||
const mediaID = this.media.getID() ?? undefined;
|
const mediaID = this.media.getID() ?? undefined;
|
||||||
|
const { isHLS, isVideo } = classifyMimeType(this._resolvedMedia?.mime_type);
|
||||||
|
|
||||||
return this._renderContainer(html`
|
return this._renderContainer(html`
|
||||||
${ViewItemClassifier.isVideo(this.media)
|
${isVideo
|
||||||
? this.media.getVideoContentType() === VideoContentType.HLS
|
? isHLS
|
||||||
? html`<advanced-camera-card-ha-hls-player
|
? html`<advanced-camera-card-ha-hls-player
|
||||||
${ref(this._refProvider)}
|
${ref(this._refProvider)}
|
||||||
allow-exoplayer
|
allow-exoplayer
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { FolderConfig } from '../../config/schema/folders';
|
|||||||
import { formatDateAndTime } from '../../utils/basic';
|
import { formatDateAndTime } from '../../utils/basic';
|
||||||
import {
|
import {
|
||||||
EventViewMedia,
|
EventViewMedia,
|
||||||
VideoContentType,
|
|
||||||
ViewFolder,
|
ViewFolder,
|
||||||
ViewMedia,
|
ViewMedia,
|
||||||
ViewMediaSourceOptions,
|
ViewMediaSourceOptions,
|
||||||
@@ -78,9 +77,6 @@ export class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMed
|
|||||||
public getEndTime(): Date | null {
|
public getEndTime(): Date | null {
|
||||||
return this._browseMedia._metadata?.endDate ?? null;
|
return this._browseMedia._metadata?.endDate ?? null;
|
||||||
}
|
}
|
||||||
public getVideoContentType(): VideoContentType | null {
|
|
||||||
return this._mediaType === ViewMediaType.Clip ? VideoContentType.MP4 : null;
|
|
||||||
}
|
|
||||||
public getID(): string {
|
public getID(): string {
|
||||||
return this._id;
|
return this._id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
interface MimeTypeClassification {
|
||||||
|
isHLS: boolean;
|
||||||
|
isVideo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a MIME type for player selection.
|
||||||
|
*
|
||||||
|
* RFC 6838 declares MIME types case-insensitive (e.g. Frigate emits
|
||||||
|
* `application/x-mpegURL`), so input is normalized before comparison.
|
||||||
|
*/
|
||||||
|
export const classifyMimeType = (mimeType?: string): MimeTypeClassification => {
|
||||||
|
const normalized = mimeType?.toLowerCase();
|
||||||
|
const isHLS =
|
||||||
|
normalized === 'application/vnd.apple.mpegurl' ||
|
||||||
|
normalized === 'application/x-mpegurl';
|
||||||
|
const isVideo = isHLS || !!normalized?.startsWith('video/');
|
||||||
|
return { isHLS, isVideo };
|
||||||
|
};
|
||||||
@@ -10,11 +10,6 @@ export enum ViewMediaType {
|
|||||||
Review = 'review',
|
Review = 'review',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum VideoContentType {
|
|
||||||
MP4 = 'mp4',
|
|
||||||
HLS = 'hls',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewMediaSourceOptions {
|
export interface ViewMediaSourceOptions {
|
||||||
cameraID?: string;
|
cameraID?: string;
|
||||||
folder?: FolderConfig;
|
folder?: FolderConfig;
|
||||||
@@ -40,9 +35,6 @@ export class ViewMedia {
|
|||||||
public getMediaType(): ViewMediaType {
|
public getMediaType(): ViewMediaType {
|
||||||
return this._mediaType;
|
return this._mediaType;
|
||||||
}
|
}
|
||||||
public getVideoContentType(): VideoContentType | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
public getID(): string | null {
|
public getID(): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,17 +74,6 @@ describe('FrigateEventViewMedia', () => {
|
|||||||
expect(media.inProgress()).toBe(false);
|
expect(media.inProgress()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get video content type as HLS', () => {
|
|
||||||
const media = new FrigateEventViewMedia(
|
|
||||||
ViewMediaType.Clip,
|
|
||||||
'camera',
|
|
||||||
createFrigateEvent(),
|
|
||||||
'content_id',
|
|
||||||
'thumbnail',
|
|
||||||
);
|
|
||||||
expect(media.getVideoContentType()).toBe('hls');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get ID', () => {
|
it('should get ID', () => {
|
||||||
const event = createFrigateEvent({
|
const event = createFrigateEvent({
|
||||||
id: 'test-event-id',
|
id: 'test-event-id',
|
||||||
@@ -384,18 +373,6 @@ describe('FrigateRecordingViewMedia', () => {
|
|||||||
expect(media.inProgress()).toBe(false);
|
expect(media.inProgress()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get video content type as HLS', () => {
|
|
||||||
const media = new FrigateRecordingViewMedia(
|
|
||||||
ViewMediaType.Recording,
|
|
||||||
'camera',
|
|
||||||
createFrigateRecording(),
|
|
||||||
'id',
|
|
||||||
'content_id',
|
|
||||||
'title',
|
|
||||||
);
|
|
||||||
expect(media.getVideoContentType()).toBe('hls');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get content ID', () => {
|
it('should get content ID', () => {
|
||||||
const media = new FrigateRecordingViewMedia(
|
const media = new FrigateRecordingViewMedia(
|
||||||
ViewMediaType.Recording,
|
ViewMediaType.Recording,
|
||||||
@@ -476,16 +453,6 @@ describe('FrigateReviewViewMedia', () => {
|
|||||||
expect(media.inProgress()).toBe(false);
|
expect(media.inProgress()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get video content type as HLS', () => {
|
|
||||||
const media = new FrigateReviewViewMedia(
|
|
||||||
'camera',
|
|
||||||
createFrigateReview(),
|
|
||||||
'content_id',
|
|
||||||
'thumb',
|
|
||||||
);
|
|
||||||
expect(media.getVideoContentType()).toBe('hls');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get content ID', () => {
|
it('should get content ID', () => {
|
||||||
const media = new FrigateReviewViewMedia(
|
const media = new FrigateReviewViewMedia(
|
||||||
'camera',
|
'camera',
|
||||||
|
|||||||
@@ -1017,7 +1017,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
|||||||
expect(media?.[0].getCameraID()).toBe('office');
|
expect(media?.[0].getCameraID()).toBe('office');
|
||||||
expect(media?.[0].getStartTime()).toEqual(new Date('2024-11-04T21:00:00'));
|
expect(media?.[0].getStartTime()).toEqual(new Date('2024-11-04T21:00:00'));
|
||||||
expect(media?.[0].getEndTime()).toEqual(new Date('2024-11-04T22:00:00'));
|
expect(media?.[0].getEndTime()).toEqual(new Date('2024-11-04T22:00:00'));
|
||||||
expect(media?.[0].getVideoContentType()).toBe('mp4');
|
|
||||||
expect(media?.[0].getID()).toBe('office/2024-11-04 21:00:00');
|
expect(media?.[0].getID()).toBe('office/2024-11-04 21:00:00');
|
||||||
expect(media?.[0].getContentID()).toBe(
|
expect(media?.[0].getContentID()).toBe(
|
||||||
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
BrowseMediaViewFolder,
|
BrowseMediaViewFolder,
|
||||||
} from '../../../src/ha/browse-media/item';
|
} from '../../../src/ha/browse-media/item';
|
||||||
import { formatDateAndTime } from '../../../src/utils/basic';
|
import { formatDateAndTime } from '../../../src/utils/basic';
|
||||||
import { VideoContentType, ViewMediaType } from '../../../src/view/item';
|
import { ViewMediaType } from '../../../src/view/item';
|
||||||
import {
|
import {
|
||||||
createBrowseMedia,
|
createBrowseMedia,
|
||||||
createFolder,
|
createFolder,
|
||||||
@@ -103,23 +103,6 @@ describe('BrowseMediaEventViewMedia', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should get video content type', () => {
|
|
||||||
it('should get video content type for clip', () => {
|
|
||||||
const browseMedia = createRichBrowseMedia();
|
|
||||||
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
|
|
||||||
expect(viewMedia.getVideoContentType()).toBe(VideoContentType.MP4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get null for snapshot', () => {
|
|
||||||
const browseMedia = createBrowseMedia();
|
|
||||||
const viewMedia = new BrowseMediaEventViewMedia(
|
|
||||||
ViewMediaType.Snapshot,
|
|
||||||
browseMedia,
|
|
||||||
);
|
|
||||||
expect(viewMedia.getVideoContentType()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get content ID', () => {
|
it('should get content ID', () => {
|
||||||
const browseMedia = createBrowseMedia({
|
const browseMedia = createBrowseMedia({
|
||||||
media_content_id: 'media_content_id',
|
media_content_id: 'media_content_id',
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { classifyMimeType } from '../../src/utils/mime-type';
|
||||||
|
|
||||||
|
describe('classifyMimeType', () => {
|
||||||
|
it('classifies undefined as neither video nor HLS', () => {
|
||||||
|
expect(classifyMimeType(undefined)).toEqual({ isHLS: false, isVideo: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies an empty string as neither video nor HLS', () => {
|
||||||
|
expect(classifyMimeType('')).toEqual({ isHLS: false, isVideo: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies application/vnd.apple.mpegurl as HLS and video', () => {
|
||||||
|
expect(classifyMimeType('application/vnd.apple.mpegurl')).toEqual({
|
||||||
|
isHLS: true,
|
||||||
|
isVideo: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies application/x-mpegurl as HLS and video', () => {
|
||||||
|
expect(classifyMimeType('application/x-mpegurl')).toEqual({
|
||||||
|
isHLS: true,
|
||||||
|
isVideo: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats HLS mime types as case-insensitive', () => {
|
||||||
|
expect(classifyMimeType('application/x-mpegURL')).toEqual({
|
||||||
|
isHLS: true,
|
||||||
|
isVideo: true,
|
||||||
|
});
|
||||||
|
expect(classifyMimeType('APPLICATION/VND.APPLE.MPEGURL')).toEqual({
|
||||||
|
isHLS: true,
|
||||||
|
isVideo: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies video/* as video but not HLS', () => {
|
||||||
|
expect(classifyMimeType('video/mp4')).toEqual({ isHLS: false, isVideo: true });
|
||||||
|
expect(classifyMimeType('VIDEO/WEBM')).toEqual({ isHLS: false, isVideo: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies non-video mime types as neither', () => {
|
||||||
|
expect(classifyMimeType('image/jpeg')).toEqual({ isHLS: false, isVideo: false });
|
||||||
|
expect(classifyMimeType('application/json')).toEqual({
|
||||||
|
isHLS: false,
|
||||||
|
isVideo: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-16
@@ -1,21 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import { ViewFolder, ViewMedia, ViewMediaType } from '../../src/view/item';
|
||||||
VideoContentType,
|
|
||||||
ViewFolder,
|
|
||||||
ViewMedia,
|
|
||||||
ViewMediaType,
|
|
||||||
} from '../../src/view/item';
|
|
||||||
import { createFolder, TestViewMedia } from '../test-utils';
|
import { createFolder, TestViewMedia } from '../test-utils';
|
||||||
|
|
||||||
describe('VideoContentType', () => {
|
|
||||||
it('MP4', () => {
|
|
||||||
expect(VideoContentType.MP4).toBe('mp4');
|
|
||||||
});
|
|
||||||
it('HLS', () => {
|
|
||||||
expect(VideoContentType.HLS).toBe('hls');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ViewMedia', () => {
|
describe('ViewMedia', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -27,7 +13,6 @@ describe('ViewMedia', () => {
|
|||||||
});
|
});
|
||||||
expect(media.getCameraID()).toBe('camera');
|
expect(media.getCameraID()).toBe('camera');
|
||||||
expect(media.getMediaType()).toBe('clip');
|
expect(media.getMediaType()).toBe('clip');
|
||||||
expect(media.getVideoContentType()).toBeNull();
|
|
||||||
expect(media.getID()).toBeNull();
|
expect(media.getID()).toBeNull();
|
||||||
expect(media.getStartTime()).toBeNull();
|
expect(media.getStartTime()).toBeNull();
|
||||||
expect(media.getEndTime()).toBeNull();
|
expect(media.getEndTime()).toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user