diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 5aef44552..4a1ecb9a4 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -29,7 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { reactNativeFactory = factory window = UIWindow(frame: UIScreen.main.bounds) - + let receiverAppID = kGCKDefaultMediaReceiverApplicationID // or "ABCD1234" let criteria = GCKDiscoveryCriteria(applicationID: receiverAppID) let options = GCKCastOptions(discoveryCriteria: criteria) @@ -38,7 +38,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options.physicalVolumeButtonsWillControlDeviceVolume = true GCKCastContext.setSharedInstanceWith(options) - + + // Debug helper: if launched with --clear-ota argument, wipe any stored Nitro OTA bundle + if ProcessInfo.processInfo.arguments.contains("--clear-ota") { + NitroOtaBundleManager.shared.clearStoredData() + print("[AppDelegate] Cleared Nitro OTA stored data (--clear-ota)") + } + + // Debug helper: if environment SKIP_NITRO_OTA=1 is set, clear stored OTA data for this run + if ProcessInfo.processInfo.environment["SKIP_NITRO_OTA"] == "1" { + NitroOtaBundleManager.shared.clearStoredData() + print("[AppDelegate] SKIP_NITRO_OTA=1 detected; cleared Nitro OTA stored data for this run") + } + + // Log the resolved JS bundle URL so we can tell whether the app will use an OTA bundle or embedded bundle + if let resolvedURL = delegate.bundleURL() { + print("[AppDelegate] Resolved RN bundle URL: \(resolvedURL.absoluteString)") + } else { + print("[AppDelegate] No RN bundle URL resolved (nil)") + } + factory.startReactNative( withModuleName: "Jellify", in: window, diff --git a/ios/Jellify.xcodeproj/project.pbxproj b/ios/Jellify.xcodeproj/project.pbxproj index 18d916caf..12cc5b539 100644 --- a/ios/Jellify.xcodeproj/project.pbxproj +++ b/ios/Jellify.xcodeproj/project.pbxproj @@ -94,8 +94,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ CFE47DDB2EA56B0200EB6067 /* icons */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = icons; sourceTree = ""; }; @@ -399,10 +397,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks.sh\"\n"; @@ -416,10 +418,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources.sh\"\n"; @@ -541,11 +547,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 287; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 300; + DEVELOPMENT_TEAM = YMP32T9CQJ; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Jellify/Info.plist; @@ -554,7 +558,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.20; + MARKETING_VERSION = 2.0.1; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -562,10 +566,9 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify; + PRODUCT_BUNDLE_IDENTIFIER = com.dimse.jellify; PRODUCT_NAME = Jellify; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.cosmonautical.jellify"; SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -583,11 +586,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 287; - DEVELOPMENT_TEAM = WAH9CZ8BPG; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 300; + DEVELOPMENT_TEAM = YMP32T9CQJ; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = Jellify/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -595,7 +596,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.20; + MARKETING_VERSION = 2.0.1; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -603,10 +604,9 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify; + PRODUCT_BUNDLE_IDENTIFIER = com.dimse.jellify; PRODUCT_NAME = Jellify; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.cosmonautical.jellify"; SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -655,7 +655,7 @@ COPY_PHASE_STRIP = NO; CXX = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; + ENABLE_TESTABILITY = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -707,10 +707,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -798,10 +795,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -821,11 +815,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 287; - DEVELOPMENT_TEAM = WAH9CZ8BPG; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 300; + DEVELOPMENT_TEAM = YMP32T9CQJ; ENABLE_USER_SCRIPT_SANDBOXING = NO; ENVFILE = .env.devrelease; INFOPLIST_FILE = Jellify/Info.plist; @@ -834,7 +826,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.20; + MARKETING_VERSION = 2.0.1; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -842,10 +834,9 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -D DEV_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify; + PRODUCT_BUNDLE_IDENTIFIER = com.dimse.jellify; PRODUCT_NAME = Jellify; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.cosmonautical.jellify"; SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -931,10 +922,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/ios/Jellify.xcodeproj/xcshareddata/xcschemes/Jellify - Release.xcscheme b/ios/Jellify.xcodeproj/xcshareddata/xcschemes/Jellify - Release.xcscheme index d821c12c4..c4c0d7ab7 100644 --- a/ios/Jellify.xcodeproj/xcshareddata/xcschemes/Jellify - Release.xcscheme +++ b/ios/Jellify.xcodeproj/xcshareddata/xcschemes/Jellify - Release.xcscheme @@ -50,6 +50,19 @@ ReferencedContainer = "container:Jellify.xcodeproj"> + + + + + + + + - - com.apple.developer.carplay-audio - - + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f264bd853..397b515c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3650,7 +3650,7 @@ SPEC CHECKSUMS: Gifu: 9f7e52357d41c0739709019eb80a71ad9aab1b6d glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 google-cast-sdk: 32f65af50d164e3c475e79ad123db3cc26fbcd37 - hermes-engine: 83ac7cadb2a3a158ae6d9e4192417c5232065e99 + hermes-engine: eb56216775bd01d2cbb08c73f932fabe6e878ac5 MMKVCore: d078dce7d6586a888b2c2ef5343b6242678e3ee8 NitroFetch: 0a3dbf3c180870ff93176957cf70b47757552698 NitroMmkv: f0a5804b437ecda18aaf0c75649964343438f083 diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index d44d445e8..66b7bd2e7 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -24,6 +24,9 @@ export interface ArtistsProps { showAlphabeticalSelector: boolean sortDescending?: boolean artistPageParams?: RefObject> + // When true, short press triggers the default long-press action + // and long press triggers the default short-press action. + invertPressBehavior?: boolean } /** @@ -38,6 +41,7 @@ export default function Artists({ showAlphabeticalSelector, sortDescending, artistPageParams, + invertPressBehavior, }: ArtistsProps): React.JSX.Element { const theme = useTheme() @@ -77,7 +81,12 @@ export default function Artists({ ) ) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( - + ) : null // Effect for handling the pending alphabet selector letter diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx index 72b012930..1fc744d29 100644 --- a/src/components/Context/index.tsx +++ b/src/components/Context/index.tsx @@ -196,6 +196,8 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele ...mutation, queuingType: QueuingType.PlayingNext, }) + // Close the popup menu immediately after action + navigationRef.dispatch(StackActions.pop()) }} pressStyle={{ opacity: 0.5 }} > @@ -211,11 +213,13 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele flex={1} gap={'$2.5'} justifyContent='flex-start' - onPress={() => { - addToQueue({ + onPress={async () => { + await addToQueue({ ...mutation, queuingType: QueuingType.DirectlyQueued, }) + // Close the popup menu immediately after action + navigationRef.dispatch(StackActions.pop()) }} pressStyle={{ opacity: 0.5 }} > diff --git a/src/components/Global/components/Track/index.tsx b/src/components/Global/components/Track/index.tsx index 2bd68d073..82ecd1da8 100644 --- a/src/components/Global/components/Track/index.tsx +++ b/src/components/Global/components/Track/index.tsx @@ -86,23 +86,8 @@ export default function Track({ // Memoize tracklist for queue loading const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? [] - // Memoize handlers to prevent recreation - const handlePress = async () => { - if (onPress) { - await onPress() - } else { - loadNewQueue({ - track, - index, - tracklist: memoizedTracklist, - queue, - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - } - } - - const handleLongPress = () => { + // Reverse: short press opens context, long press plays track + const handlePress = () => { if (onLongPress) { onLongPress() } else { @@ -117,6 +102,21 @@ export default function Track({ } } + const handleLongPress = async () => { + if (onPress) { + await onPress() + } else { + loadNewQueue({ + track, + index, + tracklist: memoizedTracklist, + queue, + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + } + } + const handleIconPress = () => { navigationRef.navigate('Context', { item: track, diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx index 930a05174..60eb25281 100644 --- a/src/components/Global/components/alphabetical-selector.tsx +++ b/src/components/Global/components/alphabetical-selector.tsx @@ -47,6 +47,7 @@ export default function AZScroller({ const selectedLetter = useSharedValue('') const [overlayLetter, setOverlayLetter] = useState('') + const [computedLineHeight, setComputedLineHeight] = useState(null) const showOverlay = () => { 'worklet' @@ -59,7 +60,7 @@ export default function AZScroller({ } const setOverlayPositionY = (y: number) => { - 'worket' + 'worklet' gesturePositionY.value = withSpring(y, { mass: 4, damping: 120, @@ -168,8 +169,11 @@ export default function AZScroller({ alphabetSelectorRef.current?.measureInWindow((x, y, width, height) => { // Use the actual layout height to calculate letter positions more accurately if (totalLetters > 0 && layoutHeight > 0) { - // Recalculate letter height based on actual container height - letterHeight.current = layoutHeight / totalLetters + const slot = layoutHeight / totalLetters + // Recalculate per-letter slot height + letterHeight.current = slot + // Use the slot height as the line-height so letters fill the selector vertically + setComputedLineHeight(Math.max(1, Math.floor(slot))) } alphabetSelectorTopY.current = y @@ -186,7 +190,11 @@ export default function AZScroller({ fontSize='$6' textAlign='center' color='$neutral' - lineHeight={'$1'} + style={ + computedLineHeight + ? { lineHeight: computedLineHeight } + : undefined + } userSelect='none' > {letter} diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index f9a10968f..0c1d9573b 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -39,6 +39,8 @@ interface ItemRowProps { navigation?: Pick, 'navigate' | 'dispatch'> queueName?: Queue sortingByReleasedDate?: boolean | undefined + // When true, swap the short and long press handlers for this row + invertPressBehavior?: boolean } /** @@ -60,6 +62,7 @@ function ItemRow({ onLongPress, queueName, sortingByReleasedDate, + invertPressBehavior, }: ItemRowProps): React.JSX.Element { const artworkAreaWidth = useSharedValue(0) @@ -74,49 +77,39 @@ function ItemRow({ const onPressIn = () => warmContext(item) - const handleLongPress = () => { + // Reverse: short press opens context, long press plays track (for Audio) + const handleLongPress = async () => { + if (onPress) await onPress() + else if (item.Type === 'Audio') { + loadNewQueue({ + track: item, + tracklist: [item], + index: 0, + queue: queueName ?? 'Search', + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + } else if (item.Type === 'MusicArtist') { + navigation?.navigate('Artist', { artist: item }) + } else if (item.Type === 'MusicAlbum') { + navigation?.navigate('Album', { album: item }) + } else if (item.Type === 'Playlist') { + navigation?.navigate('Playlist', { playlist: item, canEdit: true }) + } + } + + const onPressCallback = () => { if (onLongPress) onLongPress() - else + else { navigationRef.navigate('Context', { item, navigation, }) + } } - const onPressCallback = async () => { - if (onPress) await onPress() - else - switch (item.Type) { - case 'Audio': { - loadNewQueue({ - track: item, - tracklist: [item], - index: 0, - queue: queueName ?? 'Search', - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - break - } - case 'MusicArtist': { - navigation?.navigate('Artist', { artist: item }) - break - } - - case 'MusicAlbum': { - navigation?.navigate('Album', { album: item }) - break - } - - case 'Playlist': { - navigation?.navigate('Playlist', { playlist: item, canEdit: true }) - break - } - default: { - break - } - } - } + const rowOnPress = invertPressBehavior ? handleLongPress : onPressCallback + const rowOnLongPress = invertPressBehavior ? onPressCallback : handleLongPress const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes @@ -161,8 +154,8 @@ function ItemRow({ width={'100%'} testID={item.Id ? `item-row-${item.Id}` : undefined} onPressIn={onPressIn} - onPress={onPressCallback} - onLongPress={handleLongPress} + onPress={rowOnPress} + onLongPress={rowOnLongPress} animation={'quick'} pressStyle={pressStyle} paddingVertical={'$2'} @@ -196,16 +189,16 @@ function ItemRow({ diff --git a/src/components/Home/index.tsx b/src/components/Home/index.tsx index 18df37158..701d83c47 100644 --- a/src/components/Home/index.tsx +++ b/src/components/Home/index.tsx @@ -1,10 +1,20 @@ import { ScrollView, Platform, RefreshControl } from 'react-native' -import { YStack, getToken, useTheme } from 'tamagui' +import { YStack, getToken, useTheme, XStack } from 'tamagui' import RecentArtists from './helpers/recent-artists' import RecentlyPlayed from './helpers/recently-played' import FrequentArtists from './helpers/frequent-artists' import FrequentlyPlayedTracks from './helpers/frequent-tracks' -import { usePreventRemove } from '@react-navigation/native' +import { usePreventRemove, useNavigation } from '@react-navigation/native' +import { useLayoutEffect } from 'react' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import HomeStackParamList from '../../screens/Home/types' +import Icon from '../Global/components/icon' +import { + useShowRecentArtistsSetting, + useShowRecentlyPlayedSetting, + useShowFrequentArtistsSetting, + useShowFrequentlyPlayedTracksSetting, +} from '../../stores/settings/app' import useHomeQueries from '../../api/mutations/home' import { usePerformanceMonitor } from '../../hooks/use-performance-monitor' import { useIsRestoring } from '@tanstack/react-query' @@ -17,6 +27,29 @@ export function Home(): React.JSX.Element { usePreventRemove(true, () => {}) + const navigation = useNavigation>() + + useLayoutEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + navigation.getParent()?.navigate('SettingsTab')} + /> + ), + headerRight: () => ( + + navigation.navigate('HomeCustomize')} + /> + + ), + }) + }, [navigation]) + usePerformanceMonitor(COMPONENT_NAME, 5) const { isPending: refreshing, mutateAsync: refresh } = useHomeQueries() @@ -45,19 +78,24 @@ export function Home(): React.JSX.Element { } function HomeContent(): React.JSX.Element { + const [showRecentArtists] = useShowRecentArtistsSetting() + const [showRecentlyPlayed] = useShowRecentlyPlayedSetting() + const [showFrequentArtists] = useShowFrequentArtistsSetting() + const [showFrequentlyPlayedTracks] = useShowFrequentlyPlayedTracksSetting() + return ( - + {showRecentArtists && } - + {showRecentlyPlayed && } - + {showFrequentArtists && } - + {showFrequentlyPlayedTracks && } ) } diff --git a/src/components/Library/components/artists-tab.tsx b/src/components/Library/components/artists-tab.tsx index a796ab002..96c0b99a4 100644 --- a/src/components/Library/components/artists-tab.tsx +++ b/src/components/Library/components/artists-tab.tsx @@ -31,6 +31,7 @@ function ArtistsTab(): React.JSX.Element { showAlphabeticalSelector={showAlphabeticalSelector} sortDescending={sortDescending} artistPageParams={artistPageParams} + invertPressBehavior={true} /> ) } diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx index 7030f73fb..878a4938f 100644 --- a/src/components/Player/index.tsx +++ b/src/components/Player/index.tsx @@ -32,7 +32,7 @@ export default function PlayerScreen(): React.JSX.Element { const isAndroid = Platform.OS === 'android' - const { width, height } = useWindowDimensions() + const { width } = useWindowDimensions() const { top, bottom } = useSafeAreaInsets() @@ -94,12 +94,12 @@ export default function PlayerScreen(): React.JSX.Element { * Apple devices get a small amount of margin */ const mainContainerStyle = { - marginTop: isAndroid ? top : getTokenValue('$4'), - marginBottom: bottom + getTokenValue(isAndroid ? '$10' : '$12', 'space'), + paddingTop: isAndroid ? top : top + getTokenValue('$6', 'space'), + paddingBottom: bottom + getTokenValue(isAndroid ? '$12' : '$18', 'space'), } return nowPlaying ? ( - + {/* Swipe feedback icons (topmost overlay) */} @@ -129,10 +129,10 @@ export default function PlayerScreen(): React.JSX.Element { diff --git a/src/components/Queue/index.tsx b/src/components/Queue/index.tsx index f89a1a2d3..0de01d26d 100644 --- a/src/components/Queue/index.tsx +++ b/src/components/Queue/index.tsx @@ -3,16 +3,18 @@ import Track from '../Global/components/Track' import { RootStackParamList } from '../../screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { Text, XStack } from 'tamagui' -import { useLayoutEffect, useRef, useState } from 'react' +import { useLayoutEffect, useRef, useState, useEffect, useCallback } from 'react' import { LayoutChangeEvent, useWindowDimensions } from 'react-native' +import { useRoute } from '@react-navigation/native' import JellifyTrack from '../../types/JellifyTrack' import { useRemoveFromQueue, useRemoveUpcomingTracks, useReorderQueue, useSkip, + useClearHomeQueue, } from '../../hooks/player/callbacks' -import { useCurrentIndex, usePlayerQueueStore, useQueueRef } from '../../stores/player/queue' +import { useCurrentIndex, useQueueRef, usePlayQueue } from '../../stores/player/queue' import Sortable from 'react-native-sortables' import { OrderChangeParams, RenderItemInfo } from 'react-native-sortables/dist/typescript/types' import { useReducedHapticsSetting } from '../../stores/settings/app' @@ -34,9 +36,14 @@ export default function Queue({ }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const playQueue = usePlayerQueueStore.getState().queue + const playQueue = usePlayQueue() const [queue, setQueue] = useState(playQueue) + // Sync local queue state when the reactive store queue updates (e.g. items added) + useEffect(() => { + setQueue(playQueue) + }, [playQueue]) + const currentIndexFromStore = useCurrentIndex() const queueRef = useQueueRef() @@ -44,14 +51,89 @@ export default function Queue({ const removeFromQueue = useRemoveFromQueue() const reorderQueue = useReorderQueue() const skip = useSkip() + const clearHomeQueue = useClearHomeQueue() const scrollableRef = useAnimatedRef() + // Track last known scroll position so we can test if the playing item + // is already inside the center third of the viewport before scrolling. + const lastScrollYRef = useRef(0) + + const ensurePlayingTrackInCenterThird = useCallback( + async (indexOverride?: number) => { + const idxFromStore = currentIndexFromStore + let targetIndex = indexOverride ?? idxFromStore ?? 0 + + // If TrackPlayer knows the active index, prefer that (handles external changes) + try { + const active = await TrackPlayer.getActiveTrackIndex() + if (typeof active === 'number' && !isNaN(active)) targetIndex = active + } catch (e) { + // ignore + } + + if (targetIndex === undefined || targetIndex === null) return + + let attempts = 0 + const maxAttempts = 3 + + const tryScroll = () => { + const rowHeight = rowHeightRef.current ?? lastMeasuredRowHeight ?? TRACK_ITEM_HEIGHT + const itemTop = targetIndex * rowHeight + const itemCenter = itemTop + rowHeight / 2 + const viewportTop = lastScrollYRef.current + const viewportCenterThirdTop = viewportTop + windowHeight / 3 + const viewportCenterThirdBottom = viewportTop + (2 * windowHeight) / 3 + + // If item center already within center third, nothing to do + if ( + itemCenter >= viewportCenterThirdTop && + itemCenter <= viewportCenterThirdBottom + ) { + return + } + + // Otherwise compute a targetY that centers the item + const targetY = Math.max(0, Math.floor(itemCenter - windowHeight / 2)) + const snapOffset = Math.min(120, Math.floor(windowHeight / 6)) + const snapY = Math.max(0, targetY - snapOffset) + + try { + // quick snap near the final position + scrollableRef.current?.scrollTo({ y: snapY, animated: false }) + // then smooth to the exact target shortly after + setTimeout(() => { + try { + scrollableRef.current?.scrollTo({ y: targetY, animated: true }) + } catch (e) { + // ignore + } + }, 30) + } catch (e) { + // ignore + } + + attempts += 1 + if (attempts < maxAttempts) setTimeout(tryScroll, 50) + } + + // Start quickly after layout changes + setTimeout(tryScroll, 20) + }, + [currentIndexFromStore, windowHeight], + ) + const [reducedHaptics] = useReducedHapticsSetting() const { height: windowHeight } = useWindowDimensions() const hasScrolledToCurrentRef = useRef(false) const rowHeightRef = useRef(null) + // Ensure playing track is centered when the current index changes (new track begins) + useEffect(() => { + // fire-and-forget (ensurePlayingTrack handles retries) + ensurePlayingTrackInCenterThird().catch(() => {}) + }, [currentIndexFromStore, ensurePlayingTrackInCenterThird]) + const scrollToCurrentSong = (measuredRowHeight: number) => { if (hasScrolledToCurrentRef.current) return const index = currentIndexFromStore ?? 0 @@ -73,11 +155,13 @@ export default function Queue({ } } + const route = useRoute() + useLayoutEffect(() => { navigation.setOptions({ headerRight: () => { return ( - + Clear @@ -85,15 +169,32 @@ export default function Queue({ name='broom' color='$warning' onPress={async () => { - await removeUpcomingTracks() + const params = route.params as { homeQueue?: boolean } | undefined + if (params?.homeQueue) { + await clearHomeQueue() + } else { + await removeUpcomingTracks() + } setQueue((await TrackPlayer.getQueue()) as JellifyTrack[]) + // After clearing, ensure the playing track is visible in the center third + try { + await ensurePlayingTrackInCenterThird() + } catch (e) { + // ignore + } }} /> ) }, }) - }, []) + }, [ + navigation, + removeUpcomingTracks, + clearHomeQueue, + route.params, + ensurePlayingTrackInCenterThird, + ]) const keyExtractor = (item: JellifyTrack) => `${item.item.Id}` @@ -105,7 +206,16 @@ export default function Queue({ skip(index)} + onTap={async () => { + await skip(index) + + // After skipping, ensure the selected item is within the center third + try { + await ensurePlayingTrackInCenterThird(index) + } catch (e) { + // ignore + } + }} style={{ flexGrow: 1, }} @@ -145,6 +255,10 @@ export default function Queue({ contentInsetAdjustmentBehavior='automatic' contentOffset={contentOffset} ref={scrollableRef} + onScroll={(e) => { + lastScrollYRef.current = e.nativeEvent.contentOffset.y + }} + scrollEventThrottle={16} > { } } +export const useClearHomeQueue = () => { + return async () => { + try { + triggerHaptic('impactMedium') + + const store = usePlayerQueueStore.getState() + const currentIndexFromStore = store.currentIndex + + // If no track is playing, clear the entire queue + if (currentIndexFromStore === undefined || currentIndexFromStore === null) { + store.setUnshuffledQueue([]) + store.setShuffled(false) + store.setQueueRef('Recently Played') + store.setQueue([]) + store.setCurrentTrack(undefined) + store.setCurrentIndex(undefined) + await TrackPlayer.reset() + return + } + + // Prefer the player's active index if available + const activeIndex = await TrackPlayer.getActiveTrackIndex() + const removeUpTo = activeIndex ?? currentIndexFromStore + + if (removeUpTo > 0) { + const indicesToRemove: number[] = [] + for (let i = 0; i < removeUpTo; i++) indicesToRemove.push(i) + if (indicesToRemove.length > 0) await TrackPlayer.remove(indicesToRemove) + } + + const newQueue = await TrackPlayer.getQueue() + store.setQueue(newQueue as JellifyTrack[]) + + if (!newQueue || newQueue.length === 0) { + store.setCurrentTrack(undefined) + store.setCurrentIndex(undefined) + await TrackPlayer.reset() + } else { + const activeAfter = (await TrackPlayer.getActiveTrackIndex()) ?? 0 + store.setCurrentIndex(activeAfter) + store.setCurrentTrack((newQueue as JellifyTrack[])[activeAfter]) + } + } catch (error) { + console.error('[useClearHomeQueue] failed to clear played tracks:', error) + } + } +} + export const useReorderQueue = () => { return async ({ fromIndex, toIndex }: QueueOrderMutation) => { await TrackPlayer.move(fromIndex, toIndex) diff --git a/src/screens/Home/customize.tsx b/src/screens/Home/customize.tsx new file mode 100644 index 000000000..18b9f72f8 --- /dev/null +++ b/src/screens/Home/customize.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useState } from 'react' +import { useNavigation } from '@react-navigation/native' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import SettingsListGroup from '../../components/Settings/components/settings-list-group' +import { SwitchWithLabel } from '../../components/Global/helpers/switch-with-label' +import { Button, XStack } from 'tamagui' +import HomeStackParamList from './types' +import { + useShowRecentArtistsSetting, + useShowRecentlyPlayedSetting, + useShowFrequentArtistsSetting, + useShowFrequentlyPlayedTracksSetting, +} from '../../stores/settings/app' + +export default function HomeCustomize(): React.JSX.Element { + const navigation = useNavigation>() + + // Read current persisted values once + const [initialShowRecentArtists, setShowRecentArtistsStore] = useShowRecentArtistsSetting() + const [initialShowRecentlyPlayed, setShowRecentlyPlayedStore] = useShowRecentlyPlayedSetting() + const [initialShowFrequentArtists, setShowFrequentArtistsStore] = + useShowFrequentArtistsSetting() + const [initialShowFrequentlyPlayedTracks, setShowFrequentlyPlayedTracksStore] = + useShowFrequentlyPlayedTracksSetting() + + // Local (unsaved) state + const [showRecentArtists, setShowRecentArtists] = useState(initialShowRecentArtists) + const [showRecentlyPlayed, setShowRecentlyPlayed] = useState(initialShowRecentlyPlayed) + const [showFrequentArtists, setShowFrequentArtists] = useState( + initialShowFrequentArtists, + ) + const [showFrequentlyPlayedTracks, setShowFrequentlyPlayedTracks] = useState( + initialShowFrequentlyPlayedTracks, + ) + + const handleSave = useCallback(() => { + setShowRecentArtistsStore(showRecentArtists) + setShowRecentlyPlayedStore(showRecentlyPlayed) + setShowFrequentArtistsStore(showFrequentArtists) + setShowFrequentlyPlayedTracksStore(showFrequentlyPlayedTracks) + navigation.goBack() + }, [ + showRecentArtists, + showRecentlyPlayed, + showFrequentArtists, + showFrequentlyPlayedTracks, + setShowRecentArtistsStore, + setShowRecentlyPlayedStore, + setShowFrequentArtistsStore, + setShowFrequentlyPlayedTracksStore, + navigation, + ]) + + const handleCancel = useCallback(() => { + // Discard local changes and go back + navigation.goBack() + }, [navigation]) + + const settingsList = [ + { + title: 'Recent Artists', + iconName: 'account-music', + iconColor: showRecentArtists ? '$primary' : '$borderColor', + subTitle: 'Show recent artists on the Home page', + children: ( + + ), + }, + { + title: 'Recently Played', + iconName: 'history', + iconColor: showRecentlyPlayed ? '$primary' : '$borderColor', + subTitle: 'Show recently played tracks on the Home page', + children: ( + + ), + }, + { + title: 'Frequent Artists', + iconName: 'heart', + iconColor: showFrequentArtists ? '$primary' : '$borderColor', + subTitle: 'Show frequent artists on the Home page', + children: ( + + ), + }, + { + title: 'Frequently Played Tracks', + iconName: 'playlist-play', + iconColor: showFrequentlyPlayedTracks ? '$primary' : '$borderColor', + subTitle: 'Show frequently played tracks on the Home page', + children: ( + + ), + }, + ] + + const footer = ( + + + + + ) + + return +} diff --git a/src/screens/Home/index.tsx b/src/screens/Home/index.tsx index 697ab57d8..a29a9425c 100644 --- a/src/screens/Home/index.tsx +++ b/src/screens/Home/index.tsx @@ -1,10 +1,10 @@ -import _ from 'lodash' import { createNativeStackNavigator } from '@react-navigation/native-stack' import { PlaylistScreen } from '../Playlist' import { Home as HomeComponent } from '../../components/Home' import ArtistScreen from '../Artist' import { getTokenValue, useTheme } from 'tamagui' import HomeArtistsScreen from './artists' +import HomeCustomize from './customize' import HomeTracksScreen from './tracks' import AlbumScreen from '../Album' import HomeStackParamList from './types' @@ -36,6 +36,13 @@ export default function Home(): React.JSX.Element { }, }} /> + ( + + ), + tabBarButtonTestID: 'player-tab-button', + }} + /> + + ( ), - tabBarButtonTestID: 'library-tab-button', + tabBarButtonTestID: 'queue-tab-button', }} /> ( - + tabBarIcon: ({ color, size, focused }) => ( + ), - tabBarButtonTestID: 'search-tab-button', + tabBarButtonTestID: 'library-tab-button', }} /> @@ -97,12 +123,29 @@ export default function Tabs({ route, navigation }: TabProps): React.JSX.Element }} /> + ( + + ), + tabBarButtonTestID: 'search-tab-button', + }} + /> + null, + // Explicit flag for our custom TabBar so it can exclude this route + tabBarVisible: false, tabBarIcon: ({ color, size }) => ( ), diff --git a/src/screens/Tabs/tab-bar.tsx b/src/screens/Tabs/tab-bar.tsx index d79130a3f..3737935f2 100644 --- a/src/screens/Tabs/tab-bar.tsx +++ b/src/screens/Tabs/tab-bar.tsx @@ -46,12 +46,35 @@ export default function TabBar(props: BottomTabBarProps): React.JSX.Element { // (avoids stale styles from Reanimated/Progress when preset changes without interaction) const themeKey = `${theme.background.val}-${theme.primary.val}` + // Filter out routes that explicitly request to be hidden from the tab bar. + const visibleRoutes = props.state.routes.filter((route) => { + const desc = descriptorsWithTheme[route.key] + return desc?.options?.tabBarVisible !== false + }) + + // Map descriptors for visible routes only + const visibleDescriptors = visibleRoutes.reduce((acc, r) => { + if (descriptorsWithTheme[r.key]) acc[r.key] = descriptorsWithTheme[r.key] + return acc + }, {}) + + // Compute new index matching the focused route within the filtered list + const focusedKey = props.state.routes[props.state.index]?.key + let newIndex = visibleRoutes.findIndex((r) => r.key === focusedKey) + if (newIndex === -1) newIndex = 0 + + const filteredState = { + ...props.state, + routes: visibleRoutes, + index: newIndex, + } as BottomTabBarProps['state'] + return ( <> - {isMiniPlayerActive && } + - + ) } diff --git a/src/screens/Tabs/types.d.ts b/src/screens/Tabs/types.d.ts index be69ba4a6..3f12a1136 100644 --- a/src/screens/Tabs/types.d.ts +++ b/src/screens/Tabs/types.d.ts @@ -3,10 +3,12 @@ import { NavigatorScreenParams } from '@react-navigation/native' import LibraryStackParamList from '../Library/types' type TabParamList = { + PlayerTab: undefined HomeTab: undefined LibraryTab: undefined | NavigatorScreenParams SearchTab: undefined DiscoverTab: undefined + QueueTab: undefined SettingsTab: undefined } diff --git a/src/stores/settings/app.ts b/src/stores/settings/app.ts index c556d9a66..8136ea425 100644 --- a/src/stores/settings/app.ts +++ b/src/stores/settings/app.ts @@ -13,6 +13,19 @@ type AppSettingsStore = { hideRunTimes: boolean setHideRunTimes: (hideRunTimes: boolean) => void + // Controls visibility of Home page sections + showRecentArtists: boolean + setShowRecentArtists: (show: boolean) => void + + showRecentlyPlayed: boolean + setShowRecentlyPlayed: (show: boolean) => void + + showFrequentArtists: boolean + setShowFrequentArtists: (show: boolean) => void + + showFrequentlyPlayedTracks: boolean + setShowFrequentlyPlayedTracks: (show: boolean) => void + reducedHaptics: boolean setReducedHaptics: (reducedHaptics: boolean) => void @@ -33,6 +46,20 @@ export const useAppSettingsStore = create()( hideRunTimes: false, setHideRunTimes: (hideRunTimes: boolean) => set({ hideRunTimes }), + // Home section visibility defaults + showRecentArtists: true, + setShowRecentArtists: (show: boolean) => set({ showRecentArtists: show }), + + showRecentlyPlayed: true, + setShowRecentlyPlayed: (show: boolean) => set({ showRecentlyPlayed: show }), + + showFrequentArtists: true, + setShowFrequentArtists: (show: boolean) => set({ showFrequentArtists: show }), + + showFrequentlyPlayedTracks: true, + setShowFrequentlyPlayedTracks: (show: boolean) => + set({ showFrequentlyPlayedTracks: show }), + reducedHaptics: false, setReducedHaptics: (reducedHaptics: boolean) => set({ reducedHaptics }), @@ -87,3 +114,26 @@ export const useSendMetricsSetting: () => [boolean, (sendMetrics: boolean) => vo export const useHideRunTimesSetting: () => [boolean, (hideRunTimes: boolean) => void] = () => useAppSettingsStore(useShallow((state) => [state.hideRunTimes, state.setHideRunTimes])) + +export const useShowRecentArtistsSetting: () => [boolean, (show: boolean) => void] = () => + useAppSettingsStore( + useShallow((state) => [state.showRecentArtists, state.setShowRecentArtists]), + ) + +export const useShowRecentlyPlayedSetting: () => [boolean, (show: boolean) => void] = () => + useAppSettingsStore( + useShallow((state) => [state.showRecentlyPlayed, state.setShowRecentlyPlayed]), + ) + +export const useShowFrequentArtistsSetting: () => [boolean, (show: boolean) => void] = () => + useAppSettingsStore( + useShallow((state) => [state.showFrequentArtists, state.setShowFrequentArtists]), + ) + +export const useShowFrequentlyPlayedTracksSetting: () => [boolean, (show: boolean) => void] = () => + useAppSettingsStore( + useShallow((state) => [ + state.showFrequentlyPlayedTracks, + state.setShowFrequentlyPlayedTracks, + ]), + )