mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00: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
16 changed files with 708 additions and 36 deletions
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue