mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat(web): add support for casting (#18231)
* recreate #13966 * gcast button works * rewrote gcast-player to be GCastDestination and CastManager manages the interface between UI and casting destinations * remove unneeded imports * add "Connected to" translation * Remove css for cast launcher * fix tests * fix doc tests * fix the receiver application ID * remove casting app ID * remove cast button from nav bar It is now present at the following locations: - shared link album and single asset views - asset viewer (normal user) - album view (normal user) * part 1 of fixes from @danieldietzler code review * part 2 of code review changes from @danieldietzler and @jsram91 * cleanup documentation * onVideoStarted missing callback * add token expiry validation * cleanup logic and logging * small cleanup * rename to ICastDestination * cast button changes
This commit is contained in:
parent
12b7a079c1
commit
86db0aafe5
11
docs/docs/features/casting.md
Normal file
11
docs/docs/features/casting.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Chromecast support
|
||||
|
||||
Immich supports the Google's Cast protocol so that photos and videos can be cast to devices such as a Chromecast and a Nest Hub. This feature is considered experimental and has several important limitations listed below. Currently, this feature is only supported by the web client, support on Android and iOS is planned for the future.
|
||||
|
||||
## Limitations
|
||||
|
||||
To use casting with Immich, there are a few prerequisites:
|
||||
|
||||
1. Your instance must be accessed via an HTTPS connection in order for the casting menu to show.
|
||||
2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`)
|
||||
3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media)
|
@ -604,6 +604,7 @@
|
||||
"cannot_merge_people": "Cannot merge people",
|
||||
"cannot_undo_this_action": "You cannot undo this action!",
|
||||
"cannot_update_the_description": "Cannot update the description",
|
||||
"cast": "Cast",
|
||||
"change_date": "Change date",
|
||||
"change_description": "Change description",
|
||||
"change_display_order": "Change display order",
|
||||
@ -661,6 +662,7 @@
|
||||
"confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?",
|
||||
"confirm_new_pin_code": "Confirm new PIN code",
|
||||
"confirm_password": "Confirm password",
|
||||
"connected_to": "Connected to",
|
||||
"contain": "Contain",
|
||||
"context": "Context",
|
||||
"continue": "Continue",
|
||||
|
46
web/package-lock.json
generated
46
web/package-lock.json
generated
@ -52,6 +52,7 @@
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/svelte": "^5.2.6",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.11",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/justified-layout": "^4.1.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@ -2747,6 +2748,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.0.322",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.322.tgz",
|
||||
"integrity": "sha512-glbRm82TzLLJfi3ttlnn7HR9KIX5OYeTo9Xug0Hna03JvaqNipZT+P/q/O5kxOvUQqKUqmn8NAOrcRSG6BOQAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filesystem": "*",
|
||||
"@types/har-format": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chromecast-caf-sender": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/chromecast-caf-sender/-/chromecast-caf-sender-1.0.11.tgz",
|
||||
"integrity": "sha512-Pv3xvNYtxD/cTM/tKfuZRlLasvpxAm+CFni0GJd6Cp8XgiZS9g9tMZkR1uymsi5fIFv057SZKKAWVFFgy7fJtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chrome": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@ -2767,6 +2789,23 @@
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/filesystem": {
|
||||
"version": "0.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filewriter": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filewriter": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
@ -2782,6 +2821,13 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
@ -69,6 +69,7 @@
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/svelte": "^5.2.6",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.11",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/justified-layout": "^4.1.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
45
web/src/lib/cast/cast-button.svelte
Normal file
45
web/src/lib/cast/cast-button.svelte
Normal file
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { mdiCast, mdiCastConnected } from '@mdi/js';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { CastDestinationType, castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
whiteHover?: boolean;
|
||||
navBar?: boolean;
|
||||
}
|
||||
|
||||
let { whiteHover, navBar }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
await castManager.initialize();
|
||||
});
|
||||
|
||||
const getButtonColor = () => {
|
||||
return castManager.isCasting ? 'primary' : whiteHover ? undefined : 'opaque';
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if castManager.availableDestinations.length > 0 && castManager.availableDestinations[0].type === CastDestinationType.GCAST}
|
||||
{#if navBar}
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
color={castManager.isCasting ? 'primary' : 'secondary'}
|
||||
icon={castManager.isCasting ? mdiCastConnected : mdiCast}
|
||||
onclick={() => void GCastDestination.showCastDialog()}
|
||||
aria-label={$t('cast')}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
color={getButtonColor()}
|
||||
icon={castManager.isCasting ? mdiCastConnected : mdiCast}
|
||||
onclick={GCastDestination.showCastDialog}
|
||||
title={$t('cast')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
@ -21,6 +21,7 @@
|
||||
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
|
||||
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||
import AlbumSummary from './album-summary.svelte';
|
||||
import CastButton from '$lib/cast/cast-button.svelte';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
@ -103,6 +104,8 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet trailing()}
|
||||
<CastButton whiteHover />
|
||||
|
||||
{#if sharedLink.allowUpload}
|
||||
<CircleIconButton
|
||||
title={$t('add_photos')}
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import CastButton from '$lib/cast/cast-button.svelte';
|
||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
||||
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
|
||||
@ -116,6 +117,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto text-white" data-testid="asset-viewer-navbar-actions">
|
||||
<CastButton />
|
||||
|
||||
{#if !asset.isTrashed && $user && !isLocked}
|
||||
<ShareAction {asset} />
|
||||
{/if}
|
||||
|
@ -31,6 +31,29 @@ describe('PhotoViewer component', () => {
|
||||
beforeAll(() => {
|
||||
getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl');
|
||||
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
|
||||
|
||||
vi.stubGlobal('cast', {
|
||||
framework: {
|
||||
CastState: {
|
||||
NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE',
|
||||
},
|
||||
RemotePlayer: vi.fn().mockImplementation(() => ({})),
|
||||
RemotePlayerEventType: {
|
||||
ANY_CHANGE: 'anyChanged',
|
||||
},
|
||||
RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })),
|
||||
CastContext: {
|
||||
getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })),
|
||||
},
|
||||
CastContextEventType: {
|
||||
SESSION_STATE_CHANGED: 'sessionstatechanged',
|
||||
CAST_STATE_CHANGED: 'caststatechanged',
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('chrome', {
|
||||
cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } },
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -23,6 +23,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@ -147,6 +148,27 @@
|
||||
return AssetMediaSize.Preview;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (assetFileUrl) {
|
||||
// this can't be in an async context with $effect
|
||||
void cast(assetFileUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const cast = async (url: string) => {
|
||||
if (!url || !castManager.isCasting) {
|
||||
return;
|
||||
}
|
||||
const fullUrl = new URL(url, globalThis.location.href);
|
||||
|
||||
try {
|
||||
await castManager.loadMedia(fullUrl.href);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to cast');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
|
@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
@ -41,8 +43,8 @@
|
||||
let isScrubbing = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
||||
if (videoPlayer) {
|
||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
||||
forceMuted = false;
|
||||
videoPlayer.load();
|
||||
}
|
||||
@ -106,42 +108,53 @@
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay
|
||||
playsinline
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => {
|
||||
if (!forceMuted) {
|
||||
$videoViewerMuted = e.currentTarget.muted;
|
||||
}
|
||||
}}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={forceMuted || $videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay
|
||||
playsinline
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
use:swipe={() => ({})}
|
||||
onswipe={onSwipe}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => {
|
||||
if (!forceMuted) {
|
||||
$videoViewerMuted = e.currentTarget.muted;
|
||||
}
|
||||
}}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={forceMuted || $videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
103
web/src/lib/components/asset-viewer/video-remote-viewer.svelte
Normal file
103
web/src/lib/components/asset-viewer/video-remote-viewer.svelte
Normal file
@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { castManager, CastState } from '$lib/managers/cast-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { mdiCastConnected, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
poster: string;
|
||||
assetFileUrl: string;
|
||||
onVideoStarted: () => void;
|
||||
onVideoEnded: () => void;
|
||||
}
|
||||
|
||||
let { poster, assetFileUrl, onVideoEnded, onVideoStarted }: Props = $props();
|
||||
|
||||
let previousPlayerState: CastState | null = $state(null);
|
||||
|
||||
const handlePlayPauseButton = async () => {
|
||||
switch (castManager.castState) {
|
||||
case CastState.PLAYING: {
|
||||
castManager.pause();
|
||||
break;
|
||||
}
|
||||
case CastState.IDLE: {
|
||||
await cast(assetFileUrl, true);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
castManager.play();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (assetFileUrl) {
|
||||
// this can't be in an async context with $effect
|
||||
void cast(assetFileUrl);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (castManager.castState === CastState.IDLE && previousPlayerState !== CastState.PAUSED) {
|
||||
onVideoEnded();
|
||||
}
|
||||
|
||||
previousPlayerState = castManager.castState;
|
||||
});
|
||||
|
||||
const cast = async (url: string, force: boolean = false) => {
|
||||
if (!url || !castManager.isCasting) {
|
||||
return;
|
||||
}
|
||||
const fullUrl = new URL(url, globalThis.location.href);
|
||||
|
||||
try {
|
||||
await castManager.loadMedia(fullUrl.href, force);
|
||||
onVideoStarted();
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to cast');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
function handleSeek(event: Event) {
|
||||
const newTime = Number.parseFloat((event.target as HTMLInputElement).value);
|
||||
castManager.seekTo(newTime);
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="flex items-center space-x-2 text-gray-200 text-2xl font-bold">
|
||||
<Icon path={mdiCastConnected} class="text-primary" size="36" />
|
||||
<span>{$t('connected_to')} {castManager.receiverName}</span>
|
||||
</span>
|
||||
|
||||
<img src={poster} alt="poster" class="rounded-xl m-4" />
|
||||
|
||||
<div class="flex place-content-center place-items-center">
|
||||
{#if castManager.castState == CastState.BUFFERING}
|
||||
<div class="p-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
color="opaque"
|
||||
icon={castManager.castState == CastState.PLAYING ? mdiPause : mdiPlay}
|
||||
onclick={() => handlePlayPauseButton()}
|
||||
title={castManager.castState == CastState.PLAYING ? 'Pause' : 'Play'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={castManager.duration}
|
||||
value={castManager.currentTime ?? 0}
|
||||
onchange={handleSeek}
|
||||
class="w-full h-4 bg-primary"
|
||||
/>
|
||||
</div>
|
@ -27,6 +27,7 @@
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
import AccountInfoPanel from './account-info-panel.svelte';
|
||||
import CastButton from '$lib/cast/cast-button.svelte';
|
||||
|
||||
interface Props {
|
||||
showUploadButton?: boolean;
|
||||
@ -162,6 +163,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CastButton navBar />
|
||||
|
||||
<div
|
||||
use:clickOutside={{
|
||||
onOutclick: () => (shouldShowAccountInfoPanel = false),
|
||||
|
159
web/src/lib/managers/cast-manager.svelte.ts
Normal file
159
web/src/lib/managers/cast-manager.svelte.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte';
|
||||
import { createSession, type SessionCreateResponseDto } from '@immich/sdk';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
|
||||
// follows chrome.cast.media.PlayerState
|
||||
export enum CastState {
|
||||
IDLE = 'IDLE',
|
||||
PLAYING = 'PLAYING',
|
||||
PAUSED = 'PAUSED',
|
||||
BUFFERING = 'BUFFERING',
|
||||
}
|
||||
|
||||
export enum CastDestinationType {
|
||||
GCAST = 'GCAST',
|
||||
}
|
||||
|
||||
export interface ICastDestination {
|
||||
initialize(): Promise<boolean>; // returns if the cast destination can be used
|
||||
type: CastDestinationType; // type of cast destination
|
||||
|
||||
isAvailable: boolean; // can we use the cast destination
|
||||
isConnected: boolean; // is the cast destination actively sharing
|
||||
|
||||
currentTime: number | null; // current seek time the player is at
|
||||
duration: number | null; // duration of media
|
||||
|
||||
receiverName: string | null; // name of the cast destination
|
||||
castState: CastState; // current state of the cast destination
|
||||
|
||||
loadMedia(mediaUrl: string, sessionKey: string, reload: boolean): Promise<void>; // load media to the cast destination
|
||||
|
||||
// remote player controls
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seekTo(time: number): void;
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
class CastManager {
|
||||
private castDestinations = $state<ICastDestination[]>([]);
|
||||
private current = $derived<ICastDestination | null>(this.monitorConnectedDestination());
|
||||
|
||||
availableDestinations = $state<ICastDestination[]>([]);
|
||||
initialized = $state(false);
|
||||
|
||||
isCasting = $derived<boolean>(this.current?.isConnected ?? false);
|
||||
receiverName = $derived<string | null>(this.current?.receiverName ?? null);
|
||||
castState = $derived<CastState | null>(this.current?.castState ?? null);
|
||||
currentTime = $derived<number | null>(this.current?.currentTime ?? null);
|
||||
duration = $derived<number | null>(this.current?.duration ?? null);
|
||||
|
||||
private sessionKey: SessionCreateResponseDto | null = null;
|
||||
|
||||
constructor() {
|
||||
// load each cast destination
|
||||
this.castDestinations = [
|
||||
new GCastDestination(),
|
||||
// Add other cast destinations here (ie FCast)
|
||||
];
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// this goes first to prevent multiple calls to initialize
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
// try to initialize each cast destination
|
||||
for (const castDestination of this.castDestinations) {
|
||||
const destAvailable = await castDestination.initialize();
|
||||
if (destAvailable) {
|
||||
this.availableDestinations.push(castDestination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// monitor all cast destinations for changes
|
||||
// we want to make sure only one session is active at a time
|
||||
private monitorConnectedDestination(): ICastDestination | null {
|
||||
// check if we have a connected destination
|
||||
const connectedDest = this.castDestinations.find((dest) => dest.isConnected);
|
||||
return connectedDest || null;
|
||||
}
|
||||
|
||||
private isTokenValid() {
|
||||
// check if we already have a session token
|
||||
// we should always have a expiration date
|
||||
if (!this.sessionKey || !this.sessionKey.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenExpiration = DateTime.fromISO(this.sessionKey.expiresAt);
|
||||
|
||||
// we want to make sure we have at least 10 seconds remaining in the session
|
||||
// this is to account for network latency and other delays when sending the request
|
||||
const bufferedExpiration = tokenExpiration.minus({ seconds: 10 });
|
||||
|
||||
return bufferedExpiration > DateTime.now();
|
||||
}
|
||||
|
||||
private async refreshSessionToken() {
|
||||
// get session token to authenticate the media url
|
||||
// check and make sure we have at least 10 seconds remaining in the session
|
||||
// before we send the media request, refresh the session if needed
|
||||
if (!this.isTokenValid()) {
|
||||
this.sessionKey = await createSession({
|
||||
sessionCreateDto: {
|
||||
duration: Duration.fromObject({ minutes: 15 }).as('seconds'),
|
||||
deviceOS: 'Google Cast',
|
||||
deviceType: 'Cast',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadMedia(mediaUrl: string, reload: boolean = false) {
|
||||
if (!this.current) {
|
||||
throw new Error('No active cast destination');
|
||||
}
|
||||
|
||||
await this.refreshSessionToken();
|
||||
if (!this.sessionKey) {
|
||||
throw new Error('No session key available');
|
||||
}
|
||||
|
||||
await this.current.loadMedia(mediaUrl, this.sessionKey.token, reload);
|
||||
}
|
||||
|
||||
play() {
|
||||
this.current?.play();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.current?.pause();
|
||||
}
|
||||
|
||||
seekTo(time: number) {
|
||||
this.current?.seekTo(time);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.current?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Persist castManager across Svelte HMRs
|
||||
let castManager: CastManager;
|
||||
|
||||
if (import.meta.hot && import.meta.hot.data) {
|
||||
if (!import.meta.hot.data.castManager) {
|
||||
import.meta.hot.data.castManager = new CastManager();
|
||||
}
|
||||
castManager = import.meta.hot.data.castManager;
|
||||
} else {
|
||||
castManager = new CastManager();
|
||||
}
|
||||
|
||||
export { castManager };
|
234
web/src/lib/utils/cast/gcast-destination.svelte.ts
Normal file
234
web/src/lib/utils/cast/gcast-destination.svelte.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { CastDestinationType, CastState, type ICastDestination } from '$lib/managers/cast-manager.svelte';
|
||||
import 'chromecast-caf-sender';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
const FRAMEWORK_LINK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||
|
||||
enum SESSION_DISCOVERY_CAUSE {
|
||||
LOAD_MEDIA,
|
||||
ACTIVE_SESSION,
|
||||
}
|
||||
|
||||
export class GCastDestination implements ICastDestination {
|
||||
type = CastDestinationType.GCAST;
|
||||
isAvailable = $state<boolean>(false);
|
||||
isConnected = $state<boolean>(false);
|
||||
currentTime = $state<number | null>(null);
|
||||
duration = $state<number | null>(null);
|
||||
castState = $state<CastState>(CastState.IDLE);
|
||||
receiverName = $state<string | null>(null);
|
||||
|
||||
private remotePlayer: cast.framework.RemotePlayer | null = null;
|
||||
private session: chrome.cast.Session | null = null;
|
||||
private currentMedia: chrome.cast.media.Media | null = null;
|
||||
private currentUrl: string | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
// this is a really messy way since google does a pseudo-callbak
|
||||
// in the form of a global window event. We will give Chrome 3 seconds to respond
|
||||
// or we will mark the destination as unavailable
|
||||
|
||||
const callbackPromise: Promise<boolean> = new Promise((resolve) => {
|
||||
// check if the cast framework is already loaded
|
||||
if (this.isAvailable) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
window['__onGCastApiAvailable'] = (isAvailable: boolean) => {
|
||||
resolve(isAvailable);
|
||||
};
|
||||
|
||||
if (!document.querySelector(`script[src="${FRAMEWORK_LINK}"]`)) {
|
||||
const script = document.createElement('script');
|
||||
script.src = FRAMEWORK_LINK;
|
||||
document.body.append(script);
|
||||
}
|
||||
});
|
||||
|
||||
const timeoutPromise: Promise<boolean> = new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
resolve(false);
|
||||
},
|
||||
Duration.fromObject({ seconds: 3 }).toMillis(),
|
||||
);
|
||||
});
|
||||
|
||||
this.isAvailable = await Promise.race([callbackPromise, timeoutPromise]);
|
||||
|
||||
if (!this.isAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const castContext = cast.framework.CastContext.getInstance();
|
||||
this.remotePlayer = new cast.framework.RemotePlayer();
|
||||
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
castContext.addEventListener(cast.framework.CastContextEventType.SESSION_STATE_CHANGED, (event) =>
|
||||
this.onSessionStateChanged(event),
|
||||
);
|
||||
|
||||
castContext.addEventListener(cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) =>
|
||||
this.onCastStateChanged(event),
|
||||
);
|
||||
|
||||
const remotePlayerController = new cast.framework.RemotePlayerController(this.remotePlayer);
|
||||
remotePlayerController.addEventListener(cast.framework.RemotePlayerEventType.ANY_CHANGE, (event) =>
|
||||
this.onRemotePlayerChange(event),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadMedia(mediaUrl: string, sessionKey: string, reload: boolean = false): Promise<void> {
|
||||
if (!this.isAvailable || !this.isConnected || !this.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
// already playing the same media
|
||||
if (this.currentUrl === mediaUrl && !reload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we need to send content type in the request
|
||||
// in the future we can swap this out for an API call to get image metadata
|
||||
const assetHead = await fetch(mediaUrl, { method: 'HEAD' });
|
||||
const contentType = assetHead.headers.get('content-type');
|
||||
|
||||
if (!contentType) {
|
||||
throw new Error('No content type found for media url');
|
||||
}
|
||||
|
||||
// build the authenticated media request and send it to the cast device
|
||||
const authenticatedUrl = `${mediaUrl}&sessionKey=${sessionKey}`;
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(authenticatedUrl, contentType);
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
const successCallback = this.onMediaDiscovered.bind(this, SESSION_DISCOVERY_CAUSE.LOAD_MEDIA);
|
||||
|
||||
this.currentUrl = mediaUrl;
|
||||
|
||||
return this.session.loadMedia(request, successCallback, this.onError.bind(this));
|
||||
}
|
||||
|
||||
///
|
||||
/// Remote Player Controls
|
||||
///
|
||||
|
||||
play(): void {
|
||||
if (!this.currentMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playRequest = new chrome.cast.media.PlayRequest();
|
||||
|
||||
this.currentMedia.play(playRequest, () => {}, this.onError.bind(this));
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this.currentMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pauseRequest = new chrome.cast.media.PauseRequest();
|
||||
|
||||
this.currentMedia.pause(pauseRequest, () => {}, this.onError.bind(this));
|
||||
}
|
||||
|
||||
seekTo(time: number): void {
|
||||
const remotePlayer = new cast.framework.RemotePlayer();
|
||||
const remotePlayerController = new cast.framework.RemotePlayerController(remotePlayer);
|
||||
remotePlayer.currentTime = time;
|
||||
remotePlayerController.seek();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.session?.leave(() => {
|
||||
this.session = null;
|
||||
this.castState = CastState.IDLE;
|
||||
this.isConnected = false;
|
||||
this.receiverName = null;
|
||||
}, this.onError.bind(this));
|
||||
}
|
||||
|
||||
///
|
||||
/// Google Cast Callbacks
|
||||
///
|
||||
private onSessionStateChanged(event: cast.framework.SessionStateEventData) {
|
||||
switch (event.sessionState) {
|
||||
case cast.framework.SessionState.SESSION_ENDED: {
|
||||
this.session = null;
|
||||
break;
|
||||
}
|
||||
case cast.framework.SessionState.SESSION_RESUMED:
|
||||
case cast.framework.SessionState.SESSION_STARTED: {
|
||||
this.session = event.session.getSessionObj();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onCastStateChanged(event: cast.framework.CastStateEventData) {
|
||||
this.isConnected = event.castState === cast.framework.CastState.CONNECTED;
|
||||
this.receiverName = this.session?.receiver.friendlyName ?? null;
|
||||
|
||||
if (event.castState === cast.framework.CastState.NOT_CONNECTED) {
|
||||
this.currentMedia = null;
|
||||
this.currentUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onRemotePlayerChange(event: cast.framework.RemotePlayerChangedEvent) {
|
||||
switch (event.field) {
|
||||
case 'isConnected': {
|
||||
this.isConnected = event.value;
|
||||
break;
|
||||
}
|
||||
case 'remotePlayer': {
|
||||
this.remotePlayer = event.value;
|
||||
break;
|
||||
}
|
||||
case 'duration': {
|
||||
this.duration = event.value;
|
||||
break;
|
||||
}
|
||||
case 'currentTime': {
|
||||
this.currentTime = event.value;
|
||||
break;
|
||||
}
|
||||
case 'playerState': {
|
||||
this.castState = event.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onError(error: chrome.cast.Error) {
|
||||
console.error('Google Cast Error:', error);
|
||||
}
|
||||
|
||||
private onMediaDiscovered(cause: SESSION_DISCOVERY_CAUSE, currentMedia: chrome.cast.media.Media) {
|
||||
this.currentMedia = currentMedia;
|
||||
|
||||
if (cause === SESSION_DISCOVERY_CAUSE.LOAD_MEDIA) {
|
||||
this.castState = CastState.PLAYING;
|
||||
} else if (cause === SESSION_DISCOVERY_CAUSE.ACTIVE_SESSION) {
|
||||
// CastState and PlayerState are identical enums
|
||||
this.castState = currentMedia.playerState as unknown as CastState;
|
||||
}
|
||||
}
|
||||
|
||||
static async showCastDialog() {
|
||||
try {
|
||||
await cast.framework.CastContext.getInstance().requestSession();
|
||||
} catch {
|
||||
// the cast dialog throws an error if the user closes it
|
||||
// we don't care about this error
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto, onNavigate } from '$app/navigation';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import CastButton from '$lib/cast/cast-button.svelte';
|
||||
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
||||
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
||||
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
|
||||
@ -620,6 +621,8 @@
|
||||
{#if viewMode === AlbumPageViewMode.VIEW}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
|
||||
{#snippet trailing()}
|
||||
<CastButton whiteHover />
|
||||
|
||||
{#if isEditor}
|
||||
<CircleIconButton
|
||||
title={$t('add_photos')}
|
||||
|
@ -23,6 +23,7 @@ const config = {
|
||||
'$lib/*': 'src/lib/*',
|
||||
'@test-data': 'src/test-data',
|
||||
$i18n: '../i18n',
|
||||
'chromecast-caf-sender': './node_modules/@types/chromecast-caf-sender/index.d.ts',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user