Catalog

Edit Props (live)

TEXT
TEXT
IMAGE
config

Source

215 lines
// Port of hyperframes `spotify-card` (Apache-2.0). Spotify "Now Playing"
// card — album art, track name, artist, progress bar, Spotify logo.
// 5s timeline with card slide-in, progress bar fill, slide-out.

import React from 'react';
import {
  AbsoluteFill,
  interpolate,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';

interface Props {
  trackName: string;
  artistName: string;
  albumArtUrl: string;
  durationSec: number;
}

export default function SpotifyCard({
  trackName,
  artistName,
  albumArtUrl,
  durationSec,
}: Props) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const s = (seconds: number) => seconds * fps;

  const slideInY = interpolate(frame, [s(0.1), s(0.7)], [400, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const fadeIn = interpolate(frame, [s(0.1), s(0.7)], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const slideOutY = interpolate(frame, [s(4.3), s(4.6)], [0, 400], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const fadeOut = interpolate(frame, [s(4.3), s(4.6)], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // Progress bar fills over the playing portion (0.7s → 4.3s = 3.6s)
  const progress = interpolate(frame, [s(0.7), s(4.3)], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const elapsed = progress * durationSec;

  return (
    <AbsoluteFill
      style={{
        background:
          'radial-gradient(ellipse at center, #1db95420 0%, #0a1f15 100%)',
        fontFamily: 'system-ui, sans-serif',
      }}
    >
      <div
        style={{
          position: 'absolute',
          top: '50%',
          left: '50%',
          transform: `translate(-50%, calc(-50% + ${slideInY + slideOutY}px))`,
          opacity: Math.min(fadeIn, fadeOut),
          width: 880,
          padding: 40,
          borderRadius: 32,
          background: 'linear-gradient(135deg, #1db954 0%, #0f6635 100%)',
          boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
        }}
      >
        {/* Header with Spotify branding */}
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: 10,
            marginBottom: 32,
          }}
        >
          <svg width='32' height='32' viewBox='0 0 16 16' fill='#fff'>
            <path d='M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.669 11.538a.498.498 0 0 1-.686.165c-1.879-1.147-4.243-1.407-7.028-.77a.499.499 0 0 1-.222-.973c3.048-.696 5.662-.397 7.77.892a.5.5 0 0 1 .166.686zm.979-2.178a.624.624 0 0 1-.858.205c-2.15-1.321-5.428-1.704-7.972-.932a.625.625 0 0 1-.362-1.194c2.905-.881 6.517-.454 8.986 1.063a.624.624 0 0 1 .206.858zm.084-2.268C10.154 5.56 5.9 5.419 3.438 6.166a.748.748 0 1 1-.434-1.432c2.825-.857 7.523-.692 10.492 1.07a.747.747 0 1 1-.764 1.288z' />
          </svg>
          <span
            style={{
              fontSize: 22,
              fontWeight: 700,
              color: '#fff',
              letterSpacing: 0.5,
            }}
          >
            Now Playing
          </span>
        </div>

        <div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
          <AlbumArt url={albumArtUrl} />
          <div style={{ flex: 1, minWidth: 0 }}>
            <div
              style={{
                fontSize: 44,
                fontWeight: 700,
                color: '#fff',
                lineHeight: 1.2,
                marginBottom: 8,
                overflow: 'hidden',
                whiteSpace: 'nowrap',
                textOverflow: 'ellipsis',
              }}
            >
              {trackName}
            </div>
            <div
              style={{
                fontSize: 28,
                color: 'rgba(255,255,255,0.75)',
                lineHeight: 1.3,
              }}
            >
              {artistName}
            </div>
          </div>
        </div>

        {/* Progress bar */}
        <div style={{ marginTop: 32 }}>
          <div
            style={{
              position: 'relative',
              height: 6,
              background: 'rgba(255,255,255,0.2)',
              borderRadius: 3,
              overflow: 'hidden',
            }}
          >
            <div
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                height: '100%',
                width: `${progress * 100}%`,
                background: '#fff',
                borderRadius: 3,
              }}
            />
          </div>
          <div
            style={{
              display: 'flex',
              justifyContent: 'space-between',
              marginTop: 10,
              fontSize: 18,
              color: 'rgba(255,255,255,0.7)',
              fontVariantNumeric: 'tabular-nums',
            }}
          >
            <span>{formatTime(elapsed)}</span>
            <span>{formatTime(durationSec)}</span>
          </div>
        </div>
      </div>
    </AbsoluteFill>
  );
}

function AlbumArt({ url }: { url: string }) {
  if (url) {
    return (
      // eslint-disable-next-line @next/next/no-img-element
      <img
        src={url}
        alt=''
        style={{
          width: 200,
          height: 200,
          borderRadius: 12,
          objectFit: 'cover',
          flexShrink: 0,
          boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
        }}
      />
    );
  }
  return (
    <div
      style={{
        width: 200,
        height: 200,
        borderRadius: 12,
        flexShrink: 0,
        background: 'linear-gradient(135deg, #0a1f15 0%, #1db954 100%)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
      }}
    >
      <svg width='80' height='80' viewBox='0 0 24 24' fill='#fff'>
        <circle cx='12' cy='12' r='4' opacity={0.85} />
      </svg>
    </div>
  );
}

function formatTime(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${String(secs).padStart(2, '0')}`;
}

Schema (Zod)

import { z } from 'zod';

export const Schema = z.object({
  trackName: z.string().describe('$port:text Track title'),
  artistName: z.string().describe('$port:text Artist name'),
  albumArtUrl: z.string().describe('$port:image Album art image URL').default(''),
  durationSec: z.number().describe('Track duration (seconds)').default(215),
});

Spotify Now Playing

spotify_card

Spotify-branded now-playing card with album art, track/artist, animated progress bar, and matching green gradient. Ported from hyperframes (Apache-2.0).

Workflow Inputs (3)

  • Track title
    trackName
    TEXTRequired
  • Artist name
    artistName
    TEXTRequired
  • Album art image URL
    albumArtUrl
    IMAGE

Config Fields (1)

  • durationSec

Meta

Updated
4/21/2026