feat: add Expo mobile app (mobile/)

Full React Native app using react-native-track-player for native
audio — fixes background freeze, autoplay stopping, stuck play
button, and enables Android Auto in future. Includes:

- Navidrome API layer (ported from src/api.js)
- Zustand global store with SecureStore config persistence
- react-native-track-player with background playback service
- Bottom tab navigation: Home, Artists, Albums, Search, Settings
- Screens: Home, Library (Artists/Albums), ArtistDetail, AlbumDetail,
  Search, NowPlaying, Login/Settings
- Persistent PlayerBar with progress indicator
- Romaji/sort-tag display support carried over from web app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-09 23:19:06 +02:00
1 parent 59adee60d5
commit 1b96706a1c
31 files changed
+8640

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+12
View File
@@ -0,0 +1,12 @@
node_modules/
.expo/
dist/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
android/
ios/
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
+111
View File
@@ -0,0 +1,111 @@
import 'react-native-gesture-handler';
import React, { useEffect, useState } from 'react';
import { View, StatusBar } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createStackNavigator } from '@react-navigation/stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Ionicons } from '@expo/vector-icons';
import TrackPlayer, { Event, State } from 'react-native-track-player';
import { PlaybackService } from './src/audio/service';
import { setupPlayer } from './src/audio/player';
import { useStore } from './src/store';
import HomeScreen from './src/screens/Home';
import { ArtistsScreen, AlbumsScreen } from './src/screens/Library';
import ArtistDetailScreen from './src/screens/ArtistDetail';
import AlbumDetailScreen from './src/screens/AlbumDetail';
import SearchScreen from './src/screens/Search';
import LoginScreen from './src/screens/Login';
import NowPlayingScreen from './src/screens/NowPlaying';
import PlayerBar from './src/components/PlayerBar';
TrackPlayer.registerPlaybackService(() => PlaybackService);
const Tab = createBottomTabNavigator();
const Stack = createStackNavigator();
function HomeTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarStyle: { backgroundColor: '#1e1e1e', borderTopColor: '#2a2a2a' },
tabBarActiveTintColor: '#1db954',
tabBarInactiveTintColor: '#888',
tabBarIcon: ({ color, size }) => {
const icons = { Home: 'home', Artists: 'person', Albums: 'disc', Search: 'search', Settings: 'settings-outline' };
return <Ionicons name={icons[route.name] || 'ellipse'} size={size} color={color} />;
},
})}
>
<Tab.Screen name="Home" component={HomeScreen} options={{ title: 'Start' }} />
<Tab.Screen name="Artists" component={ArtistsScreen} options={{ title: 'Künstler' }} />
<Tab.Screen name="Albums" component={AlbumsScreen} options={{ title: 'Alben' }} />
<Tab.Screen name="Search" component={SearchScreen} options={{ title: 'Suche' }} />
<Tab.Screen name="Settings" component={LoginScreen} options={{ title: 'Einstellungen' }} />
</Tab.Navigator>
);
}
function RootNavigator() {
const currentTrack = useStore(s => s.currentTrack);
return (
<View style={{ flex: 1 }}>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Tabs" component={HomeTabs} />
<Stack.Screen name="NowPlaying" component={NowPlayingScreen} options={{ presentation: 'modal' }} />
<Stack.Screen name="AlbumDetail" component={AlbumDetailScreen} />
<Stack.Screen name="ArtistDetail" component={ArtistDetailScreen} />
</Stack.Navigator>
{currentTrack && <PlayerBar />}
</View>
);
}
export default function App() {
const { loadConfig } = useStore();
const [ready, setReady] = useState(false);
useEffect(() => {
(async () => {
await loadConfig();
await setupPlayer();
setReady(true);
})();
}, []);
useEffect(() => {
const sub1 = TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, e => {
if (e.track) useStore.getState().setCurrentTrack(e.track._song || e.track);
});
const sub2 = TrackPlayer.addEventListener(Event.PlaybackState, e => {
useStore.getState().setIsPlaying(e.state === State.Playing);
});
return () => { sub1.remove(); sub2.remove(); };
}, []);
if (!ready) return null;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar barStyle="light-content" backgroundColor="#121212" />
<NavigationContainer theme={{
dark: true,
colors: {
primary: '#1db954',
background: '#121212',
card: '#1e1e1e',
text: '#ffffff',
border: '#2a2a2a',
notification: '#1db954',
},
}}>
<RootNavigator />
</NavigationContainer>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
{
"expo": {
"name": "NovaSonic",
"slug": "NovaSonic_Mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"ios": {
"supportsTablet": true,
"infoPlist": {
"UIBackgroundModes": ["audio"]
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#121212",
"foregroundImage": "./assets/adaptive-icon.png"
},
"package": "com.honeyy.novasonic",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
},
"plugins": [
[
"react-native-track-player",
{
"androidPackage": "com.guichaguri.trackplayer"
}
]
],
"web": {
"favicon": "./assets/favicon.png"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+6740
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "novasonic_mobile",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-community/slider": "^5.2.0",
"@react-navigation/bottom-tabs": "^7.17.2",
"@react-navigation/native": "^7.3.0",
"@react-navigation/stack": "^7.10.2",
"expo": "~56.0.9",
"expo-file-system": "^56.0.7",
"expo-image": "^56.0.10",
"expo-secure-store": "^56.0.4",
"expo-status-bar": "~56.0.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "^3.0.0",
"react-native-reanimated": "^4.4.1",
"react-native-safe-area-context": "^5.8.0",
"react-native-screens": "^4.25.2",
"react-native-track-player": "^4.1.2",
"zustand": "^5.0.14"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}
+217
View File
@@ -0,0 +1,217 @@
import { useStore } from '../store';
import { md5 } from './utils';
// ─── SUBSONIC AUTH ───
function getCfg() { return useStore.getState().cfg; }
function apiUrl(method, extra = '') {
const { url, user, pass } = getCfg();
const salt = Math.random().toString(36).slice(2, 10);
const token = md5(pass + salt);
return `${url}/rest/${method}?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
}
export async function api(method, extra = '') {
const res = await fetch(apiUrl(method, extra));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const r = data['subsonic-response'];
if (r.status !== 'ok') throw new Error(r.error?.message || 'API Error');
return r;
}
// ─── API CACHE ───
const _apiCache = new Map();
const _CACHE_TTL = 5 * 60 * 1000;
export function apiIsCached(method, extra = '') {
const hit = _apiCache.get(method + extra);
return !!(hit && Date.now() - hit.ts < _CACHE_TTL);
}
export async function apiCached(method, extra = '', ttl = _CACHE_TTL) {
const key = method + extra;
const hit = _apiCache.get(key);
if (hit && Date.now() - hit.ts < ttl) return hit.data;
const data = await api(method, extra);
_apiCache.set(key, { data, ts: Date.now() });
return data;
}
export function invalidateApiCache(method, extra) {
if (method) _apiCache.delete(method + (extra || ''));
else _apiCache.clear();
}
// ─── STABLE COVER URL (session-cached salt so image cache works) ───
const _coverUrlCache = new Map();
let _coverSalt = null, _coverToken = null;
function _coverAuth() {
const { user, pass } = getCfg();
if (!_coverSalt && user && pass) {
_coverSalt = md5(user + pass).slice(0, 8);
_coverToken = md5(pass + _coverSalt);
}
return _coverSalt
? `u=${encodeURIComponent(user)}&t=${_coverToken}&s=${_coverSalt}`
: null;
}
export function coverUrl(id, size = 300) {
if (!id) return null;
const { url } = getCfg();
const key = `${id}:${size}`;
if (!_coverUrlCache.has(key)) {
const auth = _coverAuth();
const u = auth
? `${url}/rest/getCoverArt?${auth}&v=1.16.1&c=NovaSonic&f=json&id=${id}&size=${size}`
: apiUrl('getCoverArt', `&id=${id}&size=${size}`);
_coverUrlCache.set(key, u);
}
return _coverUrlCache.get(key);
}
export function invalidateCoverCache() {
_coverUrlCache.clear(); _coverSalt = null; _coverToken = null;
}
export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); }
export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); }
// ─── ROMAJI / SORT ───
let _romajiMap = null;
let _artistRomajiMap = null;
let _artistSortMap = null;
let _albumRomajiMap = null;
let _romajiIndex = null;
let _romajiIndexFetch = null;
let _artistSortFetch = null;
const _isJp = (s) => /[ぁ-んァ-ン一-龯]/.test(s);
function _buildRomajiMap(idx) {
const m = new Map(), am = new Map(), bm = new Map();
if (Array.isArray(idx)) idx.forEach(e => {
m.set(String(e.id), e);
if (e.artistId && e.artistRomaji) am.set(String(e.artistId), e.artistRomaji);
if (e.album && e.albumRomaji && !bm.has(e.album)) bm.set(e.album, e.albumRomaji);
});
_artistRomajiMap = am;
_albumRomajiMap = bm;
return m;
}
export async function fetchRomajiIndex() {
const { backendUrl, url, user, pass } = getCfg();
if (!backendUrl || !url || !user || !pass) return null;
if (_romajiIndex) return _romajiIndex;
if (_romajiIndexFetch) return _romajiIndexFetch;
_romajiIndexFetch = fetch(
`${backendUrl}/api/romaji-index?ndUrl=${encodeURIComponent(url)}&user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`
).then(r => r.ok ? r.json() : null)
.then(idx => { _romajiIndex = idx; _romajiMap = _buildRomajiMap(idx); _romajiIndexFetch = null; return idx; })
.catch(() => { _romajiIndexFetch = null; return null; });
return _romajiIndexFetch;
}
export function invalidateRomajiIndex() {
_romajiIndex = null; _romajiMap = null; _artistRomajiMap = null;
_artistSortMap = null; _albumRomajiMap = null; _artistSortFetch = null;
}
export async function ensureArtistSortMap() {
if (_artistSortMap) return;
if (_artistSortFetch) return _artistSortFetch;
_artistSortFetch = apiCached('getArtists')
.then(r => {
const all = (r?.artists?.index || []).flatMap(i => i.artist || []);
cacheArtistSortNames(all);
_artistSortFetch = null;
})
.catch(() => { _artistSortFetch = null; });
return _artistSortFetch;
}
export function cacheArtistSortNames(artists) {
if (!_artistSortMap) _artistSortMap = new Map();
for (const a of artists) {
if (a.id && a.sortName && !_isJp(a.sortName))
_artistSortMap.set(String(a.id), a.sortName);
}
}
export function romajiLookup(id) { return _romajiMap?.get(String(id)) ?? null; }
// ─── DISPLAY HELPERS ───
export function dispTitle(s) {
const { showRomaji } = getCfg();
if (!showRomaji) return s.title || '';
const st = s.sortName || s.titleSort;
if (st && !_isJp(st)) return st;
return romajiLookup(s.id)?.titleRomaji || s.title || '';
}
export function dispArtist(s) {
const { showRomaji } = getCfg();
if (!showRomaji) return s.artist || '';
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
}
export function dispAlbum(s) {
return s.album || '';
}
export function dispArtistById(id, name) {
const { showRomaji } = getCfg();
if (!showRomaji) return name || '';
const sort = _artistSortMap?.get(String(id));
if (sort) return sort;
return _artistRomajiMap?.get(String(id)) || name || '';
}
export function dispAlbumName(album) {
const name = album?.name || '';
const { showRomaji } = getCfg();
if (!showRomaji) return name;
const sort = album?.sortName;
if (sort && !_isJp(sort)) return sort;
return name;
}
// ─── STAR / UNSTAR ───
export async function star(id) { await api('star', `&id=${id}`); }
export async function unstar(id) { await api('unstar', `&id=${id}`); }
export async function scrobble(id) {
try { await api('scrobble', `&id=${id}&submission=true`); } catch {}
}
// ─── ARTIST IMAGE (Last.fm via Navidrome) ───
export async function getArtistImage(artistId) {
const cache = useStore.getState().artistImgCache;
if (cache[artistId] !== undefined) return cache[artistId];
try {
const r = await api('getArtistInfo2', `&id=${artistId}&count=0`);
const info = r.artistInfo2 || {};
const img = info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null;
const isBlank = img && (
img.includes('2a96cbd8b46e442fc41c2b86b821562f') || img.endsWith('/noimage/')
);
const result = isBlank ? null : img;
useStore.getState().setArtistImg(artistId, result);
return result;
} catch {
useStore.getState().setArtistImg(artistId, null);
return null;
}
}
// ─── SEARCH ───
export async function searchAll(query) {
const r = await api('search3', `&query=${encodeURIComponent(query)}&artistCount=5&albumCount=5&songCount=20`);
return {
artists: r.searchResult3?.artist || [],
albums: r.searchResult3?.album || [],
songs: r.searchResult3?.song || [],
};
}
+42
View File
@@ -0,0 +1,42 @@
export function md5(input) {
function safeAdd(x,y){var l=(x&0xffff)+(y&0xffff);return((x>>16)+(y>>16)+(l>>16)<<16)|(l&0xffff)}
function rol(n,c){return(n<<c)|(n>>>32-c)}
function cmn(q,a,b,x,s,t){return safeAdd(rol(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b)}
function ff(a,b,c,d,x,s,t){return cmn((b&c)|(~b&d),a,b,x,s,t)}
function gg(a,b,c,d,x,s,t){return cmn((b&d)|(c&~d),a,b,x,s,t)}
function hh(a,b,c,d,x,s,t){return cmn(b^c^d,a,b,x,s,t)}
function ii(a,b,c,d,x,s,t){return cmn(c^(b|~d),a,b,x,s,t)}
var l=input.length,m=[];
for(var i=0;i<l;i++)m[i>>2]|=(input.charCodeAt(i)&0xff)<<((i%4)*8);
m[l>>2]|=0x80<<((l%4)*8);
m[((l+8>>6)+1)*16-2]=l*8;
var a=1732584193,b=-271733879,c=-1732584194,d=271733878;
for(var i=0;i<m.length;i+=16){
var A=a,B=b,C=c,D=d;
a=ff(a,b,c,d,m[i+0],7,-680876936); d=ff(d,a,b,c,m[i+1],12,-389564586); c=ff(c,d,a,b,m[i+2],17,606105819); b=ff(b,c,d,a,m[i+3],22,-1044525330);
a=ff(a,b,c,d,m[i+4],7,-176418897); d=ff(d,a,b,c,m[i+5],12,1200080426); c=ff(c,d,a,b,m[i+6],17,-1473231341); b=ff(b,c,d,a,m[i+7],22,-45705983);
a=ff(a,b,c,d,m[i+8],7,1770035416); d=ff(d,a,b,c,m[i+9],12,-1958414417); c=ff(c,d,a,b,m[i+10],17,-42063); b=ff(b,c,d,a,m[i+11],22,-1990404162);
a=ff(a,b,c,d,m[i+12],7,1804603682); d=ff(d,a,b,c,m[i+13],12,-40341101); c=ff(c,d,a,b,m[i+14],17,-1502002290); b=ff(b,c,d,a,m[i+15],22,1236535329);
a=gg(a,b,c,d,m[i+1],5,-165796510); d=gg(d,a,b,c,m[i+6],9,-1069501632); c=gg(c,d,a,b,m[i+11],14,643717713); b=gg(b,c,d,a,m[i+0],20,-373897302);
a=gg(a,b,c,d,m[i+5],5,-701558691); d=gg(d,a,b,c,m[i+10],9,38016083); c=gg(c,d,a,b,m[i+15],14,-660478335); b=gg(b,c,d,a,m[i+4],20,-405537848);
a=gg(a,b,c,d,m[i+9],5,568446438); d=gg(d,a,b,c,m[i+14],9,-1019803690); c=gg(c,d,a,b,m[i+3],14,-187363961); b=gg(b,c,d,a,m[i+8],20,1163531501);
a=gg(a,b,c,d,m[i+13],5,-1444681467); d=gg(d,a,b,c,m[i+2],9,-51403784); c=gg(c,d,a,b,m[i+7],14,1735328473); b=gg(b,c,d,a,m[i+12],20,-1926607734);
a=hh(a,b,c,d,m[i+5],4,-378558); d=hh(d,a,b,c,m[i+8],11,-2022574463); c=hh(c,d,a,b,m[i+11],16,1839030562); b=hh(b,c,d,a,m[i+14],23,-35309556);
a=hh(a,b,c,d,m[i+1],4,-1530992060); d=hh(d,a,b,c,m[i+4],11,1272893353); c=hh(c,d,a,b,m[i+7],16,-155497632); b=hh(b,c,d,a,m[i+10],23,-1094730640);
a=hh(a,b,c,d,m[i+13],4,681279174); d=hh(d,a,b,c,m[i+0],11,-358537222); c=hh(c,d,a,b,m[i+3],16,-722521979); b=hh(b,c,d,a,m[i+6],23,76029189);
a=hh(a,b,c,d,m[i+9],4,-640364487); d=hh(d,a,b,c,m[i+12],11,-421815835); c=hh(c,d,a,b,m[i+15],16,530742520); b=hh(b,c,d,a,m[i+2],23,-995338651);
a=ii(a,b,c,d,m[i+0],6,-198630844); d=ii(d,a,b,c,m[i+7],10,1126891415); c=ii(c,d,a,b,m[i+14],15,-1416354905); b=ii(b,c,d,a,m[i+5],21,-57434055);
a=ii(a,b,c,d,m[i+12],6,1700485571); d=ii(d,a,b,c,m[i+3],10,-1894986606); c=ii(c,d,a,b,m[i+10],15,-1051523); b=ii(b,c,d,a,m[i+1],21,-2054922799);
a=ii(a,b,c,d,m[i+8],6,1873313359); d=ii(d,a,b,c,m[i+15],10,-30611744); c=ii(c,d,a,b,m[i+6],15,-1560198380); b=ii(b,c,d,a,m[i+13],21,1309151649);
a=ii(a,b,c,d,m[i+4],6,-145523070); d=ii(d,a,b,c,m[i+11],10,-1120210379);c=ii(c,d,a,b,m[i+2],15,718787259); b=ii(b,c,d,a,m[i+9],21,-343485551);
a=safeAdd(a,A);b=safeAdd(b,B);c=safeAdd(c,C);d=safeAdd(d,D);
}
function hex(n){var s='';for(var j=0;j<4;j++)s+=('0'+((n>>>(j*8))&0xff).toString(16)).slice(-2);return s}
return hex(a)+hex(b)+hex(c)+hex(d);
}
export function formatDur(s) {
if (!s) return '0:00';
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
+81
View File
@@ -0,0 +1,81 @@
import TrackPlayer, { Capability, RepeatMode, State } from 'react-native-track-player';
import { coverUrl, streamUrl, scrobble, dispTitle, dispArtistById } from '../api';
import { useStore } from '../store';
let _setupDone = false;
export async function setupPlayer() {
if (_setupDone) return;
await TrackPlayer.setupPlayer({ autoHandleInterruptions: true });
await TrackPlayer.updateOptions({
capabilities: [
Capability.Play,
Capability.Pause,
Capability.SkipToNext,
Capability.SkipToPrevious,
Capability.SeekTo,
Capability.Stop,
],
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious],
notificationCapabilities: [
Capability.Play,
Capability.Pause,
Capability.SkipToNext,
Capability.SkipToPrevious,
],
});
_setupDone = true;
}
// Convert a Navidrome song object to a react-native-track-player track object
function songToTrack(song) {
return {
id: song.id,
url: streamUrl(song.id),
title: dispTitle(song),
artist: dispArtistById(song.artistId, song.artist || ''),
album: song.album || '',
artwork: coverUrl(song.coverArt, 512) || undefined,
duration: song.duration,
// Keep the original song data for the UI
_song: song,
};
}
export async function playQueue(songs, startIndex = 0) {
if (!songs?.length) return;
const tracks = songs.map(songToTrack);
await TrackPlayer.reset();
await TrackPlayer.add(tracks);
await TrackPlayer.skip(startIndex);
await TrackPlayer.play();
const { setQueue, setCurrentTrack } = useStore.getState();
setQueue(songs, startIndex);
setCurrentTrack(songs[startIndex]);
}
export async function skipToIndex(index) {
await TrackPlayer.skip(index);
await TrackPlayer.play();
const queue = useStore.getState().queue;
if (queue[index]) useStore.getState().setCurrentTrack(queue[index]);
}
export async function togglePlayPause() {
const state = await TrackPlayer.getState();
if (state === State.Playing) await TrackPlayer.pause();
else await TrackPlayer.play();
}
export async function skipNext() { await TrackPlayer.skipToNext(); }
export async function skipPrev() { await TrackPlayer.skipToPrevious(); }
export async function seekTo(position) { await TrackPlayer.seekTo(position); }
export async function setRepeatMode(mode) {
// mode: 'off' | 'all' | 'one'
const rm = mode === 'one' ? RepeatMode.Track : mode === 'all' ? RepeatMode.Queue : RepeatMode.Off;
await TrackPlayer.setRepeatMode(rm);
useStore.getState().setRepeat(mode);
}
+16
View File
@@ -0,0 +1,16 @@
import TrackPlayer, { Event } from 'react-native-track-player';
// This file runs in a separate background thread managed by react-native-track-player.
// It handles remote control events (lock screen, headphone buttons, Android Auto).
export async function PlaybackService() {
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop());
TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext());
TrackPlayer.addEventListener(Event.RemotePrevious, () => TrackPlayer.skipToPrevious());
TrackPlayer.addEventListener(Event.RemoteSeek, e => TrackPlayer.seekTo(e.position));
TrackPlayer.addEventListener(Event.RemoteDuck, async e => {
if (e.paused) await TrackPlayer.pause();
else await TrackPlayer.play();
});
}
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
import { coverUrl, dispAlbumName, dispArtistById } from '../api';
export default function AlbumCard({ album, onPress, size = 150 }) {
const art = coverUrl(album.coverArt, 512);
return (
<TouchableOpacity style={[styles.card, { width: size }]} onPress={onPress} activeOpacity={0.7}>
{art ? (
<Image source={{ uri: art }} style={[styles.art, { width: size, height: size }]} />
) : (
<View style={[styles.art, styles.artFallback, { width: size, height: size }]}>
<Text style={styles.fallbackIcon}>💿</Text>
</View>
)}
<Text style={styles.title} numberOfLines={2}>{dispAlbumName(album)}</Text>
<Text style={styles.sub} numberOfLines={1}>
{dispArtistById(album.artistId, album.artist || '')}
</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
marginRight: 12,
},
art: {
borderRadius: 6,
backgroundColor: '#2a2a2a',
},
artFallback: {
alignItems: 'center',
justifyContent: 'center',
},
fallbackIcon: {
fontSize: 40,
},
title: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
marginTop: 6,
},
sub: {
color: '#aaa',
fontSize: 12,
marginTop: 2,
},
});
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { usePlaybackState, useProgress, State } from 'react-native-track-player';
import { Ionicons } from '@expo/vector-icons';
import { useStore } from '../store';
import { togglePlayPause, skipNext } from '../audio/player';
export default function PlayerBar() {
const navigation = useNavigation();
const { currentTrack, isPlaying } = useStore();
const playbackState = usePlaybackState();
const { position, duration } = useProgress(500);
if (!currentTrack) return null;
const playing = playbackState.state === State.Playing;
const progress = duration > 0 ? position / duration : 0;
return (
<TouchableOpacity
style={styles.container}
activeOpacity={0.9}
onPress={() => navigation.navigate('NowPlaying')}
>
{/* Progress bar at top */}
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
<View style={styles.row}>
{currentTrack.coverArt ? (
<Image
source={{ uri: currentTrack.artwork }}
style={styles.art}
/>
) : (
<View style={[styles.art, styles.artFallback]}>
<Ionicons name="musical-note" size={18} color="#888" />
</View>
)}
<View style={styles.info}>
<Text style={styles.title} numberOfLines={1}>{currentTrack.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{currentTrack.artist}</Text>
</View>
<TouchableOpacity style={styles.btn} onPress={togglePlayPause}>
<Ionicons
name={playing ? 'pause' : 'play'}
size={24}
color="#fff"
/>
</TouchableOpacity>
<TouchableOpacity style={styles.btn} onPress={skipNext}>
<Ionicons name="play-skip-forward" size={22} color="#fff" />
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#1e1e1e',
borderTopWidth: 1,
borderTopColor: '#2a2a2a',
},
progressTrack: {
height: 2,
backgroundColor: '#333',
},
progressFill: {
height: 2,
backgroundColor: '#1db954',
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 10,
},
art: {
width: 44,
height: 44,
borderRadius: 4,
},
artFallback: {
backgroundColor: '#2a2a2a',
alignItems: 'center',
justifyContent: 'center',
},
info: {
flex: 1,
},
title: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
artist: {
color: '#aaa',
fontSize: 12,
marginTop: 2,
},
btn: {
padding: 6,
},
});
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
import { coverUrl, dispTitle, dispArtistById } from '../api';
import { formatDur } from '../api/utils';
import { useStore } from '../store';
export default function SongItem({ song, index, onPress, showArt = false }) {
const currentTrack = useStore(s => s.currentTrack);
const isPlaying = currentTrack?.id === song.id;
return (
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
{showArt ? (
<Image
source={{ uri: coverUrl(song.coverArt, 128) }}
style={styles.art}
defaultSource={require('../../assets/icon.png')}
/>
) : (
<Text style={[styles.num, isPlaying && styles.numPlaying]}>
{isPlaying ? '▶' : index + 1}
</Text>
)}
<View style={styles.info}>
<Text style={[styles.title, isPlaying && styles.titlePlaying]} numberOfLines={1}>
{dispTitle(song)}
</Text>
<Text style={styles.artist} numberOfLines={1}>
{dispArtistById(song.artistId, song.artist || '')}
</Text>
</View>
<Text style={styles.dur}>{formatDur(song.duration)}</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
gap: 12,
},
num: {
width: 24,
textAlign: 'center',
color: '#aaa',
fontSize: 13,
},
numPlaying: {
color: '#1db954',
},
art: {
width: 44,
height: 44,
borderRadius: 4,
backgroundColor: '#2a2a2a',
},
info: {
flex: 1,
},
title: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
titlePlaying: {
color: '#1db954',
},
artist: {
color: '#aaa',
fontSize: 12,
marginTop: 2,
},
dur: {
color: '#666',
fontSize: 12,
},
});
+139
View File
@@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import {
View, Text, FlatList, Image, TouchableOpacity,
StyleSheet, ActivityIndicator,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { api, ensureArtistSortMap, dispAlbumName, dispArtistById, coverUrl } from '../api';
import { playQueue, skipToIndex } from '../audio/player';
import { useStore } from '../store';
import SongItem from '../components/SongItem';
export default function AlbumDetailScreen() {
const navigation = useNavigation();
const { album } = useRoute().params;
const { cfg } = useStore();
const [songs, setSongs] = useState([]);
const [ndAlbum, setNdAlbum] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
if (cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
try {
const r = await api('getAlbum', `&id=${album.id}`);
setNdAlbum(r.album);
setSongs(r.album?.song || []);
} catch {}
setLoading(false);
})();
}, [album.id]);
const displayAlbum = ndAlbum || album;
const art = coverUrl(displayAlbum.coverArt, 512);
const albumName = dispAlbumName(displayAlbum);
const artistName = dispArtistById(displayAlbum.artistId, displayAlbum.artist || '');
const totalMin = Math.round(songs.reduce((s, t) => s + (t.duration || 0), 0) / 60);
async function handlePlay(idx = 0) {
if (!songs.length) return;
await playQueue(songs, idx);
}
return (
<View style={styles.container}>
<FlatList
data={songs}
keyExtractor={s => s.id}
ListHeaderComponent={() => (
<View>
{/* Back button */}
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
<Ionicons name="chevron-back" size={24} color="#fff" />
</TouchableOpacity>
{/* Album art */}
<View style={styles.artWrap}>
{art ? (
<Image source={{ uri: art }} style={styles.art} />
) : (
<View style={[styles.art, styles.artFallback]}>
<Text style={{ fontSize: 64 }}>💿</Text>
</View>
)}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.albumName}>{albumName}</Text>
<TouchableOpacity onPress={() => navigation.navigate('ArtistDetail', {
artist: { id: displayAlbum.artistId, name: displayAlbum.artist || '' }
})}>
<Text style={styles.artistName}>{artistName}</Text>
</TouchableOpacity>
<Text style={styles.meta}>
{displayAlbum.year ? `${displayAlbum.year} · ` : ''}
{songs.length} Titel · {totalMin} Min.
</Text>
</View>
{/* Play buttons */}
<View style={styles.btns}>
<TouchableOpacity style={styles.playBtn} onPress={() => handlePlay(0)}>
<Ionicons name="play" size={18} color="#000" />
<Text style={styles.playBtnText}>Abspielen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.shuffleBtn} onPress={async () => {
const shuffled = [...songs].sort(() => Math.random() - 0.5);
await playQueue(shuffled, 0);
}}>
<Ionicons name="shuffle" size={18} color="#fff" />
</TouchableOpacity>
</View>
{loading && <ActivityIndicator color="#1db954" style={{ marginTop: 20 }} />}
</View>
)}
renderItem={({ item, index }) => (
<SongItem
song={item}
index={index}
onPress={() => handlePlay(index)}
/>
)}
contentContainerStyle={{ paddingBottom: 16 }}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212' },
backBtn: {
position: 'absolute', top: 44, left: 12, zIndex: 10,
backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 20, padding: 6,
},
artWrap: { alignItems: 'center', paddingTop: 60, paddingBottom: 16 },
art: {
width: 220, height: 220, borderRadius: 8,
shadowColor: '#000', shadowOpacity: 0.5, shadowRadius: 16,
},
artFallback: {
backgroundColor: '#2a2a2a', alignItems: 'center', justifyContent: 'center',
},
info: { paddingHorizontal: 20, alignItems: 'center' },
albumName: { color: '#fff', fontSize: 20, fontWeight: '800', textAlign: 'center', marginBottom: 6 },
artistName: { color: '#1db954', fontSize: 15, fontWeight: '600', marginBottom: 4 },
meta: { color: '#aaa', fontSize: 13, marginBottom: 16 },
btns: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, gap: 12, marginBottom: 8 },
playBtn: {
flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
gap: 8, backgroundColor: '#1db954', borderRadius: 24, paddingVertical: 12,
},
playBtnText: { color: '#000', fontWeight: '700', fontSize: 15 },
shuffleBtn: {
width: 48, height: 48, borderRadius: 24,
backgroundColor: '#2a2a2a', alignItems: 'center', justifyContent: 'center',
},
});
+97
View File
@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import {
View, Text, FlatList, Image, TouchableOpacity,
StyleSheet, ActivityIndicator, ScrollView,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { api, apiCached, dispArtistById, dispAlbumName, coverUrl, getArtistImage, cacheArtistSortNames } from '../api';
import AlbumCard from '../components/AlbumCard';
export default function ArtistDetailScreen() {
const navigation = useNavigation();
const { artist } = useRoute().params;
const [albums, setAlbums] = useState([]);
const [img, setImg] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const r = await apiCached('getArtist', `&id=${artist.id}`);
const ndArtist = r.artist;
if (ndArtist) cacheArtistSortNames([ndArtist]);
setAlbums(ndArtist?.album || []);
} catch {}
setLoading(false);
getArtistImage(artist.id).then(setImg).catch(() => {});
})();
}, [artist.id]);
const name = dispArtistById(artist.id, artist.name);
return (
<View style={styles.container}>
{/* Hero */}
<View style={styles.hero}>
{img ? (
<Image source={{ uri: img }} style={styles.heroImg} />
) : (
<View style={[styles.heroImg, styles.heroFallback]}>
<Text style={{ fontSize: 64 }}>🎤</Text>
</View>
)}
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
<Ionicons name="chevron-back" size={24} color="#fff" />
</TouchableOpacity>
<View style={styles.heroOverlay}>
<Text style={styles.heroName}>{name}</Text>
<Text style={styles.heroSub}>{albums.length} Alben</Text>
</View>
</View>
{loading ? (
<View style={styles.center}><ActivityIndicator color="#1db954" /></View>
) : (
<FlatList
data={albums}
keyExtractor={a => a.id}
numColumns={2}
renderItem={({ item }) => (
<View style={{ flex: 1, padding: 8 }}>
<AlbumCard
album={item}
size={(require('react-native').Dimensions.get('window').width / 2) - 20}
onPress={() => navigation.navigate('AlbumDetail', { album: item })}
/>
</View>
)}
contentContainerStyle={{ padding: 4 }}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212' },
hero: { width: '100%', height: 220, position: 'relative' },
heroImg: { width: '100%', height: 220 },
heroFallback: {
backgroundColor: '#1e1e1e',
alignItems: 'center', justifyContent: 'center',
},
heroOverlay: {
position: 'absolute', bottom: 0, left: 0, right: 0,
padding: 16, paddingBottom: 20,
background: 'transparent',
},
heroName: { color: '#fff', fontSize: 24, fontWeight: '800', textShadowColor: '#000', textShadowRadius: 8 },
heroSub: { color: '#ddd', fontSize: 13, marginTop: 2 },
backBtn: {
position: 'absolute', top: 44, left: 12,
backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 20,
padding: 6,
},
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingTop: 40 },
});
+118
View File
@@ -0,0 +1,118 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
View, Text, FlatList, ScrollView,
StyleSheet, ActivityIndicator, TouchableOpacity, RefreshControl,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { apiCached, cacheArtistSortNames, fetchRomajiIndex } from '../api';
import { playQueue } from '../audio/player';
import { useStore } from '../store';
import AlbumCard from '../components/AlbumCard';
function AlbumRow({ title, albums, onAlbumPress }) {
if (!albums?.length) return null;
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
<FlatList
horizontal
data={albums}
keyExtractor={a => a.id}
renderItem={({ item }) => (
<AlbumCard album={item} onPress={() => onAlbumPress(item)} />
)}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
/>
</View>
);
}
export default function HomeScreen() {
const navigation = useNavigation();
const { cfg } = useStore();
const [recent, setRecent] = useState([]);
const [frequent, setFrequent] = useState([]);
const [newest, setNewest] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing,setRefreshing]= useState(false);
const load = useCallback(async (invalidate = false) => {
if (!cfg.url) return;
try {
const [recRes, freqRes, newRes] = await Promise.allSettled([
apiCached('getAlbumList2', '&type=recent&size=20'),
apiCached('getAlbumList2', '&type=frequent&size=20'),
apiCached('getAlbumList2', '&type=newest&size=20'),
]);
setRecent( recRes.status === 'fulfilled' ? recRes.value.albumList2?.album || [] : []);
setFrequent(freqRes.status === 'fulfilled' ? freqRes.value.albumList2?.album || [] : []);
setNewest( newRes.status === 'fulfilled' ? newRes.value.albumList2?.album || [] : []);
// Pre-warm artist sort map
const artistRes = await apiCached('getArtists').catch(() => null);
if (artistRes) {
const all = (artistRes.artists?.index || []).flatMap(i => i.artist || []);
cacheArtistSortNames(all);
}
if (cfg.showRomaji) fetchRomajiIndex().catch(() => {});
} catch {}
setLoading(false);
setRefreshing(false);
}, [cfg.url, cfg.showRomaji]);
useEffect(() => { load(); }, [load]);
function goAlbum(album) { navigation.navigate('AlbumDetail', { album }); }
async function quickPlay(album) {
try {
const r = await apiCached('getAlbum', `&id=${album.id}`);
await playQueue(r.album?.song || [], 0);
} catch {}
}
if (!cfg.url) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Kein Server konfiguriert.</Text>
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
<Text style={styles.link}>Einstellungen öffnen →</Text>
</TouchableOpacity>
</View>
);
}
if (loading) {
return <View style={styles.center}><ActivityIndicator color="#1db954" size="large" /></View>;
}
return (
<ScrollView
style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} tintColor="#1db954" />}
>
<Text style={styles.heading}>Willkommen zurück 👋</Text>
<AlbumRow title="Zuletzt gehört" albums={recent} onAlbumPress={goAlbum} />
<AlbumRow title="Meistgespielt" albums={frequent} onAlbumPress={goAlbum} />
<AlbumRow title="Neu hinzugefügt" albums={newest} onAlbumPress={goAlbum} />
<View style={{ height: 16 }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212' },
center: { flex: 1, backgroundColor: '#121212', alignItems: 'center', justifyContent: 'center' },
heading: {
color: '#fff', fontSize: 22, fontWeight: '700',
paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8,
},
section: { marginTop: 16 },
sectionTitle: {
color: '#fff', fontSize: 16, fontWeight: '700',
paddingHorizontal: 16, marginBottom: 12,
},
emptyText: { color: '#aaa', fontSize: 15 },
link: { color: '#1db954', marginTop: 12, fontSize: 14 },
});
+180
View File
@@ -0,0 +1,180 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
View, Text, FlatList, TouchableOpacity,
StyleSheet, ActivityIndicator, Image, TextInput, RefreshControl,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { apiCached, cacheArtistSortNames, dispArtistById, dispAlbumName, coverUrl, getArtistImage } from '../api';
import { useStore } from '../store';
import AlbumCard from '../components/AlbumCard';
// ─── ARTISTS TAB ───
export function ArtistsScreen() {
const navigation = useNavigation();
const { cfg } = useStore();
const [artists, setArtists] = useState([]);
const [filtered, setFiltered] = useState([]);
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing,setRefreshing]= useState(false);
const load = useCallback(async () => {
if (!cfg.url) return setLoading(false);
try {
const r = await apiCached('getArtists');
const all = (r.artists?.index || []).flatMap(i => i.artist || []);
cacheArtistSortNames(all);
setArtists(all);
setFiltered(all);
} catch {}
setLoading(false);
setRefreshing(false);
}, [cfg.url]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!query) { setFiltered(artists); return; }
const q = query.toLowerCase();
setFiltered(artists.filter(a => a.name.toLowerCase().includes(q)));
}, [query, artists]);
if (loading) return <View style={styles.center}><ActivityIndicator color="#1db954" size="large" /></View>;
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<Ionicons name="search" size={16} color="#666" />
<TextInput
style={styles.searchInput}
value={query}
onChangeText={setQuery}
placeholder="Künstler suchen…"
placeholderTextColor="#555"
/>
</View>
<FlatList
data={filtered}
keyExtractor={a => a.id}
numColumns={2}
renderItem={({ item }) => (
<ArtistCard artist={item} onPress={() => navigation.navigate('ArtistDetail', { artist: item })} />
)}
contentContainerStyle={{ padding: 8 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(); }} tintColor="#1db954" />}
/>
</View>
);
}
function ArtistCard({ artist, onPress }) {
const [img, setImg] = useState(null);
useEffect(() => {
getArtistImage(artist.id).then(setImg).catch(() => {});
}, [artist.id]);
return (
<TouchableOpacity style={styles.artistCard} onPress={onPress} activeOpacity={0.7}>
{img ? (
<Image source={{ uri: img }} style={styles.artistImg} />
) : (
<View style={[styles.artistImg, styles.artistImgFallback]}>
<Text style={{ fontSize: 32 }}>🎤</Text>
</View>
)}
<Text style={styles.artistName} numberOfLines={2}>
{dispArtistById(artist.id, artist.name)}
</Text>
<Text style={styles.artistSub}>{artist.albumCount} Alben</Text>
</TouchableOpacity>
);
}
// ─── ALBUMS TAB ───
export function AlbumsScreen() {
const navigation = useNavigation();
const { cfg } = useStore();
const [albums, setAlbums] = useState([]);
const [filtered, setFiltered] = useState([]);
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing,setRefreshing]= useState(false);
const load = useCallback(async () => {
if (!cfg.url) return setLoading(false);
try {
const r = await apiCached('getAlbumList2', '&type=alphabeticalByName&size=500');
const all = r.albumList2?.album || [];
setAlbums(all);
setFiltered(all);
} catch {}
setLoading(false);
setRefreshing(false);
}, [cfg.url]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!query) { setFiltered(albums); return; }
const q = query.toLowerCase();
setFiltered(albums.filter(a =>
a.name.toLowerCase().includes(q) || a.artist?.toLowerCase().includes(q)
));
}, [query, albums]);
if (loading) return <View style={styles.center}><ActivityIndicator color="#1db954" size="large" /></View>;
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<Ionicons name="search" size={16} color="#666" />
<TextInput
style={styles.searchInput}
value={query}
onChangeText={setQuery}
placeholder="Alben suchen…"
placeholderTextColor="#555"
/>
</View>
<FlatList
data={filtered}
keyExtractor={a => a.id}
numColumns={2}
renderItem={({ item }) => (
<View style={{ flex: 1, padding: 6 }}>
<AlbumCard
album={item}
size={(require('react-native').Dimensions.get('window').width / 2) - 18}
onPress={() => navigation.navigate('AlbumDetail', { album: item })}
/>
</View>
)}
contentContainerStyle={{ padding: 6 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(); }} tintColor="#1db954" />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212' },
center: { flex: 1, backgroundColor: '#121212', alignItems: 'center', justifyContent: 'center' },
searchBar: {
flexDirection: 'row', alignItems: 'center', gap: 8,
backgroundColor: '#1e1e1e', borderRadius: 8, margin: 12,
paddingHorizontal: 12, paddingVertical: 8,
},
searchInput: { flex: 1, color: '#fff', fontSize: 14 },
artistCard: {
flex: 1, margin: 6, alignItems: 'center', padding: 8,
backgroundColor: '#1e1e1e', borderRadius: 10,
},
artistImg: {
width: 100, height: 100, borderRadius: 50,
backgroundColor: '#2a2a2a', marginBottom: 8,
},
artistImgFallback: { alignItems: 'center', justifyContent: 'center' },
artistName: { color: '#fff', fontSize: 13, fontWeight: '600', textAlign: 'center' },
artistSub: { color: '#aaa', fontSize: 11, marginTop: 2 },
});
+159
View File
@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
StyleSheet, ScrollView, ActivityIndicator, KeyboardAvoidingView, Platform,
} from 'react-native';
import { useStore } from '../store';
import { api, invalidateCoverCache, invalidateRomajiIndex, invalidateApiCache } from '../api';
export default function LoginScreen() {
const { cfg, saveConfig } = useStore();
const [url, setUrl] = useState(cfg.url || '');
const [user, setUser] = useState(cfg.user || '');
const [pass, setPass] = useState(cfg.pass || '');
const [backendUrl, setBackendUrl] = useState(cfg.backendUrl || '');
const [showRomaji, setShowRomaji] = useState(cfg.showRomaji || false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
async function handleSave() {
const trimUrl = url.trim().replace(/\/$/, '');
if (!trimUrl || !user.trim() || !pass.trim()) {
setError('Server-URL, Benutzer und Passwort sind erforderlich.');
return;
}
setLoading(true); setError('');
// Test connection before saving
const prevCfg = useStore.getState().cfg;
await saveConfig({ url: trimUrl, user: user.trim(), pass: pass.trim(), backendUrl: backendUrl.trim().replace(/\/$/, ''), showRomaji });
try {
await api('ping');
invalidateCoverCache();
invalidateRomajiIndex();
invalidateApiCache();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
// Restore old config on failure
await saveConfig(prevCfg);
setError('Verbindung fehlgeschlagen: ' + e.message);
}
setLoading(false);
}
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView style={styles.container} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<Text style={styles.logo}>🎵 NovaSonic</Text>
<Text style={styles.subtitle}>Verbinde dich mit deinem Navidrome-Server</Text>
<Text style={styles.label}>Server-URL</Text>
<TextInput
style={styles.input}
value={url}
onChangeText={setUrl}
placeholder="https://music.example.com"
placeholderTextColor="#555"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={styles.label}>Benutzername</Text>
<TextInput
style={styles.input}
value={user}
onChangeText={setUser}
placeholder="admin"
placeholderTextColor="#555"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Passwort</Text>
<TextInput
style={styles.input}
value={pass}
onChangeText={setPass}
placeholder="••••••••"
placeholderTextColor="#555"
secureTextEntry
/>
<Text style={styles.label}>Backend-URL (optional)</Text>
<TextInput
style={styles.input}
value={backendUrl}
onChangeText={setBackendUrl}
placeholder="https://backend.example.com"
placeholderTextColor="#555"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={styles.hint}>Für Romaji, Downloads und Metadaten-Bearbeitung</Text>
<TouchableOpacity style={styles.toggleRow} onPress={() => setShowRomaji(v => !v)}>
<View style={[styles.toggle, showRomaji && styles.toggleOn]}>
<View style={[styles.toggleThumb, showRomaji && styles.toggleThumbOn]} />
</View>
<Text style={styles.toggleLabel}>Romaji / Sort-Tags anzeigen</Text>
</TouchableOpacity>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TouchableOpacity style={styles.btn} onPress={handleSave} disabled={loading}>
{loading
? <ActivityIndicator color="#000" />
: <Text style={styles.btnText}>{saved ? '✅ Gespeichert!' : 'Speichern & Verbinden'}</Text>
}
</TouchableOpacity>
{cfg.url ? (
<Text style={styles.connected}>Verbunden mit: {cfg.url}</Text>
) : null}
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212' },
content: { padding: 24, paddingTop: 60 },
logo: { fontSize: 32, textAlign: 'center', marginBottom: 8 },
subtitle: { color: '#aaa', textAlign: 'center', marginBottom: 32, fontSize: 14 },
label: { color: '#ccc', fontSize: 13, marginBottom: 6, marginTop: 16 },
input: {
backgroundColor: '#1e1e1e',
color: '#fff',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 14,
borderWidth: 1,
borderColor: '#2a2a2a',
},
hint: { color: '#555', fontSize: 12, marginTop: 4 },
toggleRow: { flexDirection: 'row', alignItems: 'center', marginTop: 20, gap: 12 },
toggle: {
width: 44, height: 24, borderRadius: 12,
backgroundColor: '#333', justifyContent: 'center', paddingHorizontal: 2,
},
toggleOn: { backgroundColor: '#1db954' },
toggleThumb: {
width: 20, height: 20, borderRadius: 10, backgroundColor: '#fff',
},
toggleThumbOn: { alignSelf: 'flex-end' },
toggleLabel: { color: '#ccc', fontSize: 14 },
error: { color: '#e05252', marginTop: 16, fontSize: 13 },
btn: {
backgroundColor: '#1db954',
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
marginTop: 28,
},
btnText: { color: '#000', fontWeight: '700', fontSize: 15 },
connected: { color: '#555', textAlign: 'center', marginTop: 16, fontSize: 12 },
});
+159
View File
@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import {
View, Text, Image, TouchableOpacity,
StyleSheet, Dimensions, PanResponder,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { usePlaybackState, useProgress, State } from 'react-native-track-player';
import Slider from '@react-native-community/slider';
import { useStore } from '../store';
import { togglePlayPause, skipNext, skipPrev, seekTo, setRepeatMode } from '../audio/player';
import { formatDur } from '../api/utils';
const { width } = Dimensions.get('window');
export default function NowPlayingScreen() {
const navigation = useNavigation();
const { currentTrack, repeat, shuffle, setShuffle } = useStore();
const playbackState = usePlaybackState();
const { position, duration } = useProgress(500);
const playing = playbackState.state === State.Playing;
const cycleRepeat = () => {
const next = repeat === 'off' ? 'all' : repeat === 'all' ? 'one' : 'off';
setRepeatMode(next);
};
if (!currentTrack) {
return (
<View style={styles.empty}>
<Text style={styles.emptyText}>Nichts spielt gerade</Text>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.back}>Zurück</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerBtn}>
<Ionicons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}>Jetzt läuft</Text>
<View style={{ width: 40 }} />
</View>
{/* Album art */}
<View style={styles.artWrap}>
{currentTrack.artwork ? (
<Image source={{ uri: currentTrack.artwork }} style={styles.art} />
) : (
<View style={[styles.art, styles.artFallback]}>
<Text style={{ fontSize: 80 }}>🎵</Text>
</View>
)}
</View>
{/* Track info */}
<View style={styles.trackInfo}>
<Text style={styles.title} numberOfLines={2}>{currentTrack.title}</Text>
<Text style={styles.artist} numberOfLines={1}>{currentTrack.artist}</Text>
<Text style={styles.album} numberOfLines={1}>{currentTrack.album}</Text>
</View>
{/* Progress */}
<View style={styles.progressWrap}>
<Slider
style={styles.slider}
minimumValue={0}
maximumValue={duration || 1}
value={position}
onSlidingComplete={seekTo}
minimumTrackTintColor="#1db954"
maximumTrackTintColor="#444"
thumbTintColor="#fff"
/>
<View style={styles.times}>
<Text style={styles.time}>{formatDur(position)}</Text>
<Text style={styles.time}>{formatDur(duration)}</Text>
</View>
</View>
{/* Controls */}
<View style={styles.controls}>
<TouchableOpacity onPress={() => setShuffle(!shuffle)} style={styles.sideBtn}>
<Ionicons
name="shuffle"
size={22}
color={shuffle ? '#1db954' : '#888'}
/>
</TouchableOpacity>
<TouchableOpacity onPress={skipPrev} style={styles.controlBtn}>
<Ionicons name="play-skip-back" size={32} color="#fff" />
</TouchableOpacity>
<TouchableOpacity onPress={togglePlayPause} style={styles.playBtn}>
<Ionicons name={playing ? 'pause' : 'play'} size={36} color="#000" />
</TouchableOpacity>
<TouchableOpacity onPress={skipNext} style={styles.controlBtn}>
<Ionicons name="play-skip-forward" size={32} color="#fff" />
</TouchableOpacity>
<TouchableOpacity onPress={cycleRepeat} style={styles.sideBtn}>
<Ionicons
name={repeat === 'one' ? 'repeat-outline' : 'repeat'}
size={22}
color={repeat !== 'off' ? '#1db954' : '#888'}
/>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212', paddingBottom: 40 },
empty: { flex: 1, backgroundColor: '#121212', alignItems: 'center', justifyContent: 'center' },
emptyText: { color: '#aaa', fontSize: 16 },
back: { color: '#1db954', marginTop: 12, fontSize: 14 },
header: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingTop: 52, paddingHorizontal: 16, paddingBottom: 12,
},
headerBtn: { width: 40, alignItems: 'flex-start' },
headerTitle: { color: '#fff', fontSize: 14, fontWeight: '700', letterSpacing: 0.5 },
artWrap: { alignItems: 'center', marginTop: 8, marginBottom: 24 },
art: {
width: width - 64, height: width - 64,
borderRadius: 12,
shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 24, shadowOffset: { width: 0, height: 8 },
elevation: 12,
},
artFallback: { backgroundColor: '#1e1e1e', alignItems: 'center', justifyContent: 'center' },
trackInfo: { paddingHorizontal: 24, marginBottom: 12 },
title: { color: '#fff', fontSize: 20, fontWeight: '800', marginBottom: 4 },
artist: { color: '#1db954', fontSize: 15, fontWeight: '600', marginBottom: 2 },
album: { color: '#888', fontSize: 13 },
progressWrap: { paddingHorizontal: 16, marginBottom: 8 },
slider: { width: '100%', height: 40 },
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -8 },
time: { color: '#888', fontSize: 12 },
controls: {
flexDirection: 'row', alignItems: 'center',
justifyContent: 'space-between', paddingHorizontal: 24, marginTop: 8,
},
sideBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
controlBtn: { width: 56, height: 56, alignItems: 'center', justifyContent: 'center' },
playBtn: {
width: 68, height: 68, borderRadius: 34,
backgroundColor: '#1db954',
alignItems: 'center', justifyContent: 'center',
},
});
+157
View File
@@ -0,0 +1,157 @@
import React, { useState, useCallback } from 'react';
import {
View, Text, TextInput, FlatList,
StyleSheet, TouchableOpacity, ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { searchAll, dispTitle, dispArtistById, dispAlbumName } from '../api';
import { playQueue } from '../audio/player';
import SongItem from '../components/SongItem';
import AlbumCard from '../components/AlbumCard';
let _searchTimer = null;
export default function SearchScreen() {
const navigation = useNavigation();
const [query, setQuery] = useState('');
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const doSearch = useCallback(async (q) => {
if (!q.trim()) { setResults(null); return; }
setLoading(true);
try {
const r = await searchAll(q);
setResults(r);
} catch { setResults({ artists: [], albums: [], songs: [] }); }
setLoading(false);
}, []);
function onChangeText(text) {
setQuery(text);
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => doSearch(text), 400);
}
const data = [];
if (results?.artists?.length) {
data.push({ type: 'header', key: 'h-artists', label: 'Künstler' });
results.artists.forEach(a => data.push({ type: 'artist', key: `a-${a.id}`, item: a }));
}
if (results?.albums?.length) {
data.push({ type: 'header', key: 'h-albums', label: 'Alben' });
results.albums.forEach(a => data.push({ type: 'album', key: `al-${a.id}`, item: a }));
}
if (results?.songs?.length) {
data.push({ type: 'header', key: 'h-songs', label: 'Titel' });
results.songs.forEach((s, i) => data.push({ type: 'song', key: `s-${s.id}`, item: s, index: i }));
}
function renderItem({ item: row }) {
if (row.type === 'header') {
return <Text style={styles.sectionHeader}>{row.label}</Text>;
}
if (row.type === 'artist') {
return (
<TouchableOpacity
style={styles.rowItem}
onPress={() => navigation.navigate('ArtistDetail', { artist: row.item })}
>
<Text style={{ fontSize: 22 }}>🎤</Text>
<Text style={styles.rowText}>{dispArtistById(row.item.id, row.item.name)}</Text>
</TouchableOpacity>
);
}
if (row.type === 'album') {
return (
<TouchableOpacity
style={styles.rowItem}
onPress={() => navigation.navigate('AlbumDetail', { album: row.item })}
>
<Text style={{ fontSize: 22 }}>💿</Text>
<View style={{ flex: 1 }}>
<Text style={styles.rowText}>{dispAlbumName(row.item)}</Text>
<Text style={styles.rowSub}>{dispArtistById(row.item.artistId, row.item.artist || '')}</Text>
</View>
</TouchableOpacity>
);
}
if (row.type === 'song') {
return (
<SongItem
song={row.item}
index={row.index}
showArt
onPress={() => playQueue(results.songs, row.index)}
/>
);
}
return null;
}
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<Ionicons name="search" size={18} color="#666" />
<TextInput
style={styles.input}
value={query}
onChangeText={onChangeText}
placeholder="Suchen…"
placeholderTextColor="#555"
autoFocus
returnKeyType="search"
onSubmitEditing={() => doSearch(query)}
/>
{query ? (
<TouchableOpacity onPress={() => { setQuery(''); setResults(null); }}>
<Ionicons name="close-circle" size={18} color="#666" />
</TouchableOpacity>
) : null}
</View>
{loading && <ActivityIndicator color="#1db954" style={{ marginTop: 20 }} />}
{!loading && results && data.length === 0 && (
<Text style={styles.empty}>Keine Ergebnisse für „{query}"</Text>
)}
{!loading && results && data.length > 0 && (
<FlatList
data={data}
keyExtractor={r => r.key}
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
/>
)}
{!results && !loading && (
<Text style={styles.hint}>Suche nach Künstlern, Alben oder Titeln</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#121212' },
searchBar: {
flexDirection: 'row', alignItems: 'center', gap: 10,
backgroundColor: '#1e1e1e', borderRadius: 10,
margin: 12, paddingHorizontal: 14, paddingVertical: 10,
},
input: { flex: 1, color: '#fff', fontSize: 15 },
sectionHeader: {
color: '#aaa', fontSize: 12, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1,
paddingHorizontal: 16, paddingVertical: 10, paddingTop: 16,
},
rowItem: {
flexDirection: 'row', alignItems: 'center', gap: 12,
paddingHorizontal: 16, paddingVertical: 12,
},
rowText: { color: '#fff', fontSize: 14, fontWeight: '500' },
rowSub: { color: '#aaa', fontSize: 12, marginTop: 2 },
empty: { color: '#aaa', textAlign: 'center', marginTop: 40, fontSize: 14 },
hint: { color: '#555', textAlign: 'center', marginTop: 40, fontSize: 14 },
});
+64
View File
@@ -0,0 +1,64 @@
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
const CFG_KEY = 'novasonic_config';
export const useStore = create((set, get) => ({
// ─── Config ───
cfg: {
url: '',
user: '',
pass: '',
backendUrl: '',
showRomaji: false,
showPlayCount: false,
},
configLoaded: false,
loadConfig: async () => {
try {
const raw = await SecureStore.getItemAsync(CFG_KEY);
if (raw) {
const cfg = JSON.parse(raw);
set({ cfg: { ...get().cfg, ...cfg }, configLoaded: true });
} else {
set({ configLoaded: true });
}
} catch {
set({ configLoaded: true });
}
},
saveConfig: async (patch) => {
const cfg = { ...get().cfg, ...patch };
set({ cfg });
try {
await SecureStore.setItemAsync(CFG_KEY, JSON.stringify(cfg));
} catch {}
},
// ─── Playback ───
queue: [],
qIdx: -1,
shuffle: false,
repeat: false, // 'off' | 'all' | 'one'
currentTrack: null, // full song object
isPlaying: false,
progress: { position: 0, duration: 0 },
setQueue: (queue, idx) => set({ queue, qIdx: idx }),
setCurrentTrack: (track) => set({ currentTrack: track }),
setIsPlaying: (isPlaying) => set({ isPlaying }),
setProgress: (progress) => set({ progress }),
setShuffle: (shuffle) => set({ shuffle }),
setRepeat: (repeat) => set({ repeat }),
// ─── Navigation aux ───
artistImgCache: {},
setArtistImg: (id, url) =>
set(s => ({ artistImgCache: { ...s.artistImgCache, [id]: url } })),
// ─── Navidrome REST token ───
_ndToken: null,
setNdToken: (t) => set({ _ndToken: t }),
}));