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:
1 parent
59adee60d5
commit
1b96706a1c
31 files changed
+8640
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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);
|
||||||
Generated
+6740
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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 || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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')}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
Reference in new issue
Block a user