mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
initial scaffold
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user