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:
Brandon Wees 2025-05-20 16:08:23 -05:00 committed by GitHub
parent 12b7a079c1
commit 86db0aafe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 708 additions and 36 deletions

View file

@ -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}

View file

@ -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(() => {

View file

@ -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;

View file

@ -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>

View 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>