Files
musicshare/apps/mobile/src/components/NowPlayingCard.tsx
T
chrisandClaude Opus 5.5 5cbde5e7f9 The Android app updates itself from our Gitea releases; feed cards fade in instead of bouncing
- API: GET /app/android/latest reads the latest Gitea release (ANDROID_RELEASES_URL, optional token for a
  private repo) with the APK's SHA-256 from SHA256SUMS; GET /app/android/apk/:name streams that APK.
- App (modules/updater): downloads in the background, checks the SHA-256, and installs through
  PackageInstaller: quietly once when leaving the app (Android 12+), otherwise from the feed banner or
  Settings > App version. F-Droid builds leave it out and drop the install permissions.
- CI: the release notes come from the fastlane changelog; the app shows the part above ---.
- Settings: Create-account and Link Spotify buttons for Last.fm and ListenBrainz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:34:04 -04:00

139 lines
5.5 KiB
TypeScript

import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
} from 'react-native-reanimated';
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
import { successHaptic, tapHaptic } from '@/lib/haptics';
import { colors, radius, space } from '@/lib/theme';
import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer';
import { Lyrics } from './Lyrics';
import { ProgressBar } from './ProgressBar';
import { Reactions } from './Reactions';
import { ServiceLinks, TrackRow } from './TrackCard';
export function NowPlayingCard({
np,
preferred,
showUser = true,
pinned,
onTogglePin,
}: {
np: NowPlayingDTO;
preferred: StreamingService | null | undefined;
showUser?: boolean;
pinned?: boolean;
onTogglePin?: () => void;
}) {
// Star "pop" when pinned.
const star = useSharedValue(1);
const wasPinned = useRef(pinned);
useEffect(() => {
if (pinned && !wasPinned.current) star.set(withSequence(withSpring(1.6, { damping: 6, stiffness: 400 }), withSpring(1)));
wasPinned.current = pinned;
}, [pinned, star]);
const starStyle = useAnimatedStyle(() => ({ transform: [{ scale: star.value }] }));
const [expanded, setExpanded] = useState(false);
const toggle = () => {
tapHaptic();
setExpanded((v) => !v);
};
const togglePin = () => {
if (pinned) tapHaptic();
else successHaptic();
onTogglePin?.();
};
return (
<View style={[styles.card, pinned && styles.pinned]}>
{/* The card glows with its album art; crossfades when the song changes. */}
{np.track.artworkUrl && (
<Animated.View key={np.track.artworkUrl} style={StyleSheet.absoluteFill} entering={FadeIn.duration(700)} exiting={FadeOut.duration(500)}>
<Image source={np.track.artworkUrl} style={styles.backdrop} blurRadius={40} contentFit="cover" />
</Animated.View>
)}
<LinearGradient colors={['rgba(7,7,11,0.05)', 'rgba(7,7,11,0.6)']} style={StyleSheet.absoluteFill} />
<View style={styles.header}>
{showUser && (
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
<Avatar user={np.user} size={26} live={np.isPlaying} />
<Text style={styles.name} numberOfLines={1}>
{np.user.displayName ?? np.user.username}
</Text>
</Pressable>
)}
<View style={styles.statusRow}>
{np.isPlaying && <Equalizer playing />}
<Text style={[styles.status, np.isPlaying && styles.live]} numberOfLines={1}>
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
<Text
style={[styles.via, np.sourceUrl && styles.viaLink]}
onPress={np.sourceUrl ? () => Linking.openURL(np.sourceUrl!) : undefined}>
{' · '}
{np.sourceApp ?? SERVICE_LABELS[np.source]}
</Text>
</Text>
{onTogglePin && (
<Pressable
onPress={togglePin}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
accessibilityState={{ selected: pinned }}>
<Animated.Text style={[styles.pin, pinned && styles.pinOn, starStyle]}>{pinned ? '★' : '☆'}</Animated.Text>
</Pressable>
)}
</View>
</View>
<Animated.View key={np.track.id} entering={FadeIn.duration(250)} exiting={FadeOut.duration(200)}>
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
</Animated.View>
<ProgressBar np={np} compact={!expanded} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
{expanded && (
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
<ServiceLinks track={np.track} preferred={preferred} />
<Lyrics track={np.track} np={np} />
</Animated.View>
)}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: radius.lg,
padding: space.md + 2,
gap: space.sm + 2,
overflow: 'hidden',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
details: { gap: space.md },
pinned: { borderColor: 'rgba(251,191,36,0.55)' },
backdrop: { position: 'absolute', top: '-40%', left: '-40%', width: '180%', height: '180%', opacity: 0.55 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs + 2, flexShrink: 1 },
status: { color: colors.muted, fontSize: 13, flexShrink: 1 },
live: { color: colors.live, fontWeight: '600' },
via: { color: colors.muted, fontWeight: '400' },
viaLink: { textDecorationLine: 'underline' },
pin: { color: colors.muted, fontSize: 21, marginLeft: space.xs },
pinOn: { color: colors.pin, textShadowColor: 'rgba(251,191,36,0.6)', textShadowRadius: 10 },
});