@brika/plugin-spotify 0.3.0
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.
- package/README.md +56 -0
- package/icon.svg +1 -0
- package/locales/en/plugin.json +49 -0
- package/locales/fr/plugin.json +49 -0
- package/package.json +117 -0
- package/src/blocks/play.ts +45 -0
- package/src/bricks/components.tsx +81 -0
- package/src/bricks/player.tsx +194 -0
- package/src/bricks/utils.ts +13 -0
- package/src/index.tsx +49 -0
- package/src/playback-store.ts +202 -0
- package/src/shared.ts +70 -0
- package/src/sparks.ts +12 -0
- package/src/spotify-api.ts +216 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Spotify Connect
|
|
2
|
+
|
|
3
|
+
Spotify playback controller for BRIKA dashboards. Displays now-playing info, album art, and provides full transport controls via the Spotify Web API.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Create an app at [developer.spotify.com](https://developer.spotify.com)
|
|
8
|
+
2. Add `http://127.0.0.1:3001/api/oauth/spotify/callback` as a **Redirect URI** in your Spotify app settings
|
|
9
|
+
3. In BRIKA, go to the plugin preferences and paste your **Client ID**
|
|
10
|
+
4. Click the **Connect** link to authorize
|
|
11
|
+
|
|
12
|
+
> Uses PKCE — no client secret needed.
|
|
13
|
+
|
|
14
|
+
## Brick: Spotify Player
|
|
15
|
+
|
|
16
|
+
Responsive player that adapts to the grid size:
|
|
17
|
+
|
|
18
|
+
| Size | Layout |
|
|
19
|
+
|----------|----------------------------------------------------|
|
|
20
|
+
| 1-2 cols | Album art with play/pause overlay |
|
|
21
|
+
| 3-4 cols | Album art background with floating control panel |
|
|
22
|
+
| 5+ cols | Split layout — album art left, full controls right |
|
|
23
|
+
|
|
24
|
+
Height unlocks additional features:
|
|
25
|
+
|
|
26
|
+
- **h >= 3** (medium layout): seek slider
|
|
27
|
+
- **h >= 4**: volume slider
|
|
28
|
+
- **h >= 5** (large layout): device name badge
|
|
29
|
+
|
|
30
|
+
### Config
|
|
31
|
+
|
|
32
|
+
| Name | Type | Default | Description |
|
|
33
|
+
|-------------------|--------|---------|-------------------------------------|
|
|
34
|
+
| `refreshInterval` | number | 3000 | Polling interval in ms (1000–30000) |
|
|
35
|
+
|
|
36
|
+
Progress is interpolated locally between polls for smooth UI updates.
|
|
37
|
+
|
|
38
|
+
## Spark: Track Changed
|
|
39
|
+
|
|
40
|
+
Emitted whenever the playing track changes.
|
|
41
|
+
|
|
42
|
+
**Payload:**
|
|
43
|
+
|
|
44
|
+
| Field | Type | Description |
|
|
45
|
+
|--------------|----------------|---------------------------------|
|
|
46
|
+
| `trackName` | string | Track title |
|
|
47
|
+
| `artistName` | string | Artist name(s), comma-separated |
|
|
48
|
+
| `albumName` | string | Album title |
|
|
49
|
+
| `albumArt` | string \| null | Album art URL (640px) |
|
|
50
|
+
| `timestamp` | number | Unix timestamp (ms) |
|
|
51
|
+
|
|
52
|
+
## Scopes
|
|
53
|
+
|
|
54
|
+
- `user-read-playback-state`
|
|
55
|
+
- `user-modify-playback-state`
|
|
56
|
+
- `user-read-currently-playing`
|
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class=""><rect id="_r_9_" width="512" height="512" x="0" y="0" rx="0" fill="url(#_r_a_)" stroke="#FFFFFF" stroke-width="0" stroke-opacity="100%" paint-order="stroke"></rect><clipPath id="clip"><use xlink:href="#_r_9_"></use></clipPath><defs><radialGradient id="_r_a_" cx="50%" cy="50%" r="100%" fx="50%" fy="0%" gradientUnits="objectBoundingBox"><stop stop-color="#1DB954"></stop><stop offset="1" stop-color="#0E7932"></stop></radialGradient><radialGradient id="_r_b_" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256) rotate(90) scale(512)"><stop stop-color="white"></stop><stop offset="1" stop-color="white" stop-opacity="0"></stop></radialGradient></defs><svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 511.991" width="352" height="352" x="80" y="80" alignment-baseline="middle" style="color: rgb(220, 238, 222); width: 352px; height: 352px;"><path fill="#fff" fill-rule="nonzero" d="M255.998.003C114.616.003 0 114.616 0 255.997c0 141.385 114.616 255.994 255.998 255.994C397.395 511.991 512 397.386 512 255.997 512 114.624 397.395.015 255.994.015l.004-.015v.003zm117.4 369.22c-4.585 7.519-14.427 9.908-21.949 5.288-60.104-36.714-135.771-45.027-224.882-24.668-8.587 1.954-17.146-3.425-19.104-12.015-1.967-8.591 3.394-17.15 12.003-19.104 97.518-22.28 181.164-12.688 248.645 28.55 7.522 4.616 9.907 14.427 5.288 21.95l-.001-.001zm31.335-69.703c-5.779 9.389-18.067 12.353-27.452 6.578-68.813-42.298-173.703-54.548-255.096-29.837-10.556 3.187-21.704-2.761-24.906-13.298-3.18-10.556 2.772-21.68 13.309-24.891 92.971-28.208 208.551-14.546 287.574 34.015 9.385 5.779 12.35 18.067 6.575 27.441v-.004l-.004-.004zm2.692-72.584c-82.511-49.006-218.635-53.51-297.409-29.603-12.649 3.837-26.027-3.302-29.86-15.955-3.832-12.656 3.303-26.023 15.96-29.867 90.428-27.452 240.753-22.149 335.747 34.245 11.401 6.754 15.133 21.446 8.375 32.809-6.728 11.378-21.462 15.13-32.802 8.371h-.011z"></path></svg></svg>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Spotify Connect",
|
|
3
|
+
"description": "Control Spotify playback and display album art on your dashboard",
|
|
4
|
+
"preferences": {
|
|
5
|
+
"clientId": {
|
|
6
|
+
"title": "Client ID",
|
|
7
|
+
"description": "Spotify Developer App client ID (create one at developer.spotify.com)"
|
|
8
|
+
},
|
|
9
|
+
"connect": {
|
|
10
|
+
"title": "Connect to Spotify",
|
|
11
|
+
"description": "Authorize BRIKA to access your Spotify account"
|
|
12
|
+
},
|
|
13
|
+
"developer": {
|
|
14
|
+
"title": "Spotify Developer Portal",
|
|
15
|
+
"description": "Create and manage your Spotify app credentials"
|
|
16
|
+
},
|
|
17
|
+
"defaultDevice": {
|
|
18
|
+
"title": "Default Device",
|
|
19
|
+
"description": "Select the device to auto-start playback on"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bricks": {
|
|
23
|
+
"player": {
|
|
24
|
+
"name": "Spotify Player",
|
|
25
|
+
"description": "Control Spotify playback and display album art",
|
|
26
|
+
"config": {
|
|
27
|
+
"device": {
|
|
28
|
+
"label": "Device",
|
|
29
|
+
"description": "Override the default device for this brick"
|
|
30
|
+
},
|
|
31
|
+
"refreshInterval": {
|
|
32
|
+
"label": "Refresh interval (ms)"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"blocks": {
|
|
38
|
+
"play": {
|
|
39
|
+
"name": "Play Spotify",
|
|
40
|
+
"description": "Start playback on a Spotify device"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"sparks": {
|
|
44
|
+
"track-changed": {
|
|
45
|
+
"name": "Track Changed",
|
|
46
|
+
"description": "Emitted when the playing track changes"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Spotify Connect",
|
|
3
|
+
"description": "Contrôlez la lecture Spotify et affichez les pochettes d'album sur votre tableau de bord",
|
|
4
|
+
"preferences": {
|
|
5
|
+
"clientId": {
|
|
6
|
+
"title": "ID Client",
|
|
7
|
+
"description": "ID client de l'application Spotify Developer (créez-en un sur developer.spotify.com)"
|
|
8
|
+
},
|
|
9
|
+
"connect": {
|
|
10
|
+
"title": "Se connecter à Spotify",
|
|
11
|
+
"description": "Autoriser BRIKA à accéder à votre compte Spotify"
|
|
12
|
+
},
|
|
13
|
+
"developer": {
|
|
14
|
+
"title": "Portail développeur Spotify",
|
|
15
|
+
"description": "Créez et gérez vos identifiants d'application Spotify"
|
|
16
|
+
},
|
|
17
|
+
"defaultDevice": {
|
|
18
|
+
"title": "Appareil par défaut",
|
|
19
|
+
"description": "Sélectionnez l'appareil pour démarrer automatiquement la lecture"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bricks": {
|
|
23
|
+
"player": {
|
|
24
|
+
"name": "Lecteur Spotify",
|
|
25
|
+
"description": "Contrôlez Spotify et affichez les pochettes d'album",
|
|
26
|
+
"config": {
|
|
27
|
+
"device": {
|
|
28
|
+
"label": "Appareil",
|
|
29
|
+
"description": "Remplacer l'appareil par défaut pour cette brique"
|
|
30
|
+
},
|
|
31
|
+
"refreshInterval": {
|
|
32
|
+
"label": "Intervalle de rafraîchissement (ms)"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"blocks": {
|
|
38
|
+
"play": {
|
|
39
|
+
"name": "Lire Spotify",
|
|
40
|
+
"description": "Démarrer la lecture sur un appareil Spotify"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"sparks": {
|
|
44
|
+
"track-changed": {
|
|
45
|
+
"name": "Piste modifiée",
|
|
46
|
+
"description": "Émis lorsque la piste en cours de lecture change"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schema.brika.dev/plugin.schema.json",
|
|
3
|
+
"name": "@brika/plugin-spotify",
|
|
4
|
+
"displayName": "Spotify",
|
|
5
|
+
"version": "0.3.0",
|
|
6
|
+
"description": "Spotify Connect player for BRIKA dashboards",
|
|
7
|
+
"author": "BRIKA Team",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/maxscharwath/brika.git",
|
|
12
|
+
"directory": "plugins/spotify"
|
|
13
|
+
},
|
|
14
|
+
"icon": "./icon.svg",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"brika",
|
|
17
|
+
"brika-plugin",
|
|
18
|
+
"spotify",
|
|
19
|
+
"music",
|
|
20
|
+
"player"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./src/index.tsx",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.tsx"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"brika": "^0.3.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"link": "bun link",
|
|
32
|
+
"tsc": "bunx --bun tsc --noEmit",
|
|
33
|
+
"prepublishOnly": "brika-verify-plugin"
|
|
34
|
+
},
|
|
35
|
+
"preferences": [
|
|
36
|
+
{
|
|
37
|
+
"name": "clientId",
|
|
38
|
+
"type": "text",
|
|
39
|
+
"label": "Client ID",
|
|
40
|
+
"description": "Spotify Developer App client ID",
|
|
41
|
+
"required": true
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "connect",
|
|
45
|
+
"type": "link",
|
|
46
|
+
"label": "Connect to Spotify",
|
|
47
|
+
"description": "Authorize BRIKA to access your Spotify account",
|
|
48
|
+
"url": "/api/oauth/spotify/authorize"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "developer",
|
|
52
|
+
"type": "link",
|
|
53
|
+
"label": "Spotify Developer Portal",
|
|
54
|
+
"description": "Create and manage your Spotify app credentials",
|
|
55
|
+
"url": "https://developer.spotify.com"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "defaultDevice",
|
|
59
|
+
"type": "dynamic-dropdown",
|
|
60
|
+
"label": "Default Device",
|
|
61
|
+
"description": "Device to auto-start playback on"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"bricks": [
|
|
65
|
+
{
|
|
66
|
+
"id": "player",
|
|
67
|
+
"name": "Spotify Player",
|
|
68
|
+
"description": "Control Spotify playback and display album art",
|
|
69
|
+
"category": "media",
|
|
70
|
+
"icon": "music",
|
|
71
|
+
"color": "#1DB954",
|
|
72
|
+
"config": [
|
|
73
|
+
{
|
|
74
|
+
"type": "dynamic-dropdown",
|
|
75
|
+
"name": "device",
|
|
76
|
+
"label": "Device",
|
|
77
|
+
"description": "Override the default device for this brick"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"type": "number",
|
|
81
|
+
"name": "refreshInterval",
|
|
82
|
+
"label": "Refresh interval (ms)",
|
|
83
|
+
"default": 3000,
|
|
84
|
+
"min": 1000,
|
|
85
|
+
"max": 30000,
|
|
86
|
+
"step": 1000
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"blocks": [
|
|
92
|
+
{
|
|
93
|
+
"id": "play",
|
|
94
|
+
"name": "Play Spotify",
|
|
95
|
+
"description": "Start playback on a Spotify device",
|
|
96
|
+
"category": "action",
|
|
97
|
+
"icon": "play",
|
|
98
|
+
"color": "#1DB954"
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"sparks": [
|
|
102
|
+
{
|
|
103
|
+
"id": "track-changed",
|
|
104
|
+
"name": "Track Changed",
|
|
105
|
+
"description": "Emitted when the playing track changes"
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
"dependencies": {
|
|
109
|
+
"@brika/sdk": "0.3.0"
|
|
110
|
+
},
|
|
111
|
+
"files": [
|
|
112
|
+
"src",
|
|
113
|
+
"locales",
|
|
114
|
+
"icon.svg",
|
|
115
|
+
"README.md"
|
|
116
|
+
]
|
|
117
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineReactiveBlock, input, log, output, z } from '@brika/sdk';
|
|
2
|
+
import { getApi, resolveDevice, toSpotifyUri } from '../shared';
|
|
3
|
+
|
|
4
|
+
export const playBlock = defineReactiveBlock(
|
|
5
|
+
{
|
|
6
|
+
id: 'play',
|
|
7
|
+
name: 'Play Spotify',
|
|
8
|
+
description: 'Start playback on a Spotify device',
|
|
9
|
+
category: 'action',
|
|
10
|
+
icon: 'play',
|
|
11
|
+
color: '#1DB954',
|
|
12
|
+
inputs: {
|
|
13
|
+
trigger: input(z.generic(), { name: 'Trigger' }),
|
|
14
|
+
},
|
|
15
|
+
outputs: {
|
|
16
|
+
started: output(z.object({ deviceId: z.string(), contextUri: z.string().optional() }), { name: 'Started' }),
|
|
17
|
+
error: output(z.object({ message: z.string() }), { name: 'Error' }),
|
|
18
|
+
},
|
|
19
|
+
config: z.object({
|
|
20
|
+
contextUri: z.string().optional().describe('Spotify URI or URL (playlist, album, or track). Empty = resume last played'),
|
|
21
|
+
deviceId: z.string().optional().describe('Device name or ID. Empty = use plugin default device'),
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
({ inputs, outputs, config }) => {
|
|
25
|
+
inputs.trigger.on(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const deviceId = await resolveDevice(config.deviceId);
|
|
28
|
+
const contextUri = toSpotifyUri(config.contextUri);
|
|
29
|
+
const recent = await getApi().getRecentlyPlayed();
|
|
30
|
+
const uri = contextUri ?? recent?.uri;
|
|
31
|
+
|
|
32
|
+
if (deviceId) await getApi().transferPlayback(deviceId);
|
|
33
|
+
await getApi().play(deviceId, uri);
|
|
34
|
+
|
|
35
|
+
const target = deviceId ? ` on ${deviceId}` : '';
|
|
36
|
+
log.info(`Spotify playback started${target}`);
|
|
37
|
+
outputs.started.emit({ deviceId: deviceId ?? '', contextUri: uri });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
40
|
+
log.error(`Spotify play failed: ${message}`);
|
|
41
|
+
outputs.error.emit({ message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared sub-components for the Spotify player brick.
|
|
3
|
+
* Each renders a reusable piece of the player UI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Button, Row, Slider, Text } from '@brika/sdk/bricks';
|
|
7
|
+
import { formatMs, progressPercent } from './utils';
|
|
8
|
+
|
|
9
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
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 ─────────────────────────────────────────────────────
|
|
21
|
+
|
|
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
|
+
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>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Track Info ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function TrackInfo({ trackName, artistName }: Readonly<{
|
|
43
|
+
trackName: string;
|
|
44
|
+
artistName: string;
|
|
45
|
+
}>) {
|
|
46
|
+
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>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Progress Bar ───────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function ProgressBar({ localProgressMs, durationMs, onSeek }: Readonly<{
|
|
57
|
+
localProgressMs: number;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
onSeek: (payload?: Record<string, unknown>) => void;
|
|
60
|
+
}>) {
|
|
61
|
+
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>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Volume Slider ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function VolumeSlider({ volume, onVolume }: Readonly<{
|
|
75
|
+
volume: number;
|
|
76
|
+
onVolume: (payload?: Record<string, unknown>) => void;
|
|
77
|
+
}>) {
|
|
78
|
+
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)" />
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
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;
|
|
27
|
+
playback: PlaybackState | null;
|
|
28
|
+
actions: PlayerActions;
|
|
29
|
+
}
|
|
30
|
+
|
|
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
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Layout: Medium (3-4 cols) ──────────────────────────────────────────────
|
|
51
|
+
|
|
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
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Layout: Large (5+ cols) ────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function LargePlayer({ track, playback, height, localProgressMs, actions }: Readonly<LayoutProps & { height: number; localProgressMs: number }>) {
|
|
73
|
+
const isPlaying = playback?.isPlaying ?? false;
|
|
74
|
+
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>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
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,13 @@
|
|
|
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
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineOAuth, definePreferenceOptions } from '@brika/sdk';
|
|
2
|
+
import { log, onStop } from '@brika/sdk/lifecycle';
|
|
3
|
+
|
|
4
|
+
// ─── OAuth ────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const spotify = defineOAuth({
|
|
7
|
+
id: 'spotify',
|
|
8
|
+
authorizeUrl: 'https://accounts.spotify.com/authorize',
|
|
9
|
+
tokenUrl: 'https://accounts.spotify.com/api/token',
|
|
10
|
+
scopes: [
|
|
11
|
+
'user-read-playback-state',
|
|
12
|
+
'user-modify-playback-state',
|
|
13
|
+
'user-read-currently-playing',
|
|
14
|
+
'user-read-recently-played',
|
|
15
|
+
],
|
|
16
|
+
clientIdPreference: 'clientId',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ─── Dynamic Preferences ─────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
// Lazy import to break the circular dependency (shared.ts imports spotify from here)
|
|
22
|
+
async function fetchDeviceOptions() {
|
|
23
|
+
const { getApi } = await import('./shared');
|
|
24
|
+
const devices = await getApi().getDevices();
|
|
25
|
+
return devices.map((d) => ({ value: d.id, label: `${d.name} (${d.type})` }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
definePreferenceOptions('defaultDevice', fetchDeviceOptions);
|
|
29
|
+
definePreferenceOptions('device', fetchDeviceOptions);
|
|
30
|
+
|
|
31
|
+
// ─── Sparks ───────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export { trackChanged } from './sparks';
|
|
34
|
+
|
|
35
|
+
// ─── Blocks ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export { playBlock } from './blocks/play';
|
|
38
|
+
|
|
39
|
+
// ─── Bricks ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export { playerBrick } from './bricks/player';
|
|
42
|
+
|
|
43
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
onStop(() => {
|
|
46
|
+
log.info('Spotify plugin stopping');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
log.info('Spotify plugin loaded');
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared playback store — one polling loop, all player bricks react.
|
|
3
|
+
*
|
|
4
|
+
* Built on `defineSharedStore` from the SDK. Any brick that calls
|
|
5
|
+
* `usePlayerStore()` automatically re-renders when the state changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { log } from '@brika/sdk';
|
|
9
|
+
import { defineSharedStore } from '@brika/sdk/bricks';
|
|
10
|
+
import { spotify } from './index';
|
|
11
|
+
import { getApi } from './shared';
|
|
12
|
+
import { trackChanged } from './sparks';
|
|
13
|
+
import type { PlaybackState, RecentTrack, SpotifyDevice } from './spotify-api';
|
|
14
|
+
import { SpotifyAuthError } from './spotify-api';
|
|
15
|
+
|
|
16
|
+
// ─── Store ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface Anchor { progressMs: number; timestamp: number }
|
|
19
|
+
|
|
20
|
+
interface PlayerState {
|
|
21
|
+
playback: PlaybackState | null;
|
|
22
|
+
recentTrack: RecentTrack | null;
|
|
23
|
+
devices: SpotifyDevice[];
|
|
24
|
+
isAuthed: boolean;
|
|
25
|
+
loaded: boolean;
|
|
26
|
+
anchor: Anchor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const usePlayerStore = defineSharedStore<PlayerState>({
|
|
30
|
+
playback: null,
|
|
31
|
+
recentTrack: null,
|
|
32
|
+
devices: [],
|
|
33
|
+
isAuthed: false,
|
|
34
|
+
loaded: false,
|
|
35
|
+
anchor: { progressMs: 0, timestamp: Date.now() },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── Polling (reference-counted) ─────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const POLL_MS = 3000;
|
|
41
|
+
|
|
42
|
+
let refCount = 0;
|
|
43
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
44
|
+
let lastTrack = '';
|
|
45
|
+
|
|
46
|
+
/** Reset the polling interval so the next poll is a full POLL_MS away. */
|
|
47
|
+
function resetPollTimer(): void {
|
|
48
|
+
if (!timer) return;
|
|
49
|
+
clearInterval(timer);
|
|
50
|
+
timer = setInterval(poll, POLL_MS);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Force an immediate poll and restart the timer. */
|
|
54
|
+
function pollNow(): void {
|
|
55
|
+
poll();
|
|
56
|
+
resetPollTimer();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function emitTrackChanged(state: PlaybackState): void {
|
|
60
|
+
if (state.trackName === lastTrack) return;
|
|
61
|
+
lastTrack = state.trackName;
|
|
62
|
+
trackChanged.emit({
|
|
63
|
+
trackName: state.trackName,
|
|
64
|
+
artistName: state.artistName,
|
|
65
|
+
albumName: state.albumName,
|
|
66
|
+
albumArt: state.albumArt,
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function poll(): Promise<void> {
|
|
72
|
+
if (!spotify.isAuthenticated()) {
|
|
73
|
+
usePlayerStore.set({ playback: null, recentTrack: null, devices: [], isAuthed: false, loaded: true, anchor: { progressMs: 0, timestamp: Date.now() } });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const state = await getApi().getCurrentPlayback();
|
|
79
|
+
const anchor: Anchor = { progressMs: state?.progressMs ?? 0, timestamp: Date.now() };
|
|
80
|
+
|
|
81
|
+
let devices: SpotifyDevice[] = [];
|
|
82
|
+
let recentTrack = usePlayerStore.get().recentTrack;
|
|
83
|
+
if (state) {
|
|
84
|
+
recentTrack = null;
|
|
85
|
+
} else {
|
|
86
|
+
[devices, recentTrack] = await Promise.all([
|
|
87
|
+
getApi().getDevices(),
|
|
88
|
+
recentTrack ? Promise.resolve(recentTrack) : getApi().getRecentlyPlayed(),
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
usePlayerStore.set({ playback: state, recentTrack, devices, isAuthed: true, loaded: true, anchor });
|
|
92
|
+
|
|
93
|
+
if (state) emitTrackChanged(state);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err instanceof SpotifyAuthError) {
|
|
96
|
+
usePlayerStore.set((prev) => ({ ...prev, playback: null, isAuthed: false, loaded: true }));
|
|
97
|
+
} else {
|
|
98
|
+
usePlayerStore.set((prev) => ({ ...prev, loaded: true }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Acquire polling — starts on first subscriber, stops on last. */
|
|
104
|
+
export function acquirePolling(): () => void {
|
|
105
|
+
refCount++;
|
|
106
|
+
if (refCount === 1) {
|
|
107
|
+
// Sync auth state immediately so bricks don't flash the login screen
|
|
108
|
+
const authed = spotify.isAuthenticated();
|
|
109
|
+
if (authed !== usePlayerStore.get().isAuthed) {
|
|
110
|
+
usePlayerStore.set((prev) => ({ ...prev, isAuthed: authed }));
|
|
111
|
+
}
|
|
112
|
+
poll();
|
|
113
|
+
timer = setInterval(poll, POLL_MS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let released = false;
|
|
117
|
+
return () => {
|
|
118
|
+
if (released) return;
|
|
119
|
+
released = true;
|
|
120
|
+
refCount--;
|
|
121
|
+
if (refCount === 0 && timer) {
|
|
122
|
+
clearInterval(timer);
|
|
123
|
+
timer = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Actions ─────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** Catch and log errors from fire-and-forget API calls. */
|
|
131
|
+
function silent(promise: Promise<unknown>): void {
|
|
132
|
+
promise.catch((err) => {
|
|
133
|
+
log.error(`Spotify API error: ${err instanceof Error ? err.message : String(err)}`);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function play(deviceId?: string): void {
|
|
138
|
+
silent(getApi().play(deviceId));
|
|
139
|
+
usePlayerStore.set((prev) => {
|
|
140
|
+
if (!prev.playback) return prev;
|
|
141
|
+
const elapsed = Date.now() - prev.anchor.timestamp;
|
|
142
|
+
return {
|
|
143
|
+
...prev,
|
|
144
|
+
playback: { ...prev.playback, isPlaying: true },
|
|
145
|
+
anchor: { progressMs: prev.anchor.progressMs + elapsed, timestamp: Date.now() },
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
resetPollTimer();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function pause(deviceId?: string): void {
|
|
152
|
+
silent(getApi().pause(deviceId));
|
|
153
|
+
usePlayerStore.set((prev) => {
|
|
154
|
+
if (!prev.playback) return prev;
|
|
155
|
+
const elapsed = Date.now() - prev.anchor.timestamp;
|
|
156
|
+
const progressMs = Math.min(prev.anchor.progressMs + elapsed, prev.playback.durationMs);
|
|
157
|
+
return {
|
|
158
|
+
...prev,
|
|
159
|
+
playback: { ...prev.playback, isPlaying: false },
|
|
160
|
+
anchor: { progressMs, timestamp: Date.now() },
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
resetPollTimer();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function next(): void {
|
|
167
|
+
silent(getApi().next().then(() => pollNow()));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function previous(): void {
|
|
171
|
+
silent(getApi().previous().then(() => pollNow()));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function seek(positionMs: number): void {
|
|
175
|
+
silent(getApi().seek(positionMs));
|
|
176
|
+
usePlayerStore.set((prev) => ({
|
|
177
|
+
...prev,
|
|
178
|
+
playback: prev.playback ? { ...prev.playback, progressMs: positionMs } : null,
|
|
179
|
+
anchor: { progressMs: positionMs, timestamp: Date.now() },
|
|
180
|
+
}));
|
|
181
|
+
resetPollTimer();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function setVolume(percent: number): void {
|
|
185
|
+
silent(getApi().setVolume(percent));
|
|
186
|
+
usePlayerStore.set((prev) => {
|
|
187
|
+
if (!prev.playback) return prev;
|
|
188
|
+
return { ...prev, playback: { ...prev.playback, volume: percent } };
|
|
189
|
+
});
|
|
190
|
+
resetPollTimer();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function transferPlayback(deviceId: string): void {
|
|
194
|
+
silent(getApi().transferPlayback(deviceId).then(() => pollNow()));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function startPlayback(deviceId?: string): Promise<void> {
|
|
198
|
+
if (deviceId) await getApi().transferPlayback(deviceId);
|
|
199
|
+
const recent = await getApi().getRecentlyPlayed();
|
|
200
|
+
await getApi().play(deviceId, recent?.uri);
|
|
201
|
+
pollNow();
|
|
202
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the Spotify plugin.
|
|
3
|
+
*
|
|
4
|
+
* Uses lazy initialization to avoid circular imports — `spotify` (the OAuth
|
|
5
|
+
* client) is exported from `index.tsx`, which re-exports bricks/blocks/sparks
|
|
6
|
+
* that also need the API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getPreferences } from '@brika/sdk';
|
|
10
|
+
import { spotify } from './index';
|
|
11
|
+
import { createSpotifyApi } from './spotify-api';
|
|
12
|
+
|
|
13
|
+
// ─── Lazy API singleton ───────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
let api: ReturnType<typeof createSpotifyApi> | null = null;
|
|
16
|
+
|
|
17
|
+
/** Return (or create) the shared Spotify API instance. */
|
|
18
|
+
export function getApi(): ReturnType<typeof createSpotifyApi> {
|
|
19
|
+
api ??= createSpotifyApi(spotify);
|
|
20
|
+
return api;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Device resolution ────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the best device ID from instance config → plugin preference → undefined.
|
|
27
|
+
* Callers can further fall back to `devices[0]?.id` when a list is available.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveDeviceId(instanceDeviceId?: string): string | undefined {
|
|
30
|
+
const id = instanceDeviceId?.trim() || undefined;
|
|
31
|
+
if (id) return id;
|
|
32
|
+
const prefs = getPreferences<{ defaultDevice?: string }>();
|
|
33
|
+
return prefs.defaultDevice?.trim() || undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a device value that may be an ID or a name.
|
|
38
|
+
* Fetches the device list and matches by ID first, then by name (case-insensitive).
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveDevice(value?: string): Promise<string | undefined> {
|
|
41
|
+
const id = resolveDeviceId(value);
|
|
42
|
+
if (!id) return undefined;
|
|
43
|
+
|
|
44
|
+
const devices = await getApi().getDevices();
|
|
45
|
+
// Exact ID match — return as-is
|
|
46
|
+
if (devices.some((d) => d.id === id)) return id;
|
|
47
|
+
// Name match (case-insensitive)
|
|
48
|
+
const byName = devices.find((d) => d.name.toLowerCase() === id.toLowerCase());
|
|
49
|
+
return byName?.id ?? id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Spotify URI helpers ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const SPOTIFY_URL_RE = /^https?:\/\/open\.spotify\.com\/(track|album|playlist)\/([A-Za-z0-9]+)/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a Spotify URL or URI to a proper `spotify:type:id` URI.
|
|
58
|
+
* Accepts:
|
|
59
|
+
* - `spotify:track:ABC123` → returned as-is
|
|
60
|
+
* - `https://open.spotify.com/track/ABC123?si=...` → `spotify:track:ABC123`
|
|
61
|
+
* Returns undefined for empty/invalid input.
|
|
62
|
+
*/
|
|
63
|
+
export function toSpotifyUri(input?: string): string | undefined {
|
|
64
|
+
const value = input?.trim();
|
|
65
|
+
if (!value) return undefined;
|
|
66
|
+
if (value.startsWith('spotify:')) return value;
|
|
67
|
+
const match = SPOTIFY_URL_RE.exec(value);
|
|
68
|
+
if (match) return `spotify:${match[1]}:${match[2]}`;
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
package/src/sparks.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineSpark, z } from '@brika/sdk/sparks';
|
|
2
|
+
|
|
3
|
+
export const trackChanged = defineSpark({
|
|
4
|
+
id: 'track-changed',
|
|
5
|
+
schema: z.object({
|
|
6
|
+
trackName: z.string(),
|
|
7
|
+
artistName: z.string(),
|
|
8
|
+
albumName: z.string(),
|
|
9
|
+
albumArt: z.string().nullable(),
|
|
10
|
+
timestamp: z.number(),
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spotify Web API wrapper using the OAuth client.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { OAuthClient } from '@brika/sdk';
|
|
6
|
+
|
|
7
|
+
const BASE = 'https://api.spotify.com/v1';
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Types
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface SpotifyImage {
|
|
14
|
+
url: string;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PlaybackState {
|
|
20
|
+
isPlaying: boolean;
|
|
21
|
+
trackName: string;
|
|
22
|
+
artistName: string;
|
|
23
|
+
albumName: string;
|
|
24
|
+
albumArt: string | null;
|
|
25
|
+
progressMs: number;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
volume: number;
|
|
28
|
+
deviceName: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RecentTrack {
|
|
32
|
+
trackName: string;
|
|
33
|
+
artistName: string;
|
|
34
|
+
albumArt: string | null;
|
|
35
|
+
uri: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SpotifyDevice {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
type: string;
|
|
42
|
+
isActive: boolean;
|
|
43
|
+
volumePercent: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// API Factory
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** Thrown when the Spotify API returns 401 — signals re-auth is needed. */
|
|
51
|
+
export class SpotifyAuthError extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super('Spotify token expired or revoked');
|
|
54
|
+
this.name = 'SpotifyAuthError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pickArt(images: SpotifyImage[]): string | null {
|
|
59
|
+
const art = images.find((i) => i.width === 640) ?? images[0];
|
|
60
|
+
return art?.url ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSpotifyApi(oauth: OAuthClient) {
|
|
64
|
+
async function api<T>(path: string, init?: RequestInit): Promise<T | null> {
|
|
65
|
+
if (!oauth.isAuthenticated()) return null;
|
|
66
|
+
|
|
67
|
+
const res = await oauth.fetch(`${BASE}${path}`, init);
|
|
68
|
+
|
|
69
|
+
// 204 = no active device/playback — legitimate "no data"
|
|
70
|
+
if (res.status === 204) return null;
|
|
71
|
+
|
|
72
|
+
// 401 = token invalid — bubble up so the brick can reset auth state
|
|
73
|
+
if (res.status === 401) throw new SpotifyAuthError();
|
|
74
|
+
|
|
75
|
+
// Other errors — return null (rate-limit, server error, etc.)
|
|
76
|
+
if (!res.ok) return null;
|
|
77
|
+
|
|
78
|
+
// Some endpoints return empty bodies (play, pause, next, previous)
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
if (!text) return null;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text) as T;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
async getCurrentPlayback(): Promise<PlaybackState | null> {
|
|
91
|
+
const data = await api<{
|
|
92
|
+
is_playing: boolean;
|
|
93
|
+
progress_ms: number;
|
|
94
|
+
item: {
|
|
95
|
+
name: string;
|
|
96
|
+
duration_ms: number;
|
|
97
|
+
artists: { name: string }[];
|
|
98
|
+
album: {
|
|
99
|
+
name: string;
|
|
100
|
+
images: SpotifyImage[];
|
|
101
|
+
};
|
|
102
|
+
} | null;
|
|
103
|
+
device: {
|
|
104
|
+
name: string;
|
|
105
|
+
volume_percent: number;
|
|
106
|
+
};
|
|
107
|
+
}>('/me/player');
|
|
108
|
+
|
|
109
|
+
if (!data?.item) return null;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
isPlaying: data.is_playing,
|
|
113
|
+
trackName: data.item.name,
|
|
114
|
+
artistName: data.item.artists.map((a) => a.name).join(', '),
|
|
115
|
+
albumName: data.item.album.name,
|
|
116
|
+
albumArt: pickArt(data.item.album.images),
|
|
117
|
+
progressMs: data.progress_ms,
|
|
118
|
+
durationMs: data.item.duration_ms,
|
|
119
|
+
volume: data.device.volume_percent,
|
|
120
|
+
deviceName: data.device.name,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async play(deviceId?: string, contextUri?: string): Promise<void> {
|
|
125
|
+
const qs = deviceId ? `?device_id=${deviceId}` : '';
|
|
126
|
+
let body: string | undefined;
|
|
127
|
+
if (contextUri) {
|
|
128
|
+
const payload = contextUri.includes(':track:')
|
|
129
|
+
? { uris: [contextUri] }
|
|
130
|
+
: { context_uri: contextUri };
|
|
131
|
+
body = JSON.stringify(payload);
|
|
132
|
+
}
|
|
133
|
+
await api(`/me/player/play${qs}`, {
|
|
134
|
+
method: 'PUT',
|
|
135
|
+
...(body && { headers: { 'Content-Type': 'application/json' }, body }),
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async pause(deviceId?: string): Promise<void> {
|
|
140
|
+
const qs = deviceId ? `?device_id=${deviceId}` : '';
|
|
141
|
+
await api(`/me/player/pause${qs}`, { method: 'PUT' });
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async next(): Promise<void> {
|
|
145
|
+
await api('/me/player/next', { method: 'POST' });
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async previous(): Promise<void> {
|
|
149
|
+
await api('/me/player/previous', { method: 'POST' });
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async seek(positionMs: number): Promise<void> {
|
|
153
|
+
const ms = Math.round(Math.max(0, positionMs));
|
|
154
|
+
await api(`/me/player/seek?position_ms=${ms}`, { method: 'PUT' });
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async setVolume(percent: number): Promise<void> {
|
|
158
|
+
const vol = Math.round(Math.max(0, Math.min(100, percent)));
|
|
159
|
+
await api(`/me/player/volume?volume_percent=${vol}`, { method: 'PUT' });
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async transferPlayback(deviceId: string): Promise<void> {
|
|
163
|
+
await api('/me/player', {
|
|
164
|
+
method: 'PUT',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ device_ids: [deviceId], play: false }),
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async getRecentlyPlayed(): Promise<RecentTrack | null> {
|
|
171
|
+
const data = await api<{
|
|
172
|
+
items: Array<{
|
|
173
|
+
context?: { uri: string };
|
|
174
|
+
track: {
|
|
175
|
+
uri: string;
|
|
176
|
+
name: string;
|
|
177
|
+
artists: { name: string }[];
|
|
178
|
+
album: { images: SpotifyImage[] };
|
|
179
|
+
};
|
|
180
|
+
}>;
|
|
181
|
+
}>('/me/player/recently-played?limit=1');
|
|
182
|
+
if (!data?.items?.[0]) return null;
|
|
183
|
+
const { context, track } = data.items[0];
|
|
184
|
+
return {
|
|
185
|
+
trackName: track.name,
|
|
186
|
+
artistName: track.artists.map((a) => a.name).join(', '),
|
|
187
|
+
albumArt: pickArt(track.album.images),
|
|
188
|
+
uri: context?.uri ?? track.uri,
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async getDevices(): Promise<SpotifyDevice[]> {
|
|
193
|
+
const data = await api<{
|
|
194
|
+
devices: {
|
|
195
|
+
id: string;
|
|
196
|
+
name: string;
|
|
197
|
+
type: string;
|
|
198
|
+
is_active: boolean;
|
|
199
|
+
volume_percent: number;
|
|
200
|
+
}[];
|
|
201
|
+
}>('/me/player/devices');
|
|
202
|
+
|
|
203
|
+
if (!data) return [];
|
|
204
|
+
|
|
205
|
+
return data.devices.map((d) => ({
|
|
206
|
+
id: d.id,
|
|
207
|
+
name: d.name,
|
|
208
|
+
type: d.type,
|
|
209
|
+
isActive: d.is_active,
|
|
210
|
+
volumePercent: d.volume_percent,
|
|
211
|
+
}));
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type SpotifyApi = ReturnType<typeof createSpotifyApi>;
|