CI / check (push) Canceled after 0s
- Feed and profile cards show a progress bar with elapsed/total time, only when the source reports a real position (Plex, Jellyfin, the Android reader). It ticks locally between updates. Apple Music's guessed position is no longer reported. - The Android reader advances MediaSession's stale position to now, stamps when it was read (the server corrects for upload delay), and reports seeks. The server pushes an update to watchers when a position jumps. - When a polled source says where in the song someone is, the next poll is scheduled just after the song ends (never sooner than 5s), instead of waiting out the full interval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
import { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { router } from 'expo-router';
|
|
import { useEffect, useRef } from 'react';
|
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
import Animated, {
|
|
FadeIn,
|
|
FadeInDown,
|
|
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 { ProgressBar } from './ProgressBar';
|
|
import { TrackCard } 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 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={30} 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={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
|
|
<TrackCard track={np.track} preferred={preferred} />
|
|
</Animated.View>
|
|
<ProgressBar np={np} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
card: {
|
|
borderRadius: radius.lg,
|
|
padding: space.lg,
|
|
gap: space.md,
|
|
overflow: 'hidden',
|
|
backgroundColor: colors.surface,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
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 },
|
|
});
|