Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0de38b6
refactor(player): extract shuffle overlay + queue actions into shared…
ghenry22 May 31, 2026
4d6d250
refactor(player): migrate PlayerView onto shared shuffle/queue hooks
ghenry22 May 31, 2026
371e93a
refactor(player): migrate ExpandedPlayerView onto shared shuffle/queu…
ghenry22 May 31, 2026
0fdb134
refactor(player): migrate PlayerPanel onto shared shuffle/queue hooks
ghenry22 May 31, 2026
59c9fb1
feat(player): add UpNextPanel inline draggable queue/info/lyrics panel
ghenry22 May 31, 2026
2a9a235
feat(player): add TabletPortraitPlayer screen
ghenry22 May 31, 2026
b951095
feat(player): route /player to TabletPortraitPlayer on tablet portrait
ghenry22 May 31, 2026
ef9d1e3
test(player): expect 2 shuffle buttons in queue tab (controls + header)
ghenry22 May 31, 2026
67ea841
refactor(player): extract shared FavoriteButton + BookmarkButton
ghenry22 May 31, 2026
971a3a7
refactor(player): use shared Favorite/Bookmark buttons in all 4 players
ghenry22 May 31, 2026
faf0eb2
feat(player): rework tablet-portrait top section to a horizontal band
ghenry22 May 31, 2026
b4fb20b
feat(player): add bookmark button to expanded player secondary row
ghenry22 May 31, 2026
37a4783
fix(player): align secondary control row under the primary row
ghenry22 May 31, 2026
9ef7d6b
feat(player+cache): player reorg, tablet-portrait rework, canonical c…
ghenry22 Jun 1, 2026
6339adb
chore(deps): bump Expo SDK 56 patch releases
ghenry22 Jun 2, 2026
2b891f0
feat(tuned-in): tablet layouts + multi-decade builder; fix Intl timezone
ghenry22 Jun 2, 2026
2913d06
feat(downloads): add Download Full Library (#88)
ghenry22 Jun 2, 2026
a4805bb
feat(player): tablet-portrait mini player
ghenry22 Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ router.replace('/login');
## Layout Files

- `app/_layout.tsx` – Root Stack with auth guard, splash screen, and theme-aware header styles.
- `app/(tabs)/_layout.tsx` – Tab navigator with `MiniPlayer` above the tab bar and `SearchableHeader` as custom header.
- `app/(tabs)/_layout.tsx` – Tab navigator with `PlayerPhoneMini` (via `BottomChrome`) above the tab bar and `SearchableHeader` as custom header.

## Auth Guard

Expand Down
11 changes: 11 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,16 @@ module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
env: {
// Production builds (BABEL_ENV/NODE_ENV=production, e.g. EAS release
// builds and `expo export`) strip ALL console.* calls. The app has its
// own opt-in file-based logging (see the Logging screen / imageCacheLogger
// + diagnostics stores) for anything user-facing, so console output is
// dev-only noise that isn't visible to users — no point shipping it or
// spending cycles on it. Dev builds keep console intact.
production: {
plugins: ['transform-remove-console'],
},
},
};
};
180 changes: 99 additions & 81 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@
"@react-native-vector-icons/ionicons": "^13.1.1",
"@react-native-vector-icons/material-design-icons": "^13.1.1",
"@shopify/flash-list": "^2.3.1",
"expo": "~56.0.5",
"expo": "~56.0.8",
"expo-async-fs": "file:./modules/expo-async-fs",
"expo-backup-exclusions": "file:./modules/expo-backup-exclusions",
"expo-battery": "~56.0.4",
"expo-blur": "~56.0.3",
"expo-build-properties": "~56.0.15",
"expo-build-properties": "~56.0.16",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.15",
"expo-crypto": "56.0.3",
"expo-crypto": "~56.0.4",
"expo-device": "~56.0.4",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
Expand All @@ -59,14 +59,14 @@
"expo-image-resize": "file:./modules/expo-image-resize",
"expo-intent-launcher": "~56.0.4",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12",
"expo-linking": "~56.0.13",
"expo-localization": "~56.0.6",
"expo-location": "~56.0.14",
"expo-location": "~56.0.15",
"expo-move-to-back": "file:./modules/expo-move-to-back",
"expo-notifications": "~56.0.14",
"expo-router": "~56.2.7",
"expo-notifications": "~56.0.15",
"expo-router": "~56.2.8",
"expo-screen-orientation": "~56.0.5",
"expo-sharing": "~56.0.14",
"expo-sharing": "~56.0.15",
"expo-sqlite": "~56.0.4",
"expo-ssl-trust": "file:./modules/expo-ssl-trust",
"expo-status-bar": "~56.0.4",
Expand Down Expand Up @@ -95,7 +95,8 @@
"@testing-library/react-native": "^13.3.3",
"@types/jest": "~29.5.14",
"@types/react": "^19.2.15",
"expo-dev-client": "~56.0.16",
"babel-plugin-transform-remove-console": "^6.9.4",
"expo-dev-client": "~56.0.18",
"jest": "~29.7.0",
"jest-expo": "~56.0.4",
"patch-package": "^8.0.1",
Expand Down
10 changes: 5 additions & 5 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ import { mixHexColors } from '../utils/colors';
import AnimatedSplashScreen from '../components/AnimatedSplashScreen';
import { CertificatePromptModal } from '../components/CertificatePromptModal';
import { CreateShareSheet } from '../components/CreateShareSheet';
import { ExpandedPlayerView } from '../components/ExpandedPlayerView';
import { PlayerPanel } from '../components/PlayerPanel';
import { PlayerTabletLandscape } from '../components/player/PlayerTabletLandscape';
import { PlayerTabletSplitview } from '../components/player/PlayerTabletSplitview';
import { SplitLayout } from '../components/SplitLayout';
import { MbidSearchSheet } from '../components/MbidSearchSheet';
import { MoreOptionsSheet } from '../components/MoreOptionsSheet';
Expand Down Expand Up @@ -725,13 +725,13 @@ export default function RootLayout() {
</Stack>
</View>
}
panel={showPanel ? <PlayerPanel /> : null}
panel={showPanel ? <PlayerTabletSplitview /> : null}
panelPlaceholder={<GradientBackground style={{ flex: 1 }}>{null}</GradientBackground>}
/>

{/* Full-screen expanded player — covers everything including SplitLayout */}
{showPanel && (
<ExpandedPlayerView expandProgress={expandProgress} />
<PlayerTabletLandscape expandProgress={expandProgress} />
)}

{/* Global more-options bottom sheet driven by moreOptionsStore */}
Expand Down Expand Up @@ -777,7 +777,7 @@ export default function RootLayout() {
{/* Global error pill. Used by `playerService.fail(...)` to surface
genuine playback failures (offline + no cached tracks, RNTP
errors). Lifts itself above the BottomChrome (DownloadBanner +
MiniPlayer) when present so it doesn't stack on top. */}
mini player) when present so it doesn't stack on top. */}
<PlaybackToast />


Expand Down
7 changes: 5 additions & 2 deletions src/app/player.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { PlayerView } from '@/screens/player-view';
import { useIsTabletPortrait } from '@/hooks/useIsTabletPortrait';
import { PlayerPhonePortrait } from '@/screens/player/player-phone-portrait';
import { PlayerTabletPortrait } from '@/screens/player/player-tablet-portrait';

export default function PlayerRoute() {
return <PlayerView />;
const tabletPortrait = useIsTabletPortrait();
return tabletPortrait ? <PlayerTabletPortrait /> : <PlayerPhonePortrait />;
}
175 changes: 124 additions & 51 deletions src/components/AlbumInfoContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import FontAwesome5 from "@react-native-vector-icons/fontawesome5/static";
import Ionicons from "@react-native-vector-icons/ionicons/static";
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Linking,
Pressable,
Expand All @@ -10,12 +10,19 @@ import {
Text,
View,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';

import { FormatBadge } from './FormatBadge';
import { useRefreshControlKey } from '../hooks/useRefreshControlKey';
import { type Child } from '../services/subsonicService';
import { isVariousArtists, type Child } from '../services/subsonicService';
import { hexWithAlpha } from '../utils/colors';
import { getEffectiveFormat } from '../utils/effectiveFormat';
import { getGenreNames } from '../utils/genreHelpers';
Expand Down Expand Up @@ -115,15 +122,26 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
return phrases;
}, [track, t]);

// Build credit rows (album artist if different, composer)
// Compilation = album credited to "Various Artists" (any casing). This is how
// Navidrome/OpenSubsonic surfaces compilations; the per-album `isCompilation`
// flag only rides on AlbumID3 (getAlbum), which the player never fetches.
const isCompilation = isVariousArtists(track.displayAlbumArtist ?? track.artist);

// Build credit rows (album artist if different, composer). For compilations
// the "Various Artists" album-artist row is redundant with the placeholder, so
// skip it.
const credits = useMemo(() => {
const rows: { label: string; value: string }[] = [];
if (track.displayAlbumArtist && track.displayAlbumArtist !== track.artist) {
if (
!isCompilation &&
track.displayAlbumArtist &&
track.displayAlbumArtist !== track.artist
) {
rows.push({ label: t('detailAlbumArtist'), value: track.displayAlbumArtist });
}
if (track.displayComposer) rows.push({ label: t('detailComposer'), value: track.displayComposer });
return rows;
}, [track, t]);
}, [track, t, isCompilation]);

const handleLastFm = useCallback(() => {
if (albumInfo?.lastFmUrl) Linking.openURL(albumInfo.lastFmUrl);
Expand Down Expand Up @@ -187,51 +205,7 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
)}
</View>
) : (albumInfoLoading || refreshing) ? (
/* Skeleton placeholder — mirrors the real layout */
((() => {
// Theme-aware skeleton fill: derives from `textSecondary` so light
// mode gets a dark-gray bar (visible on white) and dark mode gets
// a light-gray bar (visible on black). The hardcoded white-alpha
// previously used was invisible on light backgrounds.
const skeletonFill = { backgroundColor: hexWithAlpha(colors.textSecondary, 0.2) };
return (
<View>
{/* Hero block */}
<View style={styles.heroBlock}>
<View style={[styles.skeletonBar, styles.skeletonAlbumTitle, skeletonFill]} />
<View style={[styles.skeletonBar, styles.skeletonArtistSubtitle, skeletonFill]} />
<View style={[styles.skeletonBar, styles.skeletonFormatBadge, skeletonFill]} />
<View style={styles.skeletonGenrePillRow}>
{[72, 96, 60, 84].map((w, i) => (
<View key={i} style={[styles.skeletonBar, styles.skeletonGenrePill, skeletonFill, { width: w }]} />
))}
</View>
</View>

{/* Inline metadata strip */}
<View style={[styles.skeletonBar, styles.skeletonMetaStrip, skeletonFill]} />

{/* Description */}
<View style={[styles.divider, { backgroundColor: colors.textSecondary }]} />
<View style={styles.descriptionSection}>
{[1, 0.97, 1, 0.95, 0.98, 1, 0.93, 0.96, 1, 0.6].map((w, i) => (
<View
key={i}
style={[styles.skeletonBar, styles.skeletonTextLine, skeletonFill, { width: `${w * 100}%` }]}
/>
))}
</View>

{/* External links */}
<View style={[styles.divider, { backgroundColor: colors.textSecondary }]} />
<View style={styles.skeletonLinksRow}>
{[90, 110, 95].map((w, i) => (
<View key={i} style={[styles.skeletonBar, styles.skeletonChip, skeletonFill, { width: w }]} />
))}
</View>
</View>
);
})())
<AlbumInfoSkeleton colors={colors} />
) : (
<>
{/* ── Hero header block (centered) ── */}
Expand Down Expand Up @@ -334,7 +308,25 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
</Pressable>
)}
</View>
) : null}
) : (
/* No description — show a friendly placeholder in the bio slot,
styled like the "no lyrics available" empty state for
consistency across player segments. */
<View style={styles.placeholderBlock}>
<View style={[styles.divider, { backgroundColor: colors.textSecondary }]} />
<View style={styles.placeholderInner}>
<Ionicons
name={isCompilation ? 'albums-outline' : 'information-circle-outline'}
size={36}
color={colors.textSecondary}
style={styles.errorIcon}
/>
<Text style={[styles.errorText, { color: colors.textSecondary }]}>
{isCompilation ? t('albumDetailsCompilation') : t('albumDetailsNotFound')}
</Text>
</View>
</View>
)}
</>
)}
{/* ── External links (centered) ── */}
Expand Down Expand Up @@ -385,6 +377,76 @@ export const AlbumInfoContent = memo(function AlbumInfoContent({
);
});

/* ------------------------------------------------------------------ */
/* Skeleton placeholder — mirrors the real layout, with a looping */
/* opacity pulse so it reads as "loading" rather than a frozen frame. */
/* ------------------------------------------------------------------ */

const AlbumInfoSkeleton = memo(function AlbumInfoSkeleton({
colors,
}: {
colors: AlbumInfoContentProps['colors'];
}) {
// Theme-aware skeleton fill: derives from `textSecondary` so light mode gets
// a dark-gray bar (visible on white) and dark mode a light-gray bar (visible
// on black).
const skeletonFill = { backgroundColor: hexWithAlpha(colors.textSecondary, 0.2) };

// Looping pulse. Starts at 1 (never 0) and breathes between 0.4 and 1 — the
// mount-and-repeat shape used elsewhere (e.g. tuned-in) so it can't get stuck
// invisible. Only mounted while loading, so it stops on unmount.
const pulse = useSharedValue(1);
useEffect(() => {
pulse.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 700 }),
withTiming(1, { duration: 700 }),
),
-1,
);
}, [pulse]);

const pulseStyle = useAnimatedStyle(() => ({ opacity: pulse.value }));

return (
<Animated.View style={pulseStyle}>
{/* Hero block */}
<View style={styles.heroBlock}>
<View style={[styles.skeletonBar, styles.skeletonAlbumTitle, skeletonFill]} />
<View style={[styles.skeletonBar, styles.skeletonArtistSubtitle, skeletonFill]} />
<View style={[styles.skeletonBar, styles.skeletonFormatBadge, skeletonFill]} />
<View style={styles.skeletonGenrePillRow}>
{[72, 96, 60, 84].map((w, i) => (
<View key={i} style={[styles.skeletonBar, styles.skeletonGenrePill, skeletonFill, { width: w }]} />
))}
</View>
</View>

{/* Inline metadata strip */}
<View style={[styles.skeletonBar, styles.skeletonMetaStrip, skeletonFill]} />

{/* Description */}
<View style={[styles.divider, { backgroundColor: colors.textSecondary }]} />
<View style={styles.descriptionSection}>
{[1, 0.97, 1, 0.95, 0.98, 1, 0.93, 0.96, 1, 0.6].map((w, i) => (
<View
key={i}
style={[styles.skeletonBar, styles.skeletonTextLine, skeletonFill, { width: `${w * 100}%` }]}
/>
))}
</View>

{/* External links */}
<View style={[styles.divider, { backgroundColor: colors.textSecondary }]} />
<View style={styles.skeletonLinksRow}>
{[90, 110, 95].map((w, i) => (
<View key={i} style={[styles.skeletonBar, styles.skeletonChip, skeletonFill, { width: w }]} />
))}
</View>
</Animated.View>
);
});

/* ------------------------------------------------------------------ */
/* Styles */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -470,6 +532,17 @@ const styles = StyleSheet.create({
textAlign: 'right',
},

/* No-description placeholder (compilation / not found) */
placeholderBlock: {
marginBottom: 4,
},
placeholderInner: {
alignItems: 'center',
paddingVertical: 28,
paddingHorizontal: 32,
gap: 12,
},

/* Album description */
descriptionSection: {
marginBottom: 4,
Expand Down
Loading