@brika/plugin-spotify 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,10 @@
34
34
  }
35
35
  }
36
36
  },
37
+ "player": {
38
+ "title": "Spotify",
39
+ "login": "Login with Spotify"
40
+ },
37
41
  "blocks": {
38
42
  "play": {
39
43
  "name": "Play Spotify",
@@ -34,6 +34,10 @@
34
34
  }
35
35
  }
36
36
  },
37
+ "player": {
38
+ "title": "Spotify",
39
+ "login": "Se connecter à Spotify"
40
+ },
37
41
  "blocks": {
38
42
  "play": {
39
43
  "name": "Lire Spotify",
package/package.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://schema.brika.dev/plugin.schema.json",
3
3
  "name": "@brika/plugin-spotify",
4
4
  "displayName": "Spotify",
5
- "version": "0.3.0",
5
+ "version": "0.3.1",
6
6
  "description": "Spotify Connect player for BRIKA dashboards",
7
7
  "author": "BRIKA Team",
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/maxscharwath/brika.git",
11
+ "url": "https://github.com/brikalabs/brika.git",
12
12
  "directory": "plugins/spotify"
13
13
  },
14
14
  "icon": "./icon.svg",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "link": "bun link",
32
- "tsc": "bunx --bun tsc --noEmit",
32
+ "typecheck": "tsgo --noEmit",
33
33
  "prepublishOnly": "brika-verify-plugin"
34
34
  },
35
35
  "preferences": [
@@ -106,7 +106,11 @@
106
106
  }
107
107
  ],
108
108
  "dependencies": {
109
- "@brika/sdk": "0.3.0"
109
+ "@brika/sdk": "0.3.1"
110
+ },
111
+ "devDependencies": {
112
+ "class-variance-authority": "^0.7.1",
113
+ "clsx": "^2.1.1"
110
114
  },
111
115
  "files": [
112
116
  "src",
package/src/actions.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Server-side actions for the Spotify player brick.
3
+ *
4
+ * Client-side bricks import these refs — the module compiler replaces them
5
+ * with `{ __actionId }` stubs at build time. The plugin process handles
6
+ * the actual Spotify API calls when the hub forwards the action.
7
+ */
8
+
9
+ import { defineAction } from '@brika/sdk/actions';
10
+ import {
11
+ next,
12
+ pause,
13
+ play,
14
+ previous,
15
+ seek,
16
+ setVolume,
17
+ startPlayback,
18
+ usePlayerStore,
19
+ } from './playback-store';
20
+ import { resolveDeviceId } from './shared';
21
+
22
+ function resolveTarget(deviceId?: string): string | undefined {
23
+ const id = resolveDeviceId(deviceId);
24
+ return id ?? usePlayerStore.get().devices[0]?.id;
25
+ }
26
+
27
+ export const doPlay = defineAction(async (input?: { deviceId?: string }) => {
28
+ const target = resolveTarget(input?.deviceId);
29
+ const { playback } = usePlayerStore.get();
30
+ if (playback) play(target);
31
+ else await startPlayback(target);
32
+ });
33
+
34
+ export const doPause = defineAction(async (input?: { deviceId?: string }) => {
35
+ pause(resolveTarget(input?.deviceId));
36
+ });
37
+
38
+ export const doNext = defineAction(async () => {
39
+ next();
40
+ });
41
+
42
+ export const doPrevious = defineAction(async () => {
43
+ previous();
44
+ });
45
+
46
+ export const doSeek = defineAction(async (input: { positionMs: number }) => {
47
+ seek(input.positionMs);
48
+ });
49
+
50
+ export const doSetVolume = defineAction(async (input: { percent: number }) => {
51
+ setVolume(input.percent);
52
+ });
@@ -1,81 +1,135 @@
1
- /**
2
- * Shared sub-components for the Spotify player brick.
3
- * Each renders a reusable piece of the player UI.
4
- */
1
+ import { cva } from 'class-variance-authority';
2
+ import clsx from 'clsx';
3
+ import { Music, Pause, Play } from 'lucide-react';
4
+ import { useEffect, useRef, useState } from 'react';
5
5
 
6
- import { Box, Button, Row, Slider, Text } from '@brika/sdk/bricks';
7
- import { formatMs, progressPercent } from './utils';
6
+ export function ScrollText({ text, className }: Readonly<{ text: string; className?: string }>) {
7
+ const containerRef = useRef<HTMLDivElement>(null);
8
+ const textRef = useRef<HTMLSpanElement>(null);
9
+ const [scrollPx, setScrollPx] = useState(0);
8
10
 
9
- // ─── Types ──────────────────────────────────────────────────────────────────
11
+ useEffect(() => {
12
+ const container = containerRef.current;
13
+ const textEl = textRef.current;
14
+ if (!container || !textEl) return;
15
+ const frame = requestAnimationFrame(() => {
16
+ const diff = textEl.offsetWidth - container.offsetWidth;
17
+ setScrollPx(diff > 2 ? diff : 0);
18
+ });
19
+ return () => cancelAnimationFrame(frame);
20
+ }, [text]);
10
21
 
11
- export interface PlayerActions {
12
- onPlay: () => void;
13
- onPause: () => void;
14
- onNext: () => void;
15
- onPrev: () => void;
16
- onSeek: (payload?: Record<string, unknown>) => void;
17
- onVolume: (payload?: Record<string, unknown>) => void;
18
- }
19
-
20
- // ─── Transport Controls ─────────────────────────────────────────────────────
22
+ const needsScroll = scrollPx > 0;
23
+ const duration = Math.max(4, scrollPx / 25);
21
24
 
22
- export function Controls({ isPlaying, onPlay, onPause, onPrev, onNext }: Readonly<{
23
- isPlaying: boolean;
24
- onPlay: () => void;
25
- onPause: () => void;
26
- onPrev: () => void;
27
- onNext: () => void;
28
- }>) {
29
25
  return (
30
- <Row gap="sm" justify="center" align="center">
31
- <Button onPress={onPrev} icon="skip-back" color="rgba(0,0,0,0.3)" />
32
- {isPlaying
33
- ? <Button onPress={onPause} icon="pause" color="rgba(0,0,0,0.5)" />
34
- : <Button onPress={onPlay} icon="play" color="rgba(0,0,0,0.5)" />}
35
- <Button onPress={onNext} icon="skip-forward" color="rgba(0,0,0,0.3)" />
36
- </Row>
26
+ <div
27
+ ref={containerRef}
28
+ className={clsx('overflow-hidden', className)}
29
+ style={needsScroll ? {
30
+ maskImage: 'linear-gradient(to right, transparent, black 6%, black 94%, transparent)',
31
+ WebkitMaskImage: 'linear-gradient(to right, transparent, black 6%, black 94%, transparent)',
32
+ } : undefined}
33
+ >
34
+ <span
35
+ ref={textRef}
36
+ className={needsScroll ? 'inline-block whitespace-nowrap' : 'block truncate'}
37
+ style={needsScroll ? {
38
+ animationName: 'spotify-scroll',
39
+ animationDuration: `${duration}s`,
40
+ animationTimingFunction: 'ease-in-out',
41
+ animationIterationCount: 'infinite',
42
+ animationDirection: 'alternate',
43
+ '--scroll-dist': `-${scrollPx}px`,
44
+ } as React.CSSProperties : undefined}
45
+ >
46
+ {text}
47
+ </span>
48
+ </div>
37
49
  );
38
50
  }
39
51
 
40
- // ─── Track Info ─────────────────────────────────────────────────────────────
41
-
42
- export function TrackInfo({ trackName, artistName }: Readonly<{
52
+ export function AlbumCover({ trackName, artistName, albumArt }: Readonly<{
43
53
  trackName: string;
44
54
  artistName: string;
55
+ albumArt?: string;
45
56
  }>) {
57
+ if (albumArt) {
58
+ return (
59
+ <img
60
+ src={albumArt}
61
+ alt={`${trackName} by ${artistName}`}
62
+ className="absolute inset-0 h-full w-full object-cover"
63
+ />
64
+ );
65
+ }
46
66
  return (
47
- <Row gap="sm" align="center">
48
- <Text content={trackName} variant="heading" color="rgba(255,255,255,0.95)" />
49
- <Text content={artistName} variant="caption" color="rgba(255,255,255,0.55)" />
50
- </Row>
67
+ <div className="absolute inset-0 flex items-center justify-center bg-muted">
68
+ <Music className="size-8 text-muted-foreground" />
69
+ </div>
51
70
  );
52
71
  }
53
72
 
54
- // ─── Progress Bar ───────────────────────────────────────────────────────────
73
+ const transportVariants = cva('cursor-pointer transition-colors', {
74
+ variants: {
75
+ size: {
76
+ sm: 'text-muted-foreground hover:text-foreground',
77
+ md: 'flex size-7 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/30',
78
+ },
79
+ },
80
+ defaultVariants: { size: 'sm' },
81
+ });
55
82
 
56
- export function ProgressBar({ localProgressMs, durationMs, onSeek }: Readonly<{
57
- localProgressMs: number;
58
- durationMs: number;
59
- onSeek: (payload?: Record<string, unknown>) => void;
83
+ const transportIconVariants = cva('', {
84
+ variants: {
85
+ size: { sm: 'size-3.5', md: 'size-3' },
86
+ },
87
+ defaultVariants: { size: 'sm' },
88
+ });
89
+
90
+ export function TransportButton({ onClick, icon: Icon, size = 'sm' }: Readonly<{
91
+ onClick: () => void;
92
+ icon: typeof Play;
93
+ size?: 'sm' | 'md';
60
94
  }>) {
61
95
  return (
62
- <Row gap="sm" align="center">
63
- <Text content={formatMs(localProgressMs)} variant="caption" color="rgba(255,255,255,0.55)" />
64
- <Box grow>
65
- <Slider value={progressPercent(localProgressMs, durationMs)} min={0} max={100} step={1} onChange={onSeek} color="#1DB954" />
66
- </Box>
67
- <Text content={formatMs(durationMs)} variant="caption" color="rgba(255,255,255,0.55)" />
68
- </Row>
96
+ <button type="button" onClick={onClick} className={transportVariants({ size })}>
97
+ <Icon className={transportIconVariants({ size })} fill="currentColor" />
98
+ </button>
69
99
  );
70
100
  }
71
101
 
72
- // ─── Volume Slider ──────────────────────────────────────────────────────────
102
+ const playPauseVariants = cva(
103
+ 'flex cursor-pointer items-center justify-center rounded-full bg-foreground text-background transition-transform hover:scale-105 active:scale-95',
104
+ {
105
+ variants: {
106
+ variant: {
107
+ idle: 'size-12',
108
+ compact: 'size-10',
109
+ default: 'size-8',
110
+ },
111
+ },
112
+ defaultVariants: { variant: 'default' },
113
+ },
114
+ );
115
+
116
+ const playPauseIconVariants = cva('', {
117
+ variants: {
118
+ variant: { idle: 'size-5', compact: 'size-4', default: 'size-3.5' },
119
+ },
120
+ defaultVariants: { variant: 'default' },
121
+ });
73
122
 
74
- export function VolumeSlider({ volume, onVolume }: Readonly<{
75
- volume: number;
76
- onVolume: (payload?: Record<string, unknown>) => void;
123
+ export function PlayPauseButton({ isPlaying, onToggle, variant = 'default' }: Readonly<{
124
+ isPlaying: boolean;
125
+ onToggle: () => void;
126
+ variant?: 'default' | 'compact' | 'idle';
77
127
  }>) {
128
+ const Icon = isPlaying ? Pause : Play;
129
+
78
130
  return (
79
- <Slider label="Volume" value={volume} min={0} max={100} step={5} unit="%" onChange={onVolume} icon="volume-2" color="rgba(255,255,255,0.6)" />
131
+ <button type="button" onClick={onToggle} className={playPauseVariants({ variant })}>
132
+ <Icon className={clsx(playPauseIconVariants({ variant }), !isPlaying && 'translate-x-px')} fill="currentColor" />
133
+ </button>
80
134
  );
81
135
  }
@@ -1,194 +1,176 @@
1
- import { Badge, Box, Button, Column, defineBrick, Icon, Image, Row, Spacer, Text, useBrickSize, useEffect, usePluginPreference, usePreference, useRef, useState } from '@brika/sdk/bricks';
2
- import { spotify } from '../index';
3
- import {
4
- acquirePolling,
5
- next,
6
- pause,
7
- play,
8
- previous,
9
- seek,
10
- setVolume,
11
- startPlayback,
12
- usePlayerStore,
13
- } from '../playback-store';
14
- import type { PlaybackState } from '../spotify-api';
15
- import { Controls, type PlayerActions, ProgressBar, TrackInfo, VolumeSlider } from './components';
16
-
17
- // ─── Common layout props ─────────────────────────────────────────────────────
18
-
19
- interface TrackDisplay {
20
- trackName: string;
21
- artistName: string;
22
- albumArt: string | null;
23
- }
24
-
25
- interface LayoutProps {
26
- track: TrackDisplay;
1
+ /**
2
+ * Spotify Player client-rendered brick.
3
+ *
4
+ * Design: full-bleed album art, gradient overlay with scrolling track info,
5
+ * pointer-draggable progress bar, and minimal transport controls.
6
+ * Compact layout (≤2×2) shows cover art with overlay buttons.
7
+ */
8
+
9
+ import { useBrickConfig, useBrickData, useBrickSize } from '@brika/sdk/brick-views';
10
+ import { useCallAction, useLocale } from '@brika/sdk/ui-kit/hooks';
11
+ import { LogIn, Music, SkipBack, SkipForward } from 'lucide-react';
12
+ import { useCallback } from 'react';
13
+ import { doNext, doPause, doPlay, doPrevious } from '../actions';
14
+ import type { PlaybackState, RecentTrack } from '../spotify-api';
15
+ import { AlbumCover, PlayPauseButton, ScrollText, TransportButton } from './components';
16
+ import { useProgress } from './use-progress';
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ interface SpotifyPlayerData {
27
21
  playback: PlaybackState | null;
28
- actions: PlayerActions;
22
+ recentTrack: RecentTrack | null;
23
+ isAuthed: boolean;
24
+ loaded: boolean;
25
+ anchor: { progressMs: number; timestamp: number };
26
+ authUrl: string;
29
27
  }
30
28
 
31
- // ─── Layout: Small (1-2 cols) ───────────────────────────────────────────────
32
-
33
- function SmallPlayer({ track, playback, width, actions }: Readonly<LayoutProps & { width: number }>) {
34
- const isPlaying = playback?.isPlaying ?? false;
35
- return (
36
- <Box backgroundImage={track.albumArt ?? undefined} backgroundFit="cover" rounded="lg" grow>
37
- <Box background="rgba(0,0,0,0.3)" grow>
38
- <Column justify="center" align="center" gap="sm">
39
- <Spacer />
40
- {width >= 2
41
- ? <Controls isPlaying={isPlaying} onPlay={actions.onPlay} onPause={actions.onPause} onPrev={actions.onPrev} onNext={actions.onNext} />
42
- : <Button onPress={isPlaying ? actions.onPause : actions.onPlay} icon={isPlaying ? 'pause' : 'play'} color="rgba(0,0,0,0.4)" />}
43
- <Spacer />
44
- </Column>
45
- </Box>
46
- </Box>
47
- );
29
+ function formatMs(ms: number) {
30
+ const totalSec = Math.floor(ms / 1000);
31
+ const m = Math.floor(totalSec / 60);
32
+ return `${m}:${String(totalSec % 60).padStart(2, '0')}`;
48
33
  }
49
34
 
50
- // ─── Layout: Medium (3-4 cols) ──────────────────────────────────────────────
35
+ // ─── Main Component ──────────────────────────────────────────────────────────
51
36
 
52
- function MediumPlayer({ track, playback, height, localProgressMs, actions }: Readonly<LayoutProps & { height: number; localProgressMs: number }>) {
53
- const isPlaying = playback?.isPlaying ?? false;
54
- return (
55
- <Box backgroundImage={track.albumArt ?? undefined} backgroundFit="cover" rounded="lg" grow padding="sm">
56
- <Column grow justify={height >= 2 ? 'end' : 'start'}>
57
- <Box background="rgba(0,0,0,0.7)" blur="lg" padding="md" grow={height < 2} rounded={height < 2 ? 'lg' : 'md'}>
58
- <Column gap="sm">
59
- <TrackInfo trackName={track.trackName} artistName={track.artistName} />
60
- <Controls isPlaying={isPlaying} onPlay={actions.onPlay} onPause={actions.onPause} onPrev={actions.onPrev} onNext={actions.onNext} />
61
- {playback && <ProgressBar localProgressMs={localProgressMs} durationMs={playback.durationMs} onSeek={actions.onSeek} />}
62
- {playback && height >= 4 && <VolumeSlider volume={playback.volume} onVolume={actions.onVolume} />}
63
- </Column>
64
- </Box>
65
- </Column>
66
- </Box>
67
- );
68
- }
37
+ export default function SpotifyPlayer() {
38
+ const { width, height } = useBrickSize();
39
+ const config = useBrickConfig();
40
+ const data = useBrickData<SpotifyPlayerData>();
41
+ const { t } = useLocale();
42
+ const callAction = useCallAction();
69
43
 
70
- // ─── Layout: Large (5+ cols) ────────────────────────────────────────────────
44
+ const deviceId = typeof config.device === 'string' && config.device ? config.device : undefined;
45
+ const playback = data?.playback ?? null;
46
+ const recentTrack = data?.recentTrack ?? null;
47
+ const isAuthed = data?.isAuthed ?? false;
48
+ const anchor = data?.anchor ?? { progressMs: 0, timestamp: Date.now() };
49
+ const authUrl = data?.authUrl ?? '';
71
50
 
72
- function LargePlayer({ track, playback, height, localProgressMs, actions }: Readonly<LayoutProps & { height: number; localProgressMs: number }>) {
51
+ const track = playback ?? recentTrack;
73
52
  const isPlaying = playback?.isPlaying ?? false;
53
+ const durationMs = playback?.durationMs ?? 0;
54
+
55
+ const progress = useProgress(anchor, isPlaying, durationMs, callAction);
56
+
57
+ const onToggle = useCallback(() => {
58
+ callAction(isPlaying ? doPause : doPlay, { deviceId });
59
+ }, [callAction, isPlaying, deviceId]);
60
+ const onNext = useCallback(() => { callAction(doNext); }, [callAction]);
61
+ const onPrev = useCallback(() => { callAction(doPrevious); }, [callAction]);
62
+
63
+ // ─── Loading ──────────────────────────────────────────────────────
64
+
65
+ if (!data?.loaded) {
66
+ return (
67
+ <div className="flex flex-1 items-center justify-center">
68
+ <div className="size-5 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ // ─── Auth required ────────────────────────────────────────────────
74
+
75
+ if (!isAuthed) {
76
+ return (
77
+ <div className="flex flex-1 flex-col items-center justify-center gap-3 p-4">
78
+ <Music className="size-8 text-[#1DB954]" />
79
+ <span className="font-semibold text-foreground">{t('player.title')}</span>
80
+ <a
81
+ href={authUrl}
82
+ className="mt-2 flex items-center gap-2 rounded-full bg-[#1DB954] px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-[#1ed760]"
83
+ >
84
+ <LogIn className="size-4" />
85
+ {t('player.login')}
86
+ </a>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ // ─── No track — idle play button ──────────────────────────────────
92
+
93
+ if (!track) {
94
+ return (
95
+ <div className="flex flex-1 items-center justify-center">
96
+ <PlayPauseButton isPlaying={false} onToggle={onToggle} variant="idle" />
97
+ </div>
98
+ );
99
+ }
100
+
101
+ // ─── Compact layout (≤2×2) ────────────────────────────────────────
102
+
103
+ if (width <= 2 && height <= 2) {
104
+ return (
105
+ <div className="relative flex h-full items-center justify-center overflow-hidden rounded-lg">
106
+ <AlbumCover trackName={track.trackName} artistName={track.artistName} albumArt={track.albumArt} />
107
+ <div className="absolute inset-0 bg-radial from-black/10 to-black/50" />
108
+ <div className="relative flex items-center gap-2">
109
+ <TransportButton onClick={onPrev} icon={SkipBack} size="md" />
110
+ <PlayPauseButton isPlaying={isPlaying} onToggle={onToggle} variant="compact" />
111
+ <TransportButton onClick={onNext} icon={SkipForward} size="md" />
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ // ─── Default layout ───────────────────────────────────────────────
118
+
74
119
  return (
75
- <Box backgroundImage={track.albumArt ?? undefined} backgroundFit="cover" rounded="lg" blur="sm">
76
- <Box background="rgba(0,0,0,0.7)" blur="lg" padding="lg" rounded="lg" grow>
77
- <Row gap="lg">
78
- {track.albumArt == null
79
- ? <Box padding="none" />
80
- : <Image src={track.albumArt} alt={playback?.albumName ?? track.trackName} fit="cover" rounded aspectRatio="1/1" />}
81
- <Box grow padding="none">
82
- <Column gap="sm" justify="center">
83
- <TrackInfo trackName={track.trackName} artistName={track.artistName} />
84
- <Controls isPlaying={isPlaying} onPlay={actions.onPlay} onPause={actions.onPause} onPrev={actions.onPrev} onNext={actions.onNext} />
85
- {playback && <ProgressBar localProgressMs={localProgressMs} durationMs={playback.durationMs} onSeek={actions.onSeek} />}
86
- {playback && height >= 4 && <VolumeSlider volume={playback.volume} onVolume={actions.onVolume} />}
87
- {playback && height >= 5 && <Badge label={playback.deviceName} icon="speaker" variant="secondary" color="rgba(255,255,255,0.6)" />}
88
- </Column>
89
- </Box>
90
- </Row>
91
- </Box>
92
- </Box>
120
+ <div className="flex h-full flex-col overflow-hidden rounded-lg">
121
+ <style>{`@keyframes spotify-scroll{0%,15%{transform:translateX(0)}85%,100%{transform:translateX(var(--scroll-dist))}}`}</style>
122
+
123
+ {/* Album cover + track info */}
124
+ <div className="relative flex-1 overflow-hidden">
125
+ <AlbumCover trackName={track.trackName} artistName={track.artistName} albumArt={track.albumArt} />
126
+ <div className="absolute inset-0 bg-linear-to-t from-black/85 via-black/15 to-black/25" />
127
+ <div className="absolute inset-x-0 bottom-0 px-3 pb-2">
128
+ <ScrollText text={track.trackName} className="text-sm font-bold text-white [text-shadow:0_1px_4px_rgba(0,0,0,0.8)]" />
129
+ <ScrollText text={track.artistName} className="mt-0.5 text-[11px] text-[rgba(255,255,255,0.8)] [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]" />
130
+ </div>
131
+ </div>
132
+
133
+ {/* Controls */}
134
+ <div className="space-y-1.5 px-3 pb-3 pt-2">
135
+ {/* Progress bar */}
136
+ <div>
137
+ <div
138
+ ref={progress.barRef}
139
+ className="group/bar relative h-1 cursor-pointer rounded-full bg-muted touch-none"
140
+ onPointerDown={progress.onPointerDown}
141
+ onPointerMove={progress.onPointerMove}
142
+ onPointerUp={progress.onPointerUp}
143
+ role="slider"
144
+ aria-valuenow={progress.localProgressMs}
145
+ aria-valuemin={0}
146
+ aria-valuemax={durationMs}
147
+ tabIndex={0}
148
+ >
149
+ <div
150
+ className="h-full rounded-full bg-primary"
151
+ style={{
152
+ width: `${progress.pct}%`,
153
+ transition: progress.dragging ? 'none' : 'width 0.3s ease',
154
+ }}
155
+ />
156
+ <div
157
+ className="absolute top-1/2 -translate-y-1/2 size-2.5 rounded-full bg-primary shadow-sm opacity-0 transition-opacity group-hover/bar:opacity-100"
158
+ style={{ left: `calc(${progress.pct}% - 5px)` }}
159
+ />
160
+ </div>
161
+ <div className="mt-0.5 flex justify-between text-[9px] text-muted-foreground tabular-nums">
162
+ <span>{formatMs(progress.localProgressMs)}</span>
163
+ <span>{formatMs(durationMs)}</span>
164
+ </div>
165
+ </div>
166
+
167
+ {/* Transport */}
168
+ <div className="flex items-center justify-center gap-4">
169
+ <TransportButton onClick={onPrev} icon={SkipBack} />
170
+ <PlayPauseButton isPlaying={isPlaying} onToggle={onToggle} />
171
+ <TransportButton onClick={onNext} icon={SkipForward} />
172
+ </div>
173
+ </div>
174
+ </div>
93
175
  );
94
176
  }
95
-
96
- // ─── Brick ──────────────────────────────────────────────────────────────────
97
-
98
- export const playerBrick = defineBrick(
99
- {
100
- id: 'player',
101
- families: ['sm', 'md', 'lg'],
102
- minSize: { w: 1, h: 1 },
103
- maxSize: { w: 12, h: 8 },
104
- },
105
- () => {
106
- const { width, height } = useBrickSize();
107
- const { playback, recentTrack, devices, isAuthed, anchor } = usePlayerStore();
108
- const [instanceDeviceId] = usePreference<string>('device', '');
109
- const pluginDeviceId = usePluginPreference<string>('defaultDevice', '');
110
- const preferredId = instanceDeviceId || pluginDeviceId || undefined;
111
- const targetId = preferredId ?? devices[0]?.id;
112
- const [localProgressMs, setLocalProgressMs] = useState(anchor.progressMs);
113
- const anchorRef = useRef(anchor);
114
-
115
- // Start/stop shared polling
116
- useEffect(() => acquirePolling(), []);
117
-
118
- // Keep anchor ref in sync and snap localProgressMs immediately
119
- useEffect(() => {
120
- anchorRef.current = anchor;
121
- setLocalProgressMs(anchor.progressMs);
122
- }, [anchor]);
123
-
124
- // ─── Local progress interpolation (1s tick) ─────────────────────────
125
-
126
- useEffect(() => {
127
- if (!playback?.isPlaying) return;
128
- const id = setInterval(() => {
129
- const elapsed = Date.now() - anchorRef.current.timestamp;
130
- const interpolated = Math.min(
131
- anchorRef.current.progressMs + elapsed,
132
- playback.durationMs,
133
- );
134
- setLocalProgressMs(interpolated);
135
- }, 1000);
136
- return () => clearInterval(id);
137
- }, [playback?.isPlaying, playback?.durationMs]);
138
-
139
- // ─── Actions ────────────────────────────────────────────────────────
140
-
141
- const actions: PlayerActions = {
142
- onPlay() {
143
- if (playback) play(targetId);
144
- else startPlayback(targetId);
145
- },
146
- onPause() { pause(targetId); },
147
- onNext() { next(); },
148
- onPrev() { previous(); },
149
- onSeek(payload) {
150
- if (typeof payload?.value === 'number' && playback) {
151
- const positionMs = Math.round((payload.value / 100) * playback.durationMs);
152
- seek(positionMs);
153
- setLocalProgressMs(positionMs);
154
- }
155
- },
156
- onVolume(payload) {
157
- if (typeof payload?.value === 'number') setVolume(payload.value);
158
- },
159
- };
160
-
161
- // ─── Render ─────────────────────────────────────────────────────────
162
-
163
- if (!isAuthed) {
164
- return (
165
- <Box background="rgba(0,0,0,0.4)" blur="md" padding="lg" rounded="lg">
166
- <Column gap="md" align="center" justify="center" grow>
167
- <Icon name="music" size="lg" color="#1DB954" />
168
- <Text content="Spotify" variant="heading" color="rgba(255,255,255,0.95)" />
169
- <Spacer size="sm" />
170
- <Button label="Login with Spotify" url={spotify.getAuthUrl()} icon="log-in" color="#1DB954" />
171
- </Column>
172
- </Box>
173
- );
174
- }
175
-
176
- const track = playback ?? recentTrack;
177
-
178
- if (!track) {
179
- return (
180
- <Box background="rgba(0,0,0,0.3)" blur="sm" padding="md" rounded="lg">
181
- <Column align="center" justify="center" grow>
182
- <Button icon="play" color="#1DB954" onPress={actions.onPlay} />
183
- </Column>
184
- </Box>
185
- );
186
- }
187
-
188
- const layoutProps = { track, playback, actions };
189
-
190
- if (width <= 2) return <SmallPlayer {...layoutProps} width={width} />;
191
- if (width <= 4) return <MediumPlayer {...layoutProps} height={height} localProgressMs={localProgressMs} />;
192
- return <LargePlayer {...layoutProps} height={height} localProgressMs={localProgressMs} />;
193
- },
194
- );
@@ -0,0 +1,78 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { doSeek } from '../actions';
3
+
4
+ type CallAction = <I, O>(ref: { readonly __actionId: string }, input?: I) => Promise<O>;
5
+
6
+ export function useProgress(
7
+ anchor: { progressMs: number; timestamp: number },
8
+ isPlaying: boolean,
9
+ durationMs: number,
10
+ callAction: CallAction,
11
+ ) {
12
+ const [localProgressMs, setLocalProgressMs] = useState(anchor.progressMs);
13
+ const draggingRef = useRef(false);
14
+ const [dragging, setDragging] = useState(false);
15
+ const barRef = useRef<HTMLDivElement>(null);
16
+ const anchorRef = useRef(anchor);
17
+
18
+ useEffect(() => {
19
+ anchorRef.current = anchor;
20
+ setLocalProgressMs(anchor.progressMs);
21
+ }, [anchor.progressMs, anchor.timestamp]);
22
+
23
+ useEffect(() => {
24
+ if (!isPlaying || dragging) return;
25
+ const id = setInterval(() => {
26
+ const elapsed = Date.now() - anchorRef.current.timestamp;
27
+ setLocalProgressMs(Math.min(anchorRef.current.progressMs + elapsed, durationMs));
28
+ }, 1000);
29
+ return () => clearInterval(id);
30
+ }, [isPlaying, dragging, durationMs]);
31
+
32
+ const positionFromPointer = useCallback(
33
+ (clientX: number) => {
34
+ const bar = barRef.current;
35
+ if (!bar || durationMs <= 0) return 0;
36
+ const rect = bar.getBoundingClientRect();
37
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
38
+ const positionMs = Math.round(ratio * durationMs);
39
+ setLocalProgressMs(positionMs);
40
+ return positionMs;
41
+ },
42
+ [durationMs],
43
+ );
44
+
45
+ const onPointerDown = useCallback(
46
+ (e: React.PointerEvent) => {
47
+ draggingRef.current = true;
48
+ setDragging(true);
49
+ e.currentTarget.setPointerCapture(e.pointerId);
50
+ positionFromPointer(e.clientX);
51
+ },
52
+ [positionFromPointer],
53
+ );
54
+
55
+ const onPointerMove = useCallback(
56
+ (e: React.PointerEvent) => {
57
+ if (!draggingRef.current) return;
58
+ positionFromPointer(e.clientX);
59
+ },
60
+ [positionFromPointer],
61
+ );
62
+
63
+ const onPointerUp = useCallback(
64
+ (e: React.PointerEvent) => {
65
+ if (!draggingRef.current) return;
66
+ draggingRef.current = false;
67
+ setDragging(false);
68
+ // Only send the seek API call on drag end (not on every move)
69
+ const positionMs = positionFromPointer(e.clientX);
70
+ callAction(doSeek, { positionMs });
71
+ },
72
+ [positionFromPointer, callAction],
73
+ );
74
+
75
+ const pct = durationMs > 0 ? (localProgressMs / durationMs) * 100 : 0;
76
+
77
+ return { localProgressMs, pct, dragging, barRef, onPointerDown, onPointerMove, onPointerUp };
78
+ }
package/src/index.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { defineOAuth, definePreferenceOptions } from '@brika/sdk';
1
+ import { defineOAuth, definePreferenceOptions, setBrickData } from '@brika/sdk';
2
2
  import { log, onStop } from '@brika/sdk/lifecycle';
3
3
 
4
4
  // ─── OAuth ────────────────────────────────────────────────────────────────────
@@ -36,13 +36,39 @@ export { trackChanged } from './sparks';
36
36
 
37
37
  export { playBlock } from './blocks/play';
38
38
 
39
+ // ─── Actions (registers defineAction handlers for client-side brick) ─────────
40
+
41
+ import './actions';
42
+
39
43
  // ─── Bricks ───────────────────────────────────────────────────────────────────
40
44
 
41
- export { playerBrick } from './bricks/player';
45
+ // Player brick is client-rendered — no server-side defineBrick export needed.
46
+ // Brick type is registered from package.json metadata.
47
+
48
+ // ─── Client-side data push ───────────────────────────────────────────────────
49
+
50
+ import { acquirePolling, usePlayerStore } from './playback-store';
51
+
52
+ // Start polling immediately so data is ready when the brick mounts
53
+ const releasePolling = acquirePolling();
54
+
55
+ // Push player state to client bricks whenever the store changes
56
+ usePlayerStore.subscribe(() => {
57
+ const state = usePlayerStore.get();
58
+ setBrickData('player', {
59
+ playback: state.playback,
60
+ recentTrack: state.recentTrack,
61
+ isAuthed: state.isAuthed,
62
+ loaded: state.loaded,
63
+ anchor: state.anchor,
64
+ authUrl: spotify.getAuthUrl(),
65
+ });
66
+ });
42
67
 
43
68
  // ─── Lifecycle ────────────────────────────────────────────────────────────────
44
69
 
45
70
  onStop(() => {
71
+ releasePolling();
46
72
  log.info('Spotify plugin stopping');
47
73
  });
48
74
 
@@ -5,8 +5,7 @@
5
5
  * `usePlayerStore()` automatically re-renders when the state changes.
6
6
  */
7
7
 
8
- import { log } from '@brika/sdk';
9
- import { defineSharedStore } from '@brika/sdk/bricks';
8
+ import { defineSharedStore, log } from '@brika/sdk';
10
9
  import { spotify } from './index';
11
10
  import { getApi } from './shared';
12
11
  import { trackChanged } from './sparks';
@@ -1,13 +0,0 @@
1
- /** Format milliseconds as m:ss */
2
- export function formatMs(ms: number): string {
3
- const s = Math.floor(ms / 1000);
4
- const m = Math.floor(s / 60);
5
- const sec = s % 60;
6
- return `${m}:${sec.toString().padStart(2, '0')}`;
7
- }
8
-
9
- /** Convert progress/duration to 0-100 percentage */
10
- export function progressPercent(progress: number, duration: number): number {
11
- if (duration <= 0) return 0;
12
- return Math.round((progress / duration) * 100);
13
- }