Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit b551ec4

Browse files
authored
Merge pull request #24 from NuclearPlayer/feat/streaming
Streaming
2 parents ba95cc4 + 6d5e490 commit b551ec4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1890
-495
lines changed

packages/docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* [Plugin system](plugins/plugin-system.md)
77
* [Settings](plugins/settings.md)
88
* [Queue](plugins/queue.md)
9+
* [Streaming](plugins/streaming.md)
910
* [Providers](plugins/providers.md)
1011

1112
## Theming

packages/docs/plugins/queue.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ type QueueItem = {
4242
status: 'idle' | 'loading' | 'success' | 'error';
4343
error?: string; // Error message if status is 'error'
4444

45-
// Stream resolution tracking
46-
activeStreamIndex: number; // Which stream from track.streams[] is active
47-
failedStreamIndices: number[]; // Streams that have been tried and failed
48-
4945
// Metadata
5046
addedAtIso: string; // ISO timestamp of when added
5147
};

packages/docs/plugins/streaming.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
description: Resolve audio streams for tracks in Nuclear's queue.
3+
---
4+
5+
# Streaming
6+
7+
## Streaming API for Plugins
8+
9+
The Streaming API resolves playable audio URLs for tracks. When a user plays a track, Nuclear searches for stream candidates (e.g., YouTube videos) and then resolves the actual audio URL just-in-time.
10+
11+
{% hint style="info" %}
12+
Access streaming via `NuclearAPI.Streaming.*` in your plugin's lifecycle hooks.
13+
{% endhint %}
14+
15+
---
16+
17+
## Core concepts
18+
19+
### Two-phase resolution
20+
21+
Stream resolution happens in two phases:
22+
23+
1. **Candidate discovery** — Search for potential sources (e.g., YouTube videos matching the track)
24+
2. **Stream resolution** — Extract the actual audio URL from the top candidate
25+
26+
### Stream candidates
27+
28+
A candidate represents a potential audio source before the URL is resolved. Key fields:
29+
30+
| Field | Type | Description |
31+
|-------|------|-------------|
32+
| `id` | `string` | Unique identifier for this candidate |
33+
| `title` | `string` | Display title (e.g., video title) |
34+
| `durationMs` | `number?` | Duration in milliseconds |
35+
| `thumbnail` | `string?` | Preview image URL |
36+
| `source` | `ProviderRef` | Which streaming provider found this |
37+
| `stream` | `Stream?` | Resolved audio URL (populated after resolution) |
38+
| `lastResolvedAtIso` | `string?` | When the stream was last resolved, so we know when to refresh it |
39+
| `failed` | `boolean` | True if resolution failed permanently |
40+
41+
{% hint style="info" %}
42+
See `StreamCandidate` in `@nuclearplayer/model` for the full type definition.
43+
{% endhint %}
44+
45+
### Streams
46+
47+
A resolved stream contains the actual playable URL. Key fields:
48+
49+
| Field | Type | Description |
50+
|-------|------|-------------|
51+
| `url` | `string` | The audio URL |
52+
| `protocol` | `'file' \| 'http' \| 'https' \| 'hls'` | Stream protocol |
53+
| `mimeType` | `string?` | e.g., `'audio/webm'` |
54+
| `bitrateKbps` | `number?` | Audio quality |
55+
| `codec` | `string?` | e.g., `'opus'`, `'aac'` |
56+
| `qualityLabel` | `string?` | e.g., `'320kbps'`, `'FLAC'` |
57+
58+
{% hint style="info" %}
59+
See `Stream` in `@nuclearplayer/model` for the full type definition.
60+
{% endhint %}
61+
62+
### Stream expiry
63+
64+
Audio URLs from services like YouTube typically expire after a period (often as short as a few hours). Nuclear automatically re-resolves expired streams when needed. The expiry window is configurable via the `playback.streamExpiryMs` setting.
65+
66+
---
67+
68+
## Usage
69+
70+
### Finding candidates
71+
72+
Call `resolveCandidatesForTrack(track)` with a `Track` object containing at least a title and artist. The method returns a result object with `success`, `candidates` (on success), or `error` (on failure).
73+
74+
### Resolving a stream
75+
76+
Once we have a candidate, Nuclear calls `resolveStreamForCandidate(candidate)` to get the actual audio URL. The method returns an updated candidate with the `stream` field populated, or the same candidate if already resolved/failed. This happens when we try to play the track.
77+
78+
---
79+
80+
## Reference
81+
82+
| Method | Description |
83+
|--------|-------------|
84+
| `resolveCandidatesForTrack(track)` | Search for stream candidates matching a track. Returns `StreamResolutionResult`. |
85+
| `resolveStreamForCandidate(candidate)` | Resolve the audio URL for a candidate. Returns updated `StreamCandidate` or `undefined`. The candidate is not mutated, a fresh copy is returned. |
86+
87+
### Resolution behavior
88+
89+
| Input state | Output |
90+
|-------------|--------|
91+
| Candidate with `failed: true` | Returns candidate unchanged (no retry) |
92+
| Candidate with valid, non-expired `stream` | Returns candidate unchanged (cached) |
93+
| Candidate with expired or missing `stream` | Attempts resolution, returns updated candidate |
94+
| Resolution succeeds | Returns candidate with `stream` populated |
95+
| Resolution fails after retries | Returns candidate with `failed: true` |
96+
| No streaming provider registered | Returns `undefined` |
97+
98+
---
99+
100+
## Related settings
101+
102+
These core settings affect stream resolution:
103+
104+
| Setting | Description | Default |
105+
|---------|-------------|---------|
106+
| `playback.streamExpiryMs` | How long before a resolved stream is considered expired | 1 hour |
107+
| `playback.streamResolutionRetries` | How many times to retry before marking as failed | 3 |

packages/hifi/src/Sound.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
cloneElement,
44
isValidElement,
55
ReactNode,
6+
ScriptHTMLAttributes,
67
useCallback,
78
useEffect,
89
useMemo,
@@ -21,8 +22,8 @@ export type SoundProps = {
2122
status: SoundStatus;
2223
seek?: number;
2324
crossfadeMs?: number;
24-
preload?: 'none' | 'metadata' | 'auto';
25-
crossOrigin?: '' | 'anonymous' | 'use-credentials';
25+
preload?: HTMLAudioElement['preload'];
26+
crossOrigin?: ScriptHTMLAttributes<HTMLAudioElement>['crossOrigin'];
2627
onTimeUpdate?: (args: { position: number; duration: number }) => void;
2728
onEnd?: () => void;
2829
onLoadStart?: () => void;

packages/i18n/src/i18n.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ i18n.use(initReactI18next).init({
3333
'settings',
3434
'dashboard',
3535
'plugins',
36+
'streaming',
3637
],
3738
interpolation: {
3839
escapeValue: false,

packages/i18n/src/locales/en_US.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@
123123
"crossfadeMs": {
124124
"title": "Crossfade",
125125
"description": "Crossfade duration between tracks in milliseconds"
126+
},
127+
"streamExpiryMs": {
128+
"title": "Stream expiry time",
129+
"description": "How long before a resolved stream URL is considered expired and needs re-resolution"
130+
},
131+
"streamResolutionRetries": {
132+
"title": "Stream resolution retries",
133+
"description": "Number of times to retry resolving a stream URL before moving to the next candidate"
126134
}
127135
}
128136
},
@@ -138,5 +146,11 @@
138146
"error": {
139147
"prefix": "Error:"
140148
}
149+
},
150+
"streaming": {
151+
"errors": {
152+
"noCandidatesFound": "Failed to find stream candidates",
153+
"allCandidatesFailed": "All stream candidates failed"
154+
}
141155
}
142156
}

packages/model/src/index.ts

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { LocalFileInfo, StreamCandidate } from './streaming';
2+
13
export type ProviderRef = {
24
provider: string;
35
id: string;
@@ -52,30 +54,6 @@ export type PlaylistRef = {
5254
source: ProviderRef;
5355
};
5456

55-
export type Stream = {
56-
url: string;
57-
protocol: 'file' | 'http' | 'https' | 'hls';
58-
mimeType?: string;
59-
bitrateKbps?: number;
60-
codec?: string;
61-
container?: string;
62-
qualityLabel?: string;
63-
durationMs?: number;
64-
contentLengthBytes?: number;
65-
source: ProviderRef;
66-
};
67-
68-
export type LocalFileInfo = {
69-
fileUri: string;
70-
fileSize?: number;
71-
format?: string;
72-
bitrateKbps?: number;
73-
sampleRateHz?: number;
74-
channels?: number;
75-
fingerprint?: string;
76-
scannedAtIso?: string;
77-
};
78-
7957
export type Track = {
8058
title: string;
8159
artists: ArtistCredit[];
@@ -87,7 +65,7 @@ export type Track = {
8765
tags?: string[];
8866
source: ProviderRef;
8967
localFile?: LocalFileInfo;
90-
streams?: Stream[];
68+
streamCandidates?: StreamCandidate[];
9169
};
9270

9371
export type Album = {
@@ -135,3 +113,4 @@ export type PlaylistItem = {
135113
export { pickArtwork } from './artwork';
136114
export type { QueueItem, RepeatMode, Queue } from './queue';
137115
export type { SearchCategory, SearchParams, SearchResults } from './search';
116+
export type { LocalFileInfo, Stream, StreamCandidate } from './streaming';

packages/model/src/queue.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ export type QueueItem = {
55
track: Track;
66
status: 'idle' | 'loading' | 'success' | 'error';
77
error?: string;
8-
activeStreamIndex: number;
9-
failedStreamIndices: number[];
108
addedAtIso: string;
119
};
1210

packages/model/src/streaming.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ProviderRef } from './index';
2+
3+
export type Stream = {
4+
url: string;
5+
protocol: 'file' | 'http' | 'https' | 'hls';
6+
mimeType?: string;
7+
bitrateKbps?: number;
8+
codec?: string;
9+
container?: string;
10+
qualityLabel?: string;
11+
durationMs?: number;
12+
contentLengthBytes?: number;
13+
source: ProviderRef;
14+
};
15+
16+
export type LocalFileInfo = {
17+
fileUri: string;
18+
fileSize?: number;
19+
format?: string;
20+
bitrateKbps?: number;
21+
sampleRateHz?: number;
22+
channels?: number;
23+
fingerprint?: string;
24+
scannedAtIso?: string;
25+
};
26+
27+
export type StreamCandidate = {
28+
id: string;
29+
title: string;
30+
durationMs?: number;
31+
thumbnail?: string;
32+
stream?: Stream;
33+
lastResolvedAtIso?: string;
34+
failed: boolean;
35+
source: ProviderRef;
36+
};

packages/player/src/components/SoundProvider.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import { useEffect } from 'react';
33

44
import { Sound, Volume } from '@nuclearplayer/hifi';
55

6+
import { useStreamResolution } from '../hooks/useStreamResolution';
67
import { useSettingsStore } from '../stores/settingsStore';
78
import { useSoundStore } from '../stores/soundStore';
89

910
export const SoundProvider: FC<PropsWithChildren> = ({ children }) => {
11+
useStreamResolution();
1012
const { src, status, seek } = useSoundStore();
1113
const getValue = useSettingsStore((s) => s.getValue);
1214
const crossfadeMs = getValue('core.playback.crossfadeMs') as number;
13-
const preload: 'none' | 'metadata' | 'auto' = 'auto';
14-
const crossOrigin: '' | 'anonymous' | 'use-credentials' = '';
15+
const preload: HTMLAudioElement['preload'] = 'auto';
16+
const crossOrigin = '' as const;
1517
const volume01 = (getValue('core.playback.volume') as number) ?? 1;
1618
const muted = (getValue('core.playback.muted') as boolean) ?? false;
1719
const volumePercent = muted ? 0 : Math.round(volume01 * 100);

0 commit comments

Comments
 (0)