morille 0.1.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.
Files changed (78) hide show
  1. package/README.md +82 -0
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +66 -0
  4. package/dist/components/animated-line.d.ts +12 -0
  5. package/dist/components/animated-line.js +14 -0
  6. package/dist/components/browse-detail-view.d.ts +14 -0
  7. package/dist/components/browse-detail-view.js +31 -0
  8. package/dist/components/keyboard-hints.d.ts +12 -0
  9. package/dist/components/keyboard-hints.js +8 -0
  10. package/dist/components/lyrics-panel.d.ts +10 -0
  11. package/dist/components/lyrics-panel.js +38 -0
  12. package/dist/components/lyrics-view.d.ts +14 -0
  13. package/dist/components/lyrics-view.js +50 -0
  14. package/dist/components/panel-content.d.ts +10 -0
  15. package/dist/components/panel-content.js +22 -0
  16. package/dist/components/playback-status.d.ts +16 -0
  17. package/dist/components/playback-status.js +22 -0
  18. package/dist/components/player.d.ts +9 -0
  19. package/dist/components/player.d.ts.map +1 -0
  20. package/dist/components/player.js +215 -0
  21. package/dist/components/player.js.map +1 -0
  22. package/dist/components/progress-bar.d.ts +11 -0
  23. package/dist/components/progress-bar.js +13 -0
  24. package/dist/components/queue-view.d.ts +9 -0
  25. package/dist/components/queue-view.js +54 -0
  26. package/dist/components/search-panel.d.ts +8 -0
  27. package/dist/components/search-panel.js +152 -0
  28. package/dist/components/shimmer.d.ts +12 -0
  29. package/dist/components/shimmer.js +34 -0
  30. package/dist/components/side-panel.d.ts +12 -0
  31. package/dist/components/side-panel.js +12 -0
  32. package/dist/components/track-info-skeleton.d.ts +9 -0
  33. package/dist/components/track-info-skeleton.js +10 -0
  34. package/dist/components/track-info.d.ts +10 -0
  35. package/dist/components/track-info.js +15 -0
  36. package/dist/config.d.ts +33 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +65 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/contexts/lyrics-context.d.ts +29 -0
  41. package/dist/contexts/lyrics-context.js +44 -0
  42. package/dist/contexts/panel-mode-context.d.ts +24 -0
  43. package/dist/contexts/panel-mode-context.js +45 -0
  44. package/dist/contexts/queue-context.d.ts +32 -0
  45. package/dist/contexts/queue-context.js +68 -0
  46. package/dist/contexts/search-context.d.ts +59 -0
  47. package/dist/contexts/search-context.js +338 -0
  48. package/dist/hooks/use-album-art.d.ts +8 -0
  49. package/dist/hooks/use-album-art.js +56 -0
  50. package/dist/hooks/use-browse.d.ts +29 -0
  51. package/dist/hooks/use-browse.js +98 -0
  52. package/dist/hooks/use-lyrics.d.ts +12 -0
  53. package/dist/hooks/use-lyrics.js +51 -0
  54. package/dist/hooks/use-playback.d.ts +24 -0
  55. package/dist/hooks/use-playback.js +282 -0
  56. package/dist/hooks/use-player-input.d.ts +18 -0
  57. package/dist/hooks/use-player-input.js +201 -0
  58. package/dist/hooks/use-queue.d.ts +28 -0
  59. package/dist/hooks/use-queue.js +194 -0
  60. package/dist/hooks/use-search.d.ts +16 -0
  61. package/dist/hooks/use-search.js +77 -0
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.js +10 -0
  64. package/dist/main.d.ts +1 -0
  65. package/dist/main.js +6 -0
  66. package/dist/spotify/auth.d.ts +36 -0
  67. package/dist/spotify/auth.js +183 -0
  68. package/dist/spotify/client.d.ts +18 -0
  69. package/dist/spotify/client.js +48 -0
  70. package/dist/spotify/fetch-with-retry.d.ts +6 -0
  71. package/dist/spotify/fetch-with-retry.js +70 -0
  72. package/dist/spotify/lyrics.d.ts +25 -0
  73. package/dist/spotify/lyrics.js +130 -0
  74. package/dist/spotify/playback.d.ts +115 -0
  75. package/dist/spotify/playback.js +201 -0
  76. package/dist/spotify/search.d.ts +79 -0
  77. package/dist/spotify/search.js +143 -0
  78. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # morille
2
+
3
+ A terminal-based Spotify player built with TypeScript, ink (React for the terminal), and the Spotify Web API.
4
+
5
+ ## Install & run
6
+
7
+ ```sh
8
+ npx morille
9
+ ```
10
+
11
+ That's it. morille ships with a default Spotify Client ID baked into the binary,
12
+ so the app works out of the box — no registration required for most users.
13
+
14
+ > Spotify Premium is required for playback control.
15
+
16
+ ### Running from source
17
+
18
+ ```sh
19
+ pnpm install
20
+ pnpm build
21
+ node dist/index.js
22
+ ```
23
+
24
+ ### Using your own Client ID
25
+
26
+ If you want to use your own Spotify app (for development, higher rate limits,
27
+ or access to more users via [extended quota mode][quota]), override the
28
+ embedded default with an environment variable:
29
+
30
+ 1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
31
+ 2. Create a new app
32
+ 3. In your app settings, add `http://127.0.0.1:8888/callback` as a Redirect URI
33
+ 4. Copy the **Client ID** from your app's overview page
34
+ 5. Export it before running morille:
35
+ ```sh
36
+ export SPOTIFY_CLIENT_ID=your_client_id_here
37
+ npx morille
38
+ ```
39
+
40
+ `SPOTIFY_CLIENT_ID` always takes precedence over the embedded default. To
41
+ change the embedded default, edit `DEFAULT_SPOTIFY_CLIENT_ID` in `src/config.ts`
42
+ and rebuild.
43
+
44
+ [quota]: https://developer.spotify.com/documentation/web-api/concepts/quota-modes
45
+
46
+ ## Usage
47
+
48
+ - **Space** - toggle play/pause
49
+ - **n** / **Right arrow** - next track
50
+ - **p** / **Left arrow** - previous track
51
+ - **.** - seek forward 10s
52
+ - **,** - seek backward 10s
53
+ - **Up arrow** - volume up 5%
54
+ - **Down arrow** - volume down 5%
55
+ - **s** - toggle shuffle
56
+ - **r** - cycle repeat (off / context / track)
57
+ - **l** - toggle lyrics display
58
+ - **d** - toggle queue view (up/down to navigate, enter to select)
59
+ - **/** - search tracks, albums, artists, playlists (type to search, left/right to switch category, enter to select)
60
+ - **b** - browse your playlists (enter to view tracks, enter to play)
61
+ - **q** - quit
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ pnpm install
67
+ pnpm dev # watch mode
68
+ pnpm build # compile
69
+ pnpm test:ts # type-check
70
+ pnpm test:biome # lint/format check
71
+ ```
72
+
73
+ ## Roadmap
74
+
75
+ - Add track to queue
76
+ - Device selection / transfer playback
77
+ - ~~Search tracks, albums, artists, and playlists~~
78
+ - ~~Browse and play playlists~~
79
+ - ~~Browse and play albums~~
80
+ - Show recently played tracks
81
+ - Like / unlike tracks (save to library)
82
+ - Keyboard shortcuts customization
package/dist/app.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Root application component. Handles Spotify authentication on mount,
3
+ * then renders the {@link Player} once connected.
4
+ */
5
+ export declare function App(): import("react/jsx-runtime").JSX.Element;
package/dist/app.js ADDED
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput } from 'ink';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Player } from './components/player.js';
5
+ import { createSpotifyClient, getClientId } from './spotify/client.js';
6
+ /**
7
+ * Root application component. Handles Spotify authentication on mount,
8
+ * then renders the {@link Player} once connected.
9
+ */
10
+ export function App() {
11
+ const [client, setClient] = useState(null);
12
+ const [authError, setAuthError] = useState(null);
13
+ const [authUrl, setAuthUrl] = useState(null);
14
+ const openBrowserRef = useRef(null);
15
+ const { exit } = useApp();
16
+ useEffect(() => {
17
+ let cancelled = false;
18
+ async function init() {
19
+ try {
20
+ const clientId = getClientId();
21
+ const spotify = await createSpotifyClient(clientId, {
22
+ onAuthUrl: (url, openFn) => {
23
+ if (!cancelled) {
24
+ setAuthUrl(url);
25
+ openBrowserRef.current = openFn;
26
+ }
27
+ },
28
+ });
29
+ if (!cancelled) {
30
+ setClient(spotify);
31
+ }
32
+ }
33
+ catch (err) {
34
+ if (!cancelled) {
35
+ setAuthError(err instanceof Error ? err.message : 'Authentication failed');
36
+ }
37
+ }
38
+ }
39
+ init();
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, []);
44
+ useInput((input) => {
45
+ if (input === 'o' && openBrowserRef.current) {
46
+ openBrowserRef.current();
47
+ }
48
+ }, {
49
+ isActive: !client && !!authUrl,
50
+ });
51
+ useEffect(() => {
52
+ if (authError) {
53
+ exit();
54
+ }
55
+ }, [
56
+ authError,
57
+ exit,
58
+ ]);
59
+ if (authError) {
60
+ return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "red", children: authError }) }));
61
+ }
62
+ if (!client) {
63
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { dimColor: true, children: "Authenticating with Spotify..." }), authUrl && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Open this URL in your browser, or press ", _jsx(Text, { bold: true, children: "o" }), " to open automatically:"] }), _jsx(Text, { color: "cyan", children: authUrl })] }))] }));
64
+ }
65
+ return _jsx(Player, { client: client });
66
+ }
@@ -0,0 +1,12 @@
1
+ type AnimatedLineProps = {
2
+ text: string;
3
+ progress: number;
4
+ };
5
+ /**
6
+ * Renders a text line with a karaoke-style sweep animation.
7
+ * Characters that have "passed" are dark green, upcoming characters are bright green.
8
+ * @param text - The lyric line text
9
+ * @param progress - Fraction of the line elapsed, from 0 to 1
10
+ */
11
+ export declare function AnimatedLine({ text, progress }: AnimatedLineProps): import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ /**
4
+ * Renders a text line with a karaoke-style sweep animation.
5
+ * Characters that have "passed" are dark green, upcoming characters are bright green.
6
+ * @param text - The lyric line text
7
+ * @param progress - Fraction of the line elapsed, from 0 to 1
8
+ */
9
+ export function AnimatedLine({ text, progress }) {
10
+ const splitIndex = Math.round(Math.min(Math.max(progress, 0), 1) * text.length);
11
+ const passed = text.slice(0, splitIndex);
12
+ const upcoming = text.slice(splitIndex);
13
+ return (_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "#0f570f", children: passed }), _jsx(Text, { color: "#0cb80c", children: upcoming })] }));
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { BrowseData } from '../hooks/use-browse.js';
2
+ type BrowseDetailViewProps = {
3
+ data: BrowseData;
4
+ selectedIndex: number;
5
+ currentTrackUri: string | null;
6
+ height: number;
7
+ };
8
+ /**
9
+ * Renders album or playlist track lists with a scrolling selection window.
10
+ * Reuses the windowing pattern from queue-view for consistent navigation.
11
+ * Highlights the currently playing track in green with a play icon.
12
+ */
13
+ export declare function BrowseDetailView({ data, selectedIndex, currentTrackUri, height }: BrowseDetailViewProps): import("react/jsx-runtime").JSX.Element;
14
+ export {};
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Renders album or playlist track lists with a scrolling selection window.
5
+ * Reuses the windowing pattern from queue-view for consistent navigation.
6
+ * Highlights the currently playing track in green with a play icon.
7
+ */
8
+ export function BrowseDetailView({ data, selectedIndex, currentTrackUri, height }) {
9
+ const headerLines = 2;
10
+ const listHeight = Math.max(height - headerLines, 2);
11
+ const items = data.tracks;
12
+ const halfHeight = Math.floor(listHeight / 2);
13
+ const startIndex = Math.max(0, Math.min(selectedIndex - halfHeight, items.length - listHeight));
14
+ const visibleCount = Math.min(listHeight, items.length);
15
+ return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsxs(Box, { gap: 1, borderColor: "gray", borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: [_jsx(Text, { bold: true, children: data.title }), data.subtitle && _jsxs(Text, { dimColor: true, children: ["- ", data.subtitle] })] }), items.length === 0 && _jsx(Text, { dimColor: true, children: "No tracks found" }), Array.from({
16
+ length: visibleCount,
17
+ }, (_, i) => {
18
+ const idx = startIndex + i;
19
+ const item = items[idx];
20
+ if (!item)
21
+ return _jsx(Text, { children: " " }, idx);
22
+ const label = `${item.artist} - ${item.name}`;
23
+ const isSelected = idx === selectedIndex;
24
+ const isPlaying = currentTrackUri !== null && item.uri === currentTrackUri;
25
+ const icon = isPlaying ? '\u25B6 ' : isSelected ? '> ' : ' ';
26
+ if (isPlaying) {
27
+ return (_jsxs(Text, { bold: true, color: "green", inverse: isSelected, children: [icon, label] }, item.uri + idx));
28
+ }
29
+ return (_jsxs(Text, { inverse: isSelected, children: [icon, label] }, item.uri + idx));
30
+ })] }));
31
+ }
@@ -0,0 +1,12 @@
1
+ type Hint = {
2
+ key: string;
3
+ label: string;
4
+ };
5
+ type KeyboardHintsProps = {
6
+ hints: Hint[];
7
+ };
8
+ /**
9
+ * Renders a row of keyboard shortcut hints in dimmed text.
10
+ */
11
+ export declare function KeyboardHints({ hints }: KeyboardHintsProps): import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Renders a row of keyboard shortcut hints in dimmed text.
5
+ */
6
+ export function KeyboardHints({ hints }) {
7
+ return (_jsx(Box, { marginTop: 1, gap: 1, flexWrap: "wrap", children: hints.map((hint) => (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, dimColor: true, children: hint.key }), ":", hint.label] }, hint.key))) }));
8
+ }
@@ -0,0 +1,10 @@
1
+ type LyricsPanelProps = {
2
+ progressMs: number;
3
+ height: number;
4
+ };
5
+ /**
6
+ * Renders lyrics content from LyricsContext.
7
+ * Shows loading state, synced lyrics view, plain text fallback, or no-lyrics message.
8
+ */
9
+ export declare function LyricsPanel({ progressMs, height }: LyricsPanelProps): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useLyricsContext } from '../contexts/lyrics-context.js';
4
+ import { LyricsView } from './lyrics-view.js';
5
+ /**
6
+ * Renders plain text lyrics in a scrollable view.
7
+ * Scrolls via keyboard (up/down arrows).
8
+ */
9
+ function PlainLyricsView({ text, scrollPosition, height }) {
10
+ const lines = text.split('\n');
11
+ const maxStart = Math.max(lines.length - height, 0);
12
+ const startIndex = Math.min(scrollPosition, maxStart);
13
+ const visible = lines.slice(startIndex, startIndex + height);
14
+ return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: visible.map((line, offset) => {
15
+ const lineIndex = startIndex + offset;
16
+ return (_jsx(Text, { dimColor: true, children: line || ' ' }, lineIndex));
17
+ }) }));
18
+ }
19
+ /**
20
+ * Renders lyrics content from LyricsContext.
21
+ * Shows loading state, synced lyrics view, plain text fallback, or no-lyrics message.
22
+ */
23
+ export function LyricsPanel({ progressMs, height }) {
24
+ const { lyrics, isLoading, offset, scrollPosition } = useLyricsContext();
25
+ if (isLoading) {
26
+ return _jsx(Text, { dimColor: true, children: "Loading lyrics..." });
27
+ }
28
+ if (!lyrics) {
29
+ return _jsx(Text, { dimColor: true, children: "No lyrics available" });
30
+ }
31
+ if (lyrics.synced.length > 0) {
32
+ return _jsx(LyricsView, { lyrics: lyrics.synced, progressMs: progressMs, height: height, offsetMs: offset });
33
+ }
34
+ if (lyrics.plain) {
35
+ return _jsx(PlainLyricsView, { text: lyrics.plain, scrollPosition: scrollPosition, height: height });
36
+ }
37
+ return _jsx(Text, { dimColor: true, children: "No lyrics available" });
38
+ }
@@ -0,0 +1,14 @@
1
+ import type { LyricLine } from '../spotify/lyrics.js';
2
+ type LyricsViewProps = {
3
+ lyrics: LyricLine[];
4
+ progressMs: number;
5
+ height: number;
6
+ offsetMs: number;
7
+ };
8
+ /**
9
+ * Scrolling lyrics display synchronized with playback progress.
10
+ * Current line has a karaoke sweep animation (dark green → bright green).
11
+ * Past lines are gray (dimColor), upcoming lines are white.
12
+ */
13
+ export declare function LyricsView({ lyrics, progressMs, height, offsetMs }: LyricsViewProps): import("react/jsx-runtime").JSX.Element;
14
+ export {};
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { getCurrentLineIndex } from '../spotify/lyrics.js';
4
+ import { AnimatedLine } from './animated-line.js';
5
+ function LyricSlot({ line, isCurrent, isPast, lineProgress }) {
6
+ if (isCurrent) {
7
+ return _jsx(AnimatedLine, { text: line.text || '\u266A', progress: lineProgress });
8
+ }
9
+ if (isPast) {
10
+ return _jsx(Text, { dimColor: true, children: line.text || ' ' });
11
+ }
12
+ return _jsx(Text, { children: line.text || ' ' });
13
+ }
14
+ function computeLineProgress(lyrics, index, progressMs, offsetMs) {
15
+ const currentTime = lyrics[index].timeMs;
16
+ const nextLine = index + 1 < lyrics.length ? lyrics[index + 1] : undefined;
17
+ const lineDuration = nextLine ? nextLine.timeMs - currentTime : 5000;
18
+ return lineDuration > 0 ? (progressMs - currentTime + offsetMs) / lineDuration : 1;
19
+ }
20
+ /**
21
+ * Scrolling lyrics display synchronized with playback progress.
22
+ * Current line has a karaoke sweep animation (dark green → bright green).
23
+ * Past lines are gray (dimColor), upcoming lines are white.
24
+ */
25
+ export function LyricsView({ lyrics, progressMs, height, offsetMs }) {
26
+ const currentIndex = getCurrentLineIndex(lyrics, progressMs, offsetMs);
27
+ const halfHeight = Math.floor(height / 2);
28
+ const startIndex = currentIndex - halfHeight;
29
+ const slots = [];
30
+ for (let i = 0; i < height; i++) {
31
+ const idx = startIndex + i;
32
+ if (idx >= 0 && idx < lyrics.length) {
33
+ slots.push({
34
+ index: idx,
35
+ line: lyrics[idx],
36
+ });
37
+ }
38
+ else {
39
+ slots.push({
40
+ index: idx,
41
+ });
42
+ }
43
+ }
44
+ return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: slots.map((slot) => {
45
+ if (!slot.line) {
46
+ return _jsx(Text, { children: " " }, slot.index);
47
+ }
48
+ return (_jsx(LyricSlot, { line: slot.line, isCurrent: slot.index === currentIndex, isPast: slot.index < currentIndex, lineProgress: slot.index === currentIndex ? computeLineProgress(lyrics, slot.index, progressMs, offsetMs) : 0 }, slot.index));
49
+ }) }));
50
+ }
@@ -0,0 +1,10 @@
1
+ type PanelContentProps = {
2
+ progressMs: number;
3
+ height: number;
4
+ };
5
+ /**
6
+ * Renders the active panel content based on the current panel mode.
7
+ * Reads panel mode from context; child components read their own data from their contexts.
8
+ */
9
+ export declare function PanelContent({ progressMs, height }: PanelContentProps): import("react/jsx-runtime").JSX.Element | null;
10
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { usePanelMode } from '../contexts/panel-mode-context.js';
3
+ import { LyricsPanel } from './lyrics-panel.js';
4
+ import { QueueView } from './queue-view.js';
5
+ import { SearchPanel } from './search-panel.js';
6
+ /**
7
+ * Renders the active panel content based on the current panel mode.
8
+ * Reads panel mode from context; child components read their own data from their contexts.
9
+ */
10
+ export function PanelContent({ progressMs, height }) {
11
+ const { panelMode } = usePanelMode();
12
+ if (panelMode === 'lyrics') {
13
+ return _jsx(LyricsPanel, { progressMs: progressMs, height: height });
14
+ }
15
+ if (panelMode === 'queue') {
16
+ return _jsx(QueueView, { height: height });
17
+ }
18
+ if (panelMode === 'search') {
19
+ return _jsx(SearchPanel, { height: height });
20
+ }
21
+ return null;
22
+ }
@@ -0,0 +1,16 @@
1
+ import type { TrackInfo } from '../spotify/playback.js';
2
+ type PlaybackStatusProps = {
3
+ track: TrackInfo | null;
4
+ isLoading: boolean;
5
+ error: string | null;
6
+ art: string | null;
7
+ progressBarWidth: number;
8
+ };
9
+ /**
10
+ * Renders the main playback area: loading skeleton, error, no-playback message,
11
+ * or the current track info with optional album art.
12
+ * During initial load, renders a shimmer skeleton matching the final layout to
13
+ * avoid flicker when data arrives.
14
+ */
15
+ export declare function PlaybackStatus({ track, isLoading, error, art, progressBarWidth }: PlaybackStatusProps): import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { TrackInfoView } from './track-info.js';
4
+ import { TrackInfoSkeleton } from './track-info-skeleton.js';
5
+ /**
6
+ * Renders the main playback area: loading skeleton, error, no-playback message,
7
+ * or the current track info with optional album art.
8
+ * During initial load, renders a shimmer skeleton matching the final layout to
9
+ * avoid flicker when data arrives.
10
+ */
11
+ export function PlaybackStatus({ track, isLoading, error, art, progressBarWidth }) {
12
+ if (error) {
13
+ return _jsx(Text, { color: "red", children: error });
14
+ }
15
+ if (isLoading && !track) {
16
+ return _jsx(TrackInfoSkeleton, { progressBarWidth: progressBarWidth });
17
+ }
18
+ if (!track) {
19
+ return _jsx(Text, { dimColor: true, children: "No active playback. Start playing on any Spotify client." });
20
+ }
21
+ return (_jsxs(Box, { children: [art && (_jsx(Box, { marginRight: 2, flexShrink: 0, children: _jsx(Text, { children: art }) })), _jsx(TrackInfoView, { track: track, progressBarWidth: progressBarWidth })] }));
22
+ }
@@ -0,0 +1,9 @@
1
+ import type { SpotifyApi } from '@spotify/web-api-ts-sdk';
2
+ type PlayerProps = {
3
+ client: SpotifyApi;
4
+ };
5
+ /**
6
+ * Player component wrapped with context providers.
7
+ */
8
+ export declare function Player({ client }: PlayerProps): import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1 @@
1
+ {"version":3,"file":"player.d.ts","sourceRoot":"","sources":["../../src/components/player.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAiB1D,KAAK,WAAW,GAAG;IACjB,MAAM,EAAE,UAAU,CAAC;CACpB,CAAC;AAmLF;;GAEG;AACH,wBAAgB,MAAM,CAAC,EAAE,MAAM,EAAE,EAAE,WAAW,2CAM7C"}