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