summaryrefslogtreecommitdiff
path: root/src/components/VideoPlayer.svelte
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/components/VideoPlayer.svelte
init commit
Diffstat (limited to 'src/components/VideoPlayer.svelte')
-rw-r--r--src/components/VideoPlayer.svelte412
1 files changed, 412 insertions, 0 deletions
diff --git a/src/components/VideoPlayer.svelte b/src/components/VideoPlayer.svelte
new file mode 100644
index 0000000..8012b9f
--- /dev/null
+++ b/src/components/VideoPlayer.svelte
@@ -0,0 +1,412 @@
+<script lang="ts">
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import MotionArtwork from '~/components/MotionArtwork.svelte';
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import type { Video } from '@jet-app/app-store/api/models';
+ import {
+ MetricsActionDetails,
+ MetricsActionType,
+ type MetricsActionDetailItem,
+ type MetricsActionTypeItem,
+ } from '~/constants/media-metrics';
+
+ /** HTML `id` attribute for the <video /> element */
+ export let id: string;
+
+ /** Source URL for the video, an HLS playlist ending in .m3u8 */
+ export let src: string;
+
+ /** Poster image to show while the video is loading */
+ export let poster: string | undefined;
+
+ /** If the video should play automatically when in view */
+ export let autoplay: boolean = false;
+
+ /* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */
+ export let autoplayVisibilityThreshold: number = 0;
+
+ /** If the video should loop from end to start. */
+ export let loop: boolean = false;
+
+ /** If the audio should be muted on the video. */
+ export let muted: boolean = true;
+
+ /** If our controls should be shown in the video player. */
+ export let useControls: boolean = true;
+
+ /** The constructor to use for creating an Hls playback session. */
+ export let HLS: Window['Hls'] = window.Hls;
+
+ /**
+ * If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster
+ * image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein
+ * the video is seeked to the first frame once the metadata is loaded, thus removing the poster.
+ */
+ export let shouldSuperimposePosterImage: boolean = false;
+
+ /** an optional metric template provided by jet */
+ export let metricsTemplate:
+ | Record<string, unknown>
+ | Video['templateMediaEvent'] = {};
+
+ export function play(isAutoPlay = true) {
+ videoRef?.play();
+ recordMediaEvent(
+ MetricsActionType.PLAY,
+ isAutoPlay
+ ? MetricsActionDetails.AUTOPLAY
+ : MetricsActionDetails.PLAY,
+ );
+ }
+
+ export function pause(isAutoPause = true) {
+ recordMediaEvent(
+ MetricsActionType.STOP,
+ isAutoPause
+ ? MetricsActionDetails.AUTOPAUSE
+ : MetricsActionDetails.PAUSE,
+ );
+
+ videoRef?.pause();
+ }
+
+ let isPaused: boolean = !autoplay;
+ let isMuted: boolean = muted;
+ let shouldShowReplayControl: boolean = false;
+ let shouldShowPlaybackControls: boolean = true;
+ let hasPlaybackBeenInitiated: boolean = false;
+ let videoRef: HTMLVideoElement | null = null;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ const handleFullScreenButtonClick = () => {
+ videoRef?.requestFullscreen();
+ };
+
+ const handleReplayButtonClick = () => {
+ if (videoRef) {
+ videoRef.currentTime = 0;
+ videoRef.play();
+ shouldShowPlaybackControls = true;
+ }
+ };
+
+ const handlePlayButtonClick = () => {
+ if (isPaused) {
+ play(false);
+ } else {
+ pause(false);
+ }
+ };
+
+ const handleMuteButtonClick = () => {
+ isMuted = !isMuted;
+ };
+
+ const handleVideoEnded = () => {
+ if (!loop) {
+ shouldShowPlaybackControls = true;
+
+ if (videoRef) {
+ videoRef.currentTime = 1;
+ videoRef.pause();
+ }
+
+ recordMediaEvent(
+ MetricsActionType.STOP,
+ MetricsActionDetails.COMPLETE,
+ );
+ }
+ };
+
+ const handleVideoPlay = () => {
+ // Display the replay button after the first play
+ shouldShowReplayControl = true;
+ hasPlaybackBeenInitiated = true;
+ };
+
+ // metric events that are waiting for loadMetadata from video element
+ let queuedMetricEvents: Array<() => void> = [];
+
+ // flush any metric events once load metadata has been called
+ const flushMetricEvents = () => {
+ queuedMetricEvents.forEach((recordFn) => recordFn());
+
+ queuedMetricEvents = [];
+ };
+
+ const recordMediaEvent = (
+ actionType: MetricsActionTypeItem,
+ actionDetail: MetricsActionDetailItem,
+ ) => {
+ if (!metricsTemplate?.fields) {
+ return;
+ }
+
+ const recordEvent = () => {
+ const duration = Math.floor(videoRef?.duration ?? 0) * 1000;
+ const position = Math.min(
+ Math.floor((videoRef?.currentTime ?? 0) * 1000),
+ duration,
+ );
+ jet.recordCustomMetricsEvent({
+ ...(metricsTemplate?.fields ?? {}),
+ actionType: actionType,
+ actionDetails: actionDetail,
+ url: src,
+ duration,
+ position,
+ topic: metricsTemplate?.topic ?? '',
+ });
+ };
+
+ if (Number.isNaN(videoRef?.duration)) {
+ queuedMetricEvents.push(() => recordEvent());
+ } else {
+ recordEvent();
+ }
+ };
+
+ const isVideoPlaying = (video: HTMLVideoElement | null) => {
+ if (!video) {
+ return false;
+ }
+ return !!(
+ video.currentTime > 0 &&
+ !video.paused &&
+ !video.ended &&
+ video.readyState > 2
+ );
+ };
+
+ const intersectionObserverConfig = {
+ threshold: autoplayVisibilityThreshold,
+ callback: (isIntersectingViewport: boolean) => {
+ if (isIntersectingViewport) {
+ play();
+ } else if (isVideoPlaying(videoRef)) {
+ pause();
+ }
+ },
+ };
+</script>
+
+<div
+ class="video-container"
+ use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined}
+>
+ <div class="video">
+ <MotionArtwork
+ {id}
+ {HLS}
+ {src}
+ {loop}
+ poster={!shouldSuperimposePosterImage ? poster : undefined}
+ bind:muted={isMuted}
+ bind:paused={isPaused}
+ bind:videoElement={videoRef}
+ on:play={handleVideoPlay}
+ on:ended={handleVideoEnded}
+ on:loadedmetadata={flushMetricEvents}
+ />
+ </div>
+
+ {#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated}
+ <img
+ src={poster}
+ class="fake-poster"
+ aria-hidden="true"
+ loading="lazy"
+ alt=""
+ />
+ {/if}
+
+ {#if useControls}
+ <div class="video-control">
+ {#if shouldShowReplayControl}
+ <button
+ class="video-control-replay"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Replay',
+ )}
+ on:click={handleReplayButtonClick}
+ >
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-replay.png"
+ alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')}
+ aria-hidden="true"
+ />
+ </button>
+ {/if}
+
+ {#if shouldShowPlaybackControls}
+ <div class="video-control-playback">
+ <button
+ class="video-control-play"
+ aria-label={$i18n.t(
+ isPaused
+ ? 'ASE.Web.AppStore.VideoPlayer.AX.Play'
+ : 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
+ )}
+ on:click={handlePlayButtonClick}
+ >
+ {#if isPaused}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-play.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Play',
+ )}
+ aria-hidden="true"
+ />
+ {:else}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-pause.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
+ )}
+ aria-hidden="true"
+ />
+ {/if}
+ </button>
+
+ <button
+ class="video-control-unmute"
+ aria-label={$i18n.t(
+ isMuted
+ ? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute'
+ : 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
+ )}
+ on:click={handleMuteButtonClick}
+ >
+ {#if isMuted}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-volume-muted.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
+ )}
+ aria-hidden="true"
+ />
+ {:else}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-volume.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Unmute',
+ )}
+ aria-hidden="true"
+ />
+ {/if}
+ </button>
+
+ <button
+ class="video-control-fullscreen"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
+ )}
+ on:click={handleFullScreenButtonClick}
+ >
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-fullscreen.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
+ )}
+ aria-hidden="true"
+ />
+ </button>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .video-container {
+ --button-size: 32px;
+ display: grid;
+ position: relative;
+ container-type: inline-size;
+ container-name: video-container;
+ width: 100%;
+ height: 100%;
+ background-color: var(--systemQuaternary);
+ }
+
+ .video {
+ width: 100%;
+ height: 100%;
+ grid-column: 1;
+ grid-row: 1;
+ line-height: 0;
+ }
+
+ .video-control {
+ grid-column: 1;
+ grid-row: 1;
+ display: inline-flex;
+ justify-content: space-between;
+ z-index: 1;
+ align-self: end;
+ color: white;
+ margin: 0 12px 12px;
+ }
+
+ .video-control::after {
+ position: absolute;
+ content: '';
+ z-index: -1;
+ bottom: 0;
+ left: 0;
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ height: calc(var(--button-size) * 2);
+ background: linear-gradient(
+ 0deg,
+ rgb(0, 0, 0, 0.68),
+ rgb(0, 0, 0, 0.2),
+ transparent
+ );
+ mask-image: linear-gradient(360deg, #000 47%, transparent);
+ }
+
+ .video-control-playback {
+ display: inline-flex;
+ margin-inline-start: auto;
+ gap: 6px;
+ }
+
+ .btn-img {
+ height: var(--button-size);
+ width: var(--button-size);
+ border-radius: 50%;
+ border: 1px solid var(--systemQuaternary-onDark);
+ background: rgba(0, 0, 0, 0.11);
+ backdrop-filter: blur(20px);
+ object-fit: cover;
+ transition: background 105ms ease-out;
+ }
+
+ .btn-img:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ @container video-container (max-width: 500px) {
+ .btn-img {
+ --button-size: 24px;
+ }
+ }
+
+ .fake-poster {
+ width: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+</style>