initial scaffold

This commit is contained in:
Boof2015
2026-05-25 21:54:43 -04:00
parent 40c2302d97
commit 9761975af7
40 changed files with 10797 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
import TrackPlayer, { isPlaying } from 'react-native-track-player';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
/**
* Transport actions screens call. Thin wrappers over RNTP so the UI never
* imports the engine directly — at M3/M4 this is where a custom Media3 module
* would slot in behind the same function signatures.
*/
/**
* Set up the player and load the demo queue. Setup is deferred to here (a
* user-initiated play) rather than app launch: RNTP starts a foreground
* MediaSession service on setup, and Android only permits starting a foreground
* service while the app is in the foreground.
*/
async function ensurePlayerReady(): Promise<void> {
await setupPlayer();
const queue = await TrackPlayer.getQueue();
if (queue.length === 0) {
await TrackPlayer.add(SAMPLE_TRACKS.map(toRntpTrack));
}
}
/** M0 entry point: ensure the player is ready, then start playback. */
export async function playSample(): Promise<void> {
await ensurePlayerReady();
await TrackPlayer.play();
}
export const play = (): Promise<void> => TrackPlayer.play();
export const pause = (): Promise<void> => TrackPlayer.pause();
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
export async function togglePlay(): Promise<void> {
const { playing } = await isPlaying();
if (playing) {
await TrackPlayer.pause();
} else {
await ensurePlayerReady();
await TrackPlayer.play();
}
}
export async function skipToNext(): Promise<void> {
try {
await TrackPlayer.skipToNext();
} catch {
// no next track — ignore
}
}
export async function skipToPrevious(): Promise<void> {
try {
await TrackPlayer.skipToPrevious();
} catch {
// no previous track — ignore
}
}
+21
View File
@@ -0,0 +1,21 @@
import TrackPlayer, { Event } from 'react-native-track-player';
/**
* RNTP playback service — registered in `index.js`. Runs in a headless context
* and wires MediaSession / lock-screen / notification / Bluetooth remote
* controls to the player. Must not depend on React or the JS UI tree.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop());
TrackPlayer.addEventListener(Event.RemoteNext, () =>
TrackPlayer.skipToNext().catch(() => {}),
);
TrackPlayer.addEventListener(Event.RemotePrevious, () =>
TrackPlayer.skipToPrevious().catch(() => {}),
);
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
TrackPlayer.seekTo(position),
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Track as RntpTrack } from 'react-native-track-player';
import type { Track } from '@/types/audio';
/**
* M0 verification tracks. Streamed from a public royalty-free source so playback
* works on a fresh emulator before on-device file scanning (M1) exists. These
* also exercise the streaming/queue path we reuse for Subsonic/Jellyfin (M5).
*/
export const SAMPLE_TRACKS: Track[] = [
{
id: 'sample-1',
path: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
title: 'SoundHelix Song 1',
artist: 'T. Schürger',
album: 'Astra Test Tones',
duration: 372,
format: 'MP3',
sampleRate: 44100,
bitrate: 320000,
channels: 2,
sourceType: 'local',
},
{
id: 'sample-2',
path: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
title: 'SoundHelix Song 2',
artist: 'T. Schürger',
album: 'Astra Test Tones',
duration: 426,
format: 'MP3',
sampleRate: 44100,
bitrate: 320000,
channels: 2,
sourceType: 'local',
},
];
/** Map an Astra Track to an RNTP track, carrying audiophile metadata as custom fields. */
export function toRntpTrack(track: Track): RntpTrack {
return {
id: track.id,
url: track.path,
title: track.title,
artist: track.artist,
album: track.album,
artwork: track.artworkData,
duration: track.duration,
// Custom fields preserved by RNTP and read back in `rntpToTrack`.
format: track.format,
sampleRate: track.sampleRate,
bitDepth: track.bitDepth,
bitrate: track.bitrate,
};
}
/** Reconstruct an Astra Track from the active RNTP track (for the player store). */
export function rntpToTrack(rt: RntpTrack): Track {
return {
id: String(rt.id ?? rt.url),
path: String(rt.url),
title: rt.title ?? 'Unknown title',
artist: rt.artist ?? 'Unknown artist',
album: rt.album ?? '',
duration: typeof rt.duration === 'number' ? rt.duration : 0,
artworkData: typeof rt.artwork === 'string' ? rt.artwork : undefined,
format: (rt.format as string) ?? 'PCM',
sampleRate: rt.sampleRate as number | undefined,
bitDepth: rt.bitDepth as number | undefined,
bitrate: rt.bitrate as number | undefined,
};
}
+50
View File
@@ -0,0 +1,50 @@
import TrackPlayer, {
AppKilledPlaybackBehavior,
Capability,
} from 'react-native-track-player';
/**
* Idempotent RNTP setup. RNTP runs on Media3/ExoPlayer under the hood, which
* gives us MediaSession (lock screen / notification / Bluetooth / Android Auto)
* and background playback. The custom Media3 AudioProcessor chain (EQ + PCM
* scope tap) lands at M3/M4 behind this same module.
*/
let setupPromise: Promise<void> | null = null;
export function setupPlayer(): Promise<void> {
if (!setupPromise) {
setupPromise = doSetup().catch((err) => {
setupPromise = null; // allow a retry on a genuine failure
throw err;
});
}
return setupPromise;
}
async function doSetup(): Promise<void> {
try {
await TrackPlayer.setupPlayer({ autoHandleInterruptions: true });
} catch (err) {
// setupPlayer rejects if the player was already initialized (e.g. across a
// Fast Refresh). That case is safe to ignore; anything else should surface.
const message = err instanceof Error ? err.message : String(err);
if (!/already.*initialized/i.test(message)) throw err;
}
await TrackPlayer.updateOptions({
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
capabilities: [
Capability.Play,
Capability.Pause,
Capability.Stop,
Capability.SeekTo,
Capability.SkipToNext,
Capability.SkipToPrevious,
],
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
progressUpdateEventInterval: 1,
});
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import {
State,
useActiveTrack,
usePlaybackState,
useProgress,
} from 'react-native-track-player';
import { usePlayerStore } from '@/stores/playerStore';
import type { PlaybackState } from '@/types/audio';
import { rntpToTrack } from './sampleTracks';
function mapState(state?: State): PlaybackState {
switch (state) {
case State.Playing:
return 'playing';
case State.Buffering:
case State.Loading:
return 'loading';
case State.Paused:
case State.Ready:
return 'paused';
default:
return 'stopped'; // None, Stopped, Ended, Error
}
}
/**
* Mirrors RNTP's playback state into `playerStore` so the whole UI reads from
* one Zustand source (matching the desktop pattern). Mount once, near the root.
*/
export function usePlaybackSync(): void {
const activeTrack = useActiveTrack();
const progress = useProgress(500);
const playbackState = usePlaybackState();
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
const setProgress = usePlayerStore((s) => s.setProgress);
const setPlaybackState = usePlayerStore((s) => s.setPlaybackState);
useEffect(() => {
setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
}, [activeTrack, setCurrentTrack]);
useEffect(() => {
setProgress(progress.position, progress.duration);
}, [progress.position, progress.duration, setProgress]);
useEffect(() => {
setPlaybackState(mapState(playbackState.state));
}, [playbackState.state, setPlaybackState]);
}