Skalthoff/queueing-up-some-improvements (#647)

* Refactor SwipeableRow to manage menu state with React and enhance gesture handling

* Enhance SwipeableRow to close menu on tap when open and improve zIndex handling for action overlays

* Add artwork area width management and implement SlidingTextArea for better layout handling

* Refactor Track component layout for improved alignment and spacing

* add setting for hiding runtimes on tracks

alignment on song details on a track

* Enhance tap gesture area for SwipeableRow to allow per-row controls

* Update LastUpgradeVersion to 2610 in project files and adjust SwipeableRow behavior for quick-action menus

* Refactor SwipeableRow and related components to improve quick-action menu behavior and artwork visibility during swipes

* Add test IDs for quick actions and item rows to improve testability

* Refactor quick action icons for consistency and remove outdated test files

* feat: enhance SwipeableRow with interactive tap-to-close overlay

---------

Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
This commit is contained in:
skalthoff
2025-11-14 20:48:29 -08:00
committed by GitHub
parent d005c33017
commit 846d169baf
16 changed files with 780 additions and 117 deletions

0
.env.devrelease Normal file
View File

View File

@@ -221,6 +221,7 @@
children = (
1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */,
E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */,
7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -308,7 +309,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2600;
LastUpgradeCheck = 2610;
TargetAttributes = {
00E356ED1AD99517003FC87E = {
CreatedOnToolsVersion = 6.2;
@@ -612,6 +613,13 @@
};
name = Release;
};
47C4374A2EBD5610003A655B /* DevRelease */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_NAME = JellifyTests;
};
name = DevRelease;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -801,6 +809,138 @@
};
name = Release;
};
CFDEVREL001 /* DevRelease */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
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 = 247;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENVFILE = .env.devrelease;
INFOPLIST_FILE = Jellify/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.20.0;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -D DEV_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.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";
VERSIONING_SYSTEM = "apple-generic";
};
name = DevRelease;
};
CFDEVRELPROJ001 /* DevRelease */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
CXX = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = DevRelease;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -809,6 +949,7 @@
buildConfigurations = (
00E356F61AD99517003FC87E /* Debug */,
00E356F71AD99517003FC87E /* Release */,
47C4374A2EBD5610003A655B /* DevRelease */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -818,6 +959,7 @@
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
CFDEVREL001 /* DevRelease */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -827,6 +969,7 @@
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
CFDEVRELPROJ001 /* DevRelease */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
LastUpgradeVersion = "2610"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
LastUpgradeVersion = "2610"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -3,3 +3,4 @@ appId: com.cosmonautical.jellify
- runFlow: ../tests/4-search.yaml
- runFlow: ../tests/5-discover.yaml
- runFlow: ../tests/6-settings.yaml
- runFlow: ../tests/7-quickactions.yaml

View File

@@ -0,0 +1,206 @@
appId: com.cosmonautical.jellify
---
# Quick Actions Swipe Test
# This test validates the quick action menu that appears when swiping on track rows
# The test works with any swipe action configuration
# Start from Home tab
- tapOn:
id: "home-tab-button"
# Wait for content to load
- assertVisible: "Home"
# Navigate to Recently Played full list
- tapOn: "Play it again"
# Wait for track list to load
- assertVisible: "Recently Played"
# Wait a moment for tracks to render
- extendedWaitUntil:
visible:
id: "track-item-0"
timeout: 10000
# Test Right Swipe (Left Quick Actions)
# Swipe right on a track to reveal left-side quick actions
# Using a slower, more deliberate swipe gesture
- swipe:
start: 15%, 30%
end: 90%, 30%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Assert that quick action buttons are visible after swipe
# The exact buttons depend on user settings
# With 1 action configured: immediate action (no buttons)
# With 2+ actions: quick action menu appears
- assertVisible:
id: "quick-action-left-0"
optional: true
# If multiple left actions configured, check for additional buttons
- assertVisible:
id: "quick-action-left-1"
optional: true
- assertVisible:
id: "quick-action-left-2"
optional: true
# If quick actions appeared (multi-action config), tap the first one
- tapOn:
id: "quick-action-left-0"
optional: true
# Wait a moment for the action to complete and menu to close
- waitForAnimationToEnd
# Test Left Swipe (Right Quick Actions)
# Scroll down a bit to get a fresh track
- scroll
# Swipe left on a track to reveal right-side quick actions
# Using a slower, more deliberate swipe gesture
- swipe:
start: 80%, 40%
end: 25%, 40%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Assert that right quick action buttons are visible (if multi-action config)
- assertVisible:
id: "quick-action-right-0"
optional: true
# If multiple actions are configured, verify second and third buttons
- assertVisible:
id: "quick-action-right-1"
optional: true
- assertVisible:
id: "quick-action-right-2"
optional: true
# Tap the first right quick action if it appeared
- tapOn:
id: "quick-action-right-0"
optional: true
# Wait for action to complete
- waitForAnimationToEnd
# Test menu closes when tapping elsewhere
# Swipe to open menu again
# Start further from edge to avoid Android back gesture
- swipe:
start: 80%, 45%
end: 25%, 45%
duration: 300
# Wait for menu to open
# Check if quick action menu appeared (multi-action config)
- assertVisible:
id: "quick-action-right-0"
optional: true
# Tap on the track content area to close the menu (if it opened)
- tapOn:
point: 50%, 45%
# Wait for menu to close
# Verify the menu closed (only relevant if it was open)
- assertNotVisible:
id: "quick-action-right-0"
optional: true
# Navigate to Library to test with different content
- tapOn:
id: "library-tab-button"
# Test swipe actions in Library tab
- tapOn: "Tracks"
# Wait for tracks to load
- assertVisible: "Tracks"
# Scroll to ensure we're not at the top
- scroll
# Test swipe on library tracks
# Using a slower, more deliberate swipe gesture
# Start further from edge to avoid gesture conflicts
- swipe:
start: 15%, 35%
end: 70%, 35%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Verify quick actions appear in Library context too (if multi-action config)
- assertVisible:
id: "quick-action-left-0"
optional: true
# Close the menu by tapping (if it opened)
- tapOn:
point: 50%, 35%
# Wait for menu to close
- waitForAnimationToEnd
# Test quick actions in Search tab
- tapOn:
id: "search-tab-button"
# Wait for search screen
- assertVisible: "Search"
# Type a search query
- tapOn:
text: "Search"
- inputText: "music"
- hideKeyboard
# Wait for search results
- waitForAnimationToEnd
# Scroll down to see more results
- scroll
# Test swipe actions on search results (tracks only have swipe actions)
# Look for a track in results and swipe
# Using a slower, more deliberate swipe gesture
# Start further from edge to avoid Android back gesture
- swipe:
start: 80%, 45%
end: 25%, 45%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Verify quick actions work in search context
- assertVisible:
id: "quick-action-right-0"
optional: true
# If actions appeared, close them
- tapOn:
point: 50%, 40%
optional: true
# Return to home
- tapOn:
id: "home-tab-button"

View File

@@ -212,7 +212,8 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
}}
pressStyle={{ opacity: 0.5 }}
>
<Icon small color='$primary' name='music-note-plus' />
{/* Use same icon as swipe Add to Queue for consistency */}
<Icon small color='$primary' name='playlist-play' />
<Text bold>Play Next</Text>
</ListItem>
@@ -231,7 +232,8 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
}}
pressStyle={{ opacity: 0.5 }}
>
<Icon small color='$primary' name='music-note-plus' />
{/* Consistent Add to Queue icon */}
<Icon small color='$primary' name='playlist-play' />
<Text bold>Add to Queue</Text>
</ListItem>

View File

@@ -6,6 +6,7 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
cancelAnimation,
} from 'react-native-reanimated'
import Icon from './icon'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
@@ -16,6 +17,8 @@ import {
unregisterSwipeableRow,
} from './swipeable-row-registry'
import { scheduleOnRN } from 'react-native-worklets'
import { SwipeableRowProvider } from './swipeable-row-context'
import { Pressable } from 'react-native'
export type SwipeAction = {
label: string
@@ -57,29 +60,40 @@ export default function SwipeableRow({
}: Props) {
const triggerHaptic = useHapticFeedback()
const tx = useSharedValue(0)
const menuOpen = useSharedValue(false)
const dragging = useSharedValue(false)
const idRef = useRef<string | undefined>(undefined)
const menuOpenRef = useRef(false)
// React state for menu open (avoids pointerEvents bug from treating SharedValue object as truthy)
const [isMenuOpen, setIsMenuOpen] = useState(false)
// Shared value mirror for animated children consumers
const menuOpenSV = useSharedValue(false)
const defaultMaxLeft = 120
const defaultMaxRight = -120
const threshold = 80
const [rightActionsWidth, setRightActionsWidth] = useState(0)
const [leftActionsWidth, setLeftActionsWidth] = useState(0)
const ACTION_SIZE = 48
// Compute how far we allow left swipe. If quick actions exist, use their width; else a sane default.
const hasRightSide = !!rightAction || (rightActions && rightActions.length > 0)
const measuredRightWidth =
rightActions && rightActions.length > 0
? rightActionsWidth || rightActions.length * ACTION_SIZE
: 0
const maxRight = hasRightSide
? rightActions && rightActions.length > 0
? -Math.max(0, rightActionsWidth)
? -Math.max(0, measuredRightWidth)
: defaultMaxRight
: 0
// Compute how far we allow right swipe. If quick actions exist on left side, use their width.
const hasLeftSide = !!leftAction || (leftActions && leftActions.length > 0)
const measuredLeftWidth =
leftActions && leftActions.length > 0
? leftActionsWidth || leftActions.length * ACTION_SIZE
: 0
const maxLeft = hasLeftSide
? leftActions && leftActions.length > 0
? Math.max(0, leftActionsWidth)
? Math.max(0, measuredLeftWidth)
: defaultMaxLeft
: 0
@@ -88,22 +102,22 @@ export default function SwipeableRow({
}
const syncClosedState = useCallback(() => {
'worklet'
menuOpenRef.current = false
menuOpen.set(false)
setIsMenuOpen(false)
menuOpenSV.value = false
notifySwipeableRowClosed(idRef.current!)
}, [])
}, [menuOpenSV])
const close = useCallback(() => {
syncClosedState()
cancelAnimation(tx)
tx.value = withTiming(0, { duration: 160, easing: Easing.out(Easing.cubic) })
}, [syncClosedState, tx])
const openMenu = useCallback(() => {
menuOpenRef.current = true
menuOpen.set(true)
setIsMenuOpen(true)
menuOpenSV.value = true
notifySwipeableRowOpened(idRef.current!)
}, [])
}, [menuOpenSV])
useEffect(() => {
registerSwipeableRow(idRef.current!, close)
@@ -112,21 +126,23 @@ export default function SwipeableRow({
}
}, [close])
useEffect(() => {
menuOpenRef.current = menuOpen.value
}, [menuOpen])
// menu open state now handled in React, no SharedValue mirroring required
const fgOpacity = useSharedValue(1.0)
const tapGesture = useMemo(() => {
// Reserve the right edge for per-row controls (e.g. three dots) by shrinking the tap area there
// so those controls can receive presses without being swallowed by the row tap gesture.
return Gesture.Tap()
.runOnJS(true)
.hitSlop({ right: -64 })
.maxDistance(2)
.onBegin(() => {
fgOpacity.set(0.5)
})
.onEnd((e, success) => {
if (onPress && success) {
// If a quick-action menu is open, row-level tap should NOT trigger onPress.
if (!isMenuOpen && onPress && success) {
triggerHaptic('impactLight')
onPress()
}
@@ -134,7 +150,7 @@ export default function SwipeableRow({
.onFinalize(() => {
fgOpacity.set(1.0)
})
}, [onPress])
}, [onPress, isMenuOpen])
const longPressGesture = useMemo(() => {
return Gesture.LongPress()
@@ -170,8 +186,8 @@ export default function SwipeableRow({
*/
left: -50,
})
.activeOffsetX([-10, 10])
.failOffsetY([-10, 10])
.activeOffsetX([-15, 15])
.failOffsetY([-8, 8])
.onBegin(() => {
if (disabled) return
dragging.set(true)
@@ -182,13 +198,17 @@ export default function SwipeableRow({
const next = Math.max(Math.min(e.translationX, maxLeft), maxRight)
tx.value = next
})
.onEnd(() => {
.onEnd((e) => {
if (disabled) return
// Velocity-based assistance: fast flicks open even if displacement below threshold
const v = e.velocityX
const velocityTrigger = 800
if (tx.value > threshold) {
// Right swipe: show left quick actions if provided; otherwise trigger leftAction
if (leftActions && leftActions.length > 0) {
triggerHaptic('impactLight')
// Snap open to expose quick actions, do not auto-trigger
cancelAnimation(tx)
tx.value = withTiming(maxLeft, {
duration: 140,
easing: Easing.out(Easing.cubic),
@@ -197,11 +217,13 @@ export default function SwipeableRow({
return
} else if (leftAction) {
triggerHaptic('impactLight')
cancelAnimation(tx)
tx.value = withTiming(
maxLeft,
{ duration: 140, easing: Easing.out(Easing.cubic) },
() => {
scheduleOnRN(leftAction.onTrigger)
cancelAnimation(tx)
tx.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
@@ -216,6 +238,7 @@ export default function SwipeableRow({
if (rightActions && rightActions.length > 0) {
triggerHaptic('impactLight')
// Snap open to expose quick actions, do not auto-trigger
cancelAnimation(tx)
tx.value = withTiming(maxRight, {
duration: 140,
easing: Easing.out(Easing.cubic),
@@ -224,11 +247,70 @@ export default function SwipeableRow({
return
} else if (rightAction) {
triggerHaptic('impactLight')
cancelAnimation(tx)
tx.value = withTiming(
maxRight,
{ duration: 140, easing: Easing.out(Easing.cubic) },
() => {
scheduleOnRN(rightAction.onTrigger)
cancelAnimation(tx)
tx.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
})
},
)
return
}
}
// Velocity fallback (open quick actions if fast flick even without full displacement)
if (v > velocityTrigger && hasLeftSide) {
if (leftActions && leftActions.length > 0) {
triggerHaptic('impactLight')
cancelAnimation(tx)
tx.value = withTiming(maxLeft, {
duration: 140,
easing: Easing.out(Easing.cubic),
})
openMenu()
return
} else if (leftAction) {
triggerHaptic('impactLight')
cancelAnimation(tx)
tx.value = withTiming(
maxLeft,
{ duration: 140, easing: Easing.out(Easing.cubic) },
() => {
scheduleOnRN(leftAction.onTrigger)
cancelAnimation(tx)
tx.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
})
},
)
return
}
}
if (v < -velocityTrigger && hasRightSide) {
if (rightActions && rightActions.length > 0) {
triggerHaptic('impactLight')
cancelAnimation(tx)
tx.value = withTiming(maxRight, {
duration: 140,
easing: Easing.out(Easing.cubic),
})
openMenu()
return
} else if (rightAction) {
triggerHaptic('impactLight')
cancelAnimation(tx)
tx.value = withTiming(
maxRight,
{ duration: 140, easing: Easing.out(Easing.cubic) },
() => {
scheduleOnRN(rightAction.onTrigger)
cancelAnimation(tx)
tx.value = withTiming(0, {
duration: 160,
easing: Easing.out(Easing.cubic),
@@ -265,36 +347,58 @@ export default function SwipeableRow({
},
],
opacity: withTiming(fgOpacity.value, { easing: Easing.bounce }),
zIndex: 20,
}))
// Keep the tap-to-close overlay anchored to the foreground so quick actions stay interactive
const overlayStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: tx.value,
},
],
}))
const leftUnderlayStyle = useAnimatedStyle(() => {
// Normalize progress to [0,1] with a monotonic denominator to avoid non-monotonic ranges
// when the available swipe distance is smaller than the threshold (e.g., 1 quick action = 48px)
// Normalize progress to [0,1]
const leftMax = maxLeft === 0 ? 1 : maxLeft
const denom = Math.max(1, Math.min(threshold, leftMax))
const progress = Math.min(1, Math.max(0, tx.value / denom))
// Slight ease by capping at 0.9 near threshold and 1.0 when fully open
const opacity = progress < 1 ? progress * 0.9 : 1
return { opacity }
return {
opacity,
// Quick-action buttons should sit visually beneath content
zIndex: leftActions && leftActions.length > 0 ? 5 : 10,
}
})
const rightUnderlayStyle = useAnimatedStyle(() => {
const rightMax = maxRight === 0 ? -1 : maxRight // negative value when available
const rightMax = maxRight === 0 ? -1 : maxRight
const absMax = Math.abs(rightMax)
const denom = Math.max(1, Math.min(threshold, absMax))
const progress = Math.min(1, Math.max(0, -tx.value / denom))
const opacity = progress < 1 ? progress * 0.9 : 1
return { opacity }
return {
opacity,
zIndex: rightActions && rightActions.length > 0 ? 5 : 10,
}
})
if (disabled) return <>{children}</>
const combinedGesture = Gesture.Race(panGesture, longPressGesture, tapGesture)
return (
<GestureDetector gesture={Gesture.Simultaneous(tapGesture, longPressGesture, panGesture)}>
<GestureDetector gesture={combinedGesture}>
<YStack position='relative' overflow='hidden'>
{/* Left action underlay with colored background (icon-only) */}
{leftAction && !leftActions && (
<Animated.View
style={[
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
leftUnderlayStyle,
]}
pointerEvents='none'
@@ -315,10 +419,16 @@ export default function SwipeableRow({
{leftActions && leftActions.length > 0 && (
<Animated.View
style={[
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
{
position: 'absolute',
left: 0,
width: measuredLeftWidth,
top: 0,
bottom: 0,
},
leftUnderlayStyle,
]}
pointerEvents={menuOpen ? 'auto' : 'none'}
pointerEvents={isMenuOpen ? 'auto' : 'none'}
>
{/* Underlay background matches list background for continuity */}
<XStack
@@ -337,6 +447,9 @@ export default function SwipeableRow({
{leftActions.map((action, idx) => (
<XStack
key={`left-quick-action-${idx}`}
// Maestro test id for left quick action button
// pattern: quick-action-left-<index>
testID={`quick-action-left-${idx}`}
width={48}
height={48}
alignItems='center'
@@ -344,6 +457,8 @@ export default function SwipeableRow({
backgroundColor={action.color}
borderRadius={0}
pressStyle={{ opacity: 0.8 }}
accessibilityRole='button'
accessibilityLabel={`Left quick action ${action.icon}`}
onPress={() => {
action.onPress()
close()
@@ -361,7 +476,13 @@ export default function SwipeableRow({
{rightAction && !rightActions && (
<Animated.View
style={[
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
rightUnderlayStyle,
]}
pointerEvents='none'
@@ -383,10 +504,16 @@ export default function SwipeableRow({
{rightActions && rightActions.length > 0 && (
<Animated.View
style={[
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
{
position: 'absolute',
right: 0,
width: Math.abs(measuredRightWidth),
top: 0,
bottom: 0,
},
rightUnderlayStyle,
]}
pointerEvents={menuOpen ? 'auto' : 'none'}
pointerEvents={isMenuOpen ? 'auto' : 'none'}
>
{/* Underlay background matches list background to keep continuity */}
<XStack
@@ -405,6 +532,7 @@ export default function SwipeableRow({
{rightActions.map((action, idx) => (
<XStack
key={`quick-action-${idx}`}
testID={`quick-action-right-${idx}`}
width={48}
height={48}
alignItems='center'
@@ -412,6 +540,8 @@ export default function SwipeableRow({
backgroundColor={action.color}
borderRadius={0}
pressStyle={{ opacity: 0.8 }}
accessibilityRole='button'
accessibilityLabel={`Right quick action ${action.icon}`}
onPress={() => {
action.onPress()
close()
@@ -425,25 +555,48 @@ export default function SwipeableRow({
</Animated.View>
)}
{/* Foreground content */}
<Animated.View
style={fgStyle}
pointerEvents={dragging ? 'none' : 'auto'}
accessibilityHint={leftAction || rightAction ? 'Swipe for actions' : undefined}
{/* Foreground content (provider wraps children to expose tx & menu open shared value) */}
<SwipeableRowProvider
value={{
tx,
menuOpenSV,
leftWidth: measuredLeftWidth,
rightWidth: Math.abs(measuredRightWidth),
}}
>
{children}
</Animated.View>
<Animated.View
style={fgStyle}
// Disable touches only while a menu is open; allow presses when closed
pointerEvents={isMenuOpen ? 'none' : 'auto'}
accessibilityHint={
leftAction || rightAction ? 'Swipe for actions' : undefined
}
>
{children}
</Animated.View>
</SwipeableRowProvider>
{/* Tap-capture overlay: when a quick-action menu is open, tapping the row closes it without triggering child onPress */}
<XStack
position='absolute'
left={0}
right={0}
top={0}
bottom={0}
pointerEvents={menuOpen ? 'auto' : 'none'}
onPress={close}
/>
{/* Tap-capture overlay: sits above foreground, below action buttons */}
<Animated.View
style={[
{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
zIndex: 30,
},
overlayStyle,
]}
pointerEvents={isMenuOpen ? 'auto' : 'none'}
>
<Pressable
style={{ flex: 1 }}
onPress={close}
pointerEvents={isMenuOpen ? 'auto' : 'none'}
/>
</Animated.View>
</YStack>
</GestureDetector>
)

View File

@@ -15,6 +15,8 @@ import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native'
import { useCallback } from 'react'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useSwipeableRowContext } from './swipeable-row-context'
import SwipeableRow from './SwipeableRow'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
import { buildSwipeConfig } from '../helpers/swipe-actions'
@@ -46,6 +48,7 @@ export default function ItemRow({
circular,
navigation,
onPress,
queueName,
}: ItemRowProps): React.JSX.Element {
const api = useApi()
@@ -157,6 +160,7 @@ export default function ItemRow({
alignContent='center'
minHeight={'$7'}
width={'100%'}
testID={item.Id ? `item-row-${item.Id}` : undefined}
onPressIn={onPressIn}
onPress={onPressCallback}
onLongPress={onLongPress}
@@ -165,16 +169,8 @@ export default function ItemRow({
paddingVertical={'$2'}
paddingRight={'$2'}
>
<YStack marginHorizontal={'$3'} justifyContent='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</YStack>
<ItemRowDetails item={item} />
<HideableArtwork item={item} circular={circular} />
<StickyText item={item} />
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
{renderRunTime ? (
@@ -237,3 +233,40 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
</YStack>
)
}
// Artwork wrapper that fades out when the quick-action menu is open
function HideableArtwork({
item,
circular,
}: {
item: BaseItemDto
circular?: boolean
}): React.JSX.Element {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
const style = useAnimatedStyle(() => ({
opacity: tx.value === 0 ? 1 : 0,
}))
return (
<Animated.View style={style}>
<YStack marginHorizontal={'$3'} justifyContent='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</YStack>
</Animated.View>
)
}
// Text/details remain visible. No counter-translation needed now that underlays are width-bound.
function StickyText({ item }: { item: BaseItemDto }): React.JSX.Element {
const style = useAnimatedStyle(() => ({}))
return (
<Animated.View style={[style, { flex: 5 }]}>
<ItemRowDetails item={item} />
</Animated.View>
)
}

View File

@@ -0,0 +1,34 @@
import React, { createContext, useContext } from 'react'
import { SharedValue } from 'react-native-reanimated'
type SwipeableRowContextValue = {
tx: SharedValue<number>
menuOpenSV: SharedValue<boolean>
leftWidth: number
rightWidth: number
}
// Provide benign defaults so consuming hooks don't crash outside provider
const defaultShared: SharedValue<number> = { value: 0 } as SharedValue<number>
const defaultBool: SharedValue<boolean> = { value: false } as SharedValue<boolean>
const SwipeableRowContext = createContext<SwipeableRowContextValue>({
tx: defaultShared,
menuOpenSV: defaultBool,
leftWidth: 0,
rightWidth: 0,
})
export function SwipeableRowProvider({
children,
value,
}: {
children: React.ReactNode
value: SwipeableRowContextValue
}) {
return <SwipeableRowContext.Provider value={value}>{children}</SwipeableRowContext.Provider>
}
export function useSwipeableRowContext(): SwipeableRowContextValue {
return useContext(SwipeableRowContext)
}

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import React, { useMemo, useCallback, useState } from 'react'
import { getToken, Spacer, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -14,6 +14,7 @@ import navigationRef from '../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../screens/types'
import ItemImage from './image'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media'
@@ -26,7 +27,8 @@ import { useApi } from '../../../stores'
import { useCurrentTrack, usePlayQueue } from '../../../stores/player/queue'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { StackActions } from '@react-navigation/native'
import { TouchableOpacity } from 'react-native'
import { useSwipeableRowContext } from './swipeable-row-context'
import { useHideRunTimesSetting } from '../../../stores/settings/app'
export interface TrackProps {
track: BaseItemDto
@@ -62,11 +64,14 @@ export default function Track({
onRemove,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const api = useApi()
const deviceProfile = useStreamingDeviceProfile()
const [hideRunTimes] = useHideRunTimesSetting()
const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
@@ -212,6 +217,27 @@ export default function Track({
[leftSettings, rightSettings, swipeHandlers],
)
const runtimeComponent = useMemo(
() =>
hideRunTimes ? (
<></>
) : (
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
),
[hideRunTimes, track.RunTimeTicks],
)
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow
@@ -227,8 +253,8 @@ export default function Track({
flex={1}
testID={testID ?? undefined}
paddingVertical={'$2'}
justifyContent='center'
marginRight={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
@@ -243,9 +269,12 @@ export default function Track({
alignContent='center'
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
>
{showArtwork ? (
<ItemImage item={track} width={'$12'} height={'$12'} />
<HideableArtwork>
<ItemImage item={track} width={'$12'} height={'$12'} />
</HideableArtwork>
) : (
<Text
key={`${track.Id}-number`}
@@ -259,53 +288,75 @@ export default function Track({
)}
</XStack>
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={6}>
<Text
key={`${track.Id}-artists`}
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
>
{artistsText}
{trackName}
</Text>
)}
</YStack>
<DownloadedIcon item={track} />
{shouldShowArtists && (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
>
{artistsText}
</Text>
)}
</YStack>
</SlidingTextArea>
<FavoriteIcon item={track} />
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'center',
flex: 1.5,
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
flex={1}
onPress={handleIconPress}
/>
<XStack justifyContent='flex-end' alignItems='center' flex={2} gap='$1'>
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
onPress={handleIconPress}
/>
</XStack>
</XStack>
</SwipeableRow>
</Theme>
)
}
function HideableArtwork({ children }: { children: React.ReactNode }) {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
const style = useAnimatedStyle(() => ({ opacity: tx.value === 0 ? 1 : 0 }))
return <Animated.View style={style}>{children}</Animated.View>
}
function SlidingTextArea({
leftGapWidth,
hasArtwork,
children,
}: {
leftGapWidth: number
hasArtwork: boolean
children: React.ReactNode
}) {
const { tx, rightWidth } = useSwipeableRowContext()
const style = useAnimatedStyle(() => {
const t = tx.value
let offset = 0
if (t > 0 && hasArtwork) {
// Swiping right: row content moves right; pull text left up to artwork width to fill the gap
offset = -Math.min(t, Math.max(0, leftGapWidth))
} else if (t < 0) {
// Swiping left: row content moves left; push text right a bit to keep it visible
const compensate = Math.min(-t, Math.max(0, rightWidth))
offset = compensate * 0.7
}
return { transform: [{ translateX: offset }] }
})
return <Animated.View style={[{ flex: 5 }, style]}>{children}</Animated.View>
}

View File

@@ -18,7 +18,8 @@ function toSwipeAction(type: SwipeActionType, handlers: SwipeHandlers): SwipeAct
case 'AddToQueue':
return {
label: 'Add to queue',
icon: 'playlist-plus',
// Use a distinct icon from Add to Playlist to avoid confusion
icon: 'playlist-play',
color: '$success',
onTrigger: handlers.addToQueue,
}
@@ -44,7 +45,8 @@ function toQuickAction(type: SwipeActionType, handlers: SwipeHandlers): QuickAct
switch (type) {
case 'AddToQueue':
return {
icon: 'playlist-plus',
// Distinct icon for Add to Queue quick action
icon: 'playlist-play',
color: '$success',
onPress: handlers.addToQueue,
}

View File

@@ -4,6 +4,7 @@ import SettingsListGroup from './settings-list-group'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import {
ThemeSetting,
useHideRunTimesSetting,
useReducedHapticsSetting,
useSendMetricsSetting,
useThemeSetting,
@@ -17,6 +18,9 @@ export default function PreferencesTab(): React.JSX.Element {
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
const [themeSetting, setThemeSetting] = useThemeSetting()
const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting()
const left = useSwipeSettingsStore((s) => s.left)
const right = useSwipeSettingsStore((s) => s.right)
const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft)
@@ -27,13 +31,16 @@ export default function PreferencesTab(): React.JSX.Element {
label,
icon,
onPress,
testID,
}: {
active: boolean
label: string
icon: string
onPress: () => void
testID?: string
}) => (
<Button
testID={testID}
pressStyle={{
backgroundColor: '$neutral',
}}
@@ -113,18 +120,21 @@ export default function PreferencesTab(): React.JSX.Element {
<SizableText size={'$3'}>Swipe Left</SizableText>
<XStack gap={'$2'} flexWrap='wrap'>
<ActionChip
testID='swipe-left-favorite-toggle'
active={left.includes('ToggleFavorite')}
label='Favorite'
icon='heart'
onPress={() => toggleLeft('ToggleFavorite')}
/>
<ActionChip
testID='swipe-left-playlist-toggle'
active={left.includes('AddToPlaylist')}
label='Add to Playlist'
icon='playlist-plus'
onPress={() => toggleLeft('AddToPlaylist')}
/>
<ActionChip
testID='swipe-left-queue-toggle'
active={left.includes('AddToQueue')}
label='Add to Queue'
icon='playlist-play'
@@ -136,18 +146,21 @@ export default function PreferencesTab(): React.JSX.Element {
<SizableText size={'$3'}>Swipe Right</SizableText>
<XStack gap={'$2'} flexWrap='wrap'>
<ActionChip
testID='swipe-right-favorite-toggle'
active={right.includes('ToggleFavorite')}
label='Favorite'
icon='heart'
onPress={() => toggleRight('ToggleFavorite')}
/>
<ActionChip
testID='swipe-right-playlist-toggle'
active={right.includes('AddToPlaylist')}
label='Add to Playlist'
icon='playlist-plus'
onPress={() => toggleRight('AddToPlaylist')}
/>
<ActionChip
testID='swipe-right-queue-toggle'
active={right.includes('AddToQueue')}
label='Add to Queue'
icon='playlist-play'
@@ -159,6 +172,20 @@ export default function PreferencesTab(): React.JSX.Element {
</YStack>
),
},
{
title: 'Hide Runtimes',
iconName: 'clock-digital',
iconColor: hideRunTimes ? '$success' : '$borderColor',
subTitle: 'Hides track runtime lengths',
children: (
<SwitchWithLabel
checked={hideRunTimes}
onCheckedChange={setHideRunTimes}
size={'$2'}
label={hideRunTimes ? 'Hidden' : 'Shown'}
/>
),
},
{
title: 'Reduce Haptics',
iconName: reducedHaptics ? 'vibrate-off' : 'vibrate',
@@ -169,7 +196,7 @@ export default function PreferencesTab(): React.JSX.Element {
checked={reducedHaptics}
onCheckedChange={setReducedHaptics}
size={'$2'}
label={reducedHaptics ? 'Enabled' : 'Disabled'}
label={reducedHaptics ? 'Reduced' : 'Disabled'}
/>
),
},
@@ -183,7 +210,7 @@ export default function PreferencesTab(): React.JSX.Element {
checked={sendMetrics}
onCheckedChange={setSendMetrics}
size={'$2'}
label={sendMetrics ? 'Enabled' : 'Disabled'}
label={sendMetrics ? 'Sending' : 'Disabled'}
/>
),
},

View File

@@ -70,7 +70,7 @@ export default function Tracks({
* play that exact track, since the index was offset by the headings
*/
const renderItem = useCallback(
({ item: track }: { index: number; item: string | number | BaseItemDto }) =>
({ item: track, index }: { index: number; item: string | number | BaseItemDto }) =>
typeof track === 'string' ? (
<FlashListStickyHeader text={track.toUpperCase()} />
) : typeof track === 'number' ? null : typeof track === 'object' ? (
@@ -79,6 +79,7 @@ export default function Tracks({
showArtwork
index={0}
track={track}
testID={`track-item-${index}`}
tracklist={tracksToDisplay.slice(
tracksToDisplay.indexOf(track),
tracksToDisplay.indexOf(track) + 50,

View File

@@ -1,6 +1,7 @@
import { mmkvStateStorage } from '../../constants/storage'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
export type ThemeSetting = 'system' | 'light' | 'dark' | 'oled'
@@ -8,6 +9,9 @@ type AppSettingsStore = {
sendMetrics: boolean
setSendMetrics: (sendMetrics: boolean) => void
hideRunTimes: boolean
setHideRunTimes: (hideRunTimes: boolean) => void
reducedHaptics: boolean
setReducedHaptics: (reducedHaptics: boolean) => void
@@ -18,15 +22,18 @@ type AppSettingsStore = {
export const useAppSettingsStore = create<AppSettingsStore>()(
devtools(
persist(
(set) => ({
(set): AppSettingsStore => ({
sendMetrics: false,
setSendMetrics: (sendMetrics) => set({ sendMetrics }),
setSendMetrics: (sendMetrics: boolean) => set({ sendMetrics }),
hideRunTimes: false,
setHideRunTimes: (hideRunTimes: boolean) => set({ hideRunTimes }),
reducedHaptics: false,
setReducedHaptics: (reducedHaptics) => set({ reducedHaptics }),
setReducedHaptics: (reducedHaptics: boolean) => set({ reducedHaptics }),
theme: 'system',
setTheme: (theme) => set({ theme }),
setTheme: (theme: ThemeSetting) => set({ theme }),
}),
{
name: 'app-settings-storage',
@@ -58,3 +65,6 @@ export const useSendMetricsSetting: () => [boolean, (sendMetrics: boolean) => vo
return [sendMetrics, setSendMetrics]
}
export const useHideRunTimesSetting: () => [boolean, (hideRunTimes: boolean) => void] = () =>
useAppSettingsStore(useShallow((state) => [state.hideRunTimes, state.setHideRunTimes]))