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 = ( children = (
1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */, 1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */,
E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */, E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */,
7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -308,7 +309,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2600; LastUpgradeCheck = 2610;
TargetAttributes = { TargetAttributes = {
00E356ED1AD99517003FC87E = { 00E356ED1AD99517003FC87E = {
CreatedOnToolsVersion = 6.2; CreatedOnToolsVersion = 6.2;
@@ -612,6 +613,13 @@
}; };
name = Release; name = Release;
}; };
47C4374A2EBD5610003A655B /* DevRelease */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_NAME = JellifyTests;
};
name = DevRelease;
};
83CBBA201A601CBA00E9B192 /* Debug */ = { 83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -801,6 +809,138 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -809,6 +949,7 @@
buildConfigurations = ( buildConfigurations = (
00E356F61AD99517003FC87E /* Debug */, 00E356F61AD99517003FC87E /* Debug */,
00E356F71AD99517003FC87E /* Release */, 00E356F71AD99517003FC87E /* Release */,
47C4374A2EBD5610003A655B /* DevRelease */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@@ -818,6 +959,7 @@
buildConfigurations = ( buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */, 13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */, 13B07F951A680F5B00A75B9A /* Release */,
CFDEVREL001 /* DevRelease */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@@ -827,6 +969,7 @@
buildConfigurations = ( buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */, 83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */, 83CBBA211A601CBA00E9B192 /* Release */,
CFDEVRELPROJ001 /* DevRelease */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;

View File

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

View File

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

View File

@@ -3,3 +3,4 @@ appId: com.cosmonautical.jellify
- runFlow: ../tests/4-search.yaml - runFlow: ../tests/4-search.yaml
- runFlow: ../tests/5-discover.yaml - runFlow: ../tests/5-discover.yaml
- runFlow: ../tests/6-settings.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 }} 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> <Text bold>Play Next</Text>
</ListItem> </ListItem>
@@ -231,7 +232,8 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
}} }}
pressStyle={{ opacity: 0.5 }} 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> <Text bold>Add to Queue</Text>
</ListItem> </ListItem>

View File

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

View File

@@ -15,6 +15,8 @@ import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context' import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native' import { RouteProp, useRoute } from '@react-navigation/native'
import { useCallback } from 'react' import { useCallback } from 'react'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useSwipeableRowContext } from './swipeable-row-context'
import SwipeableRow from './SwipeableRow' import SwipeableRow from './SwipeableRow'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe' import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
import { buildSwipeConfig } from '../helpers/swipe-actions' import { buildSwipeConfig } from '../helpers/swipe-actions'
@@ -46,6 +48,7 @@ export default function ItemRow({
circular, circular,
navigation, navigation,
onPress, onPress,
queueName,
}: ItemRowProps): React.JSX.Element { }: ItemRowProps): React.JSX.Element {
const api = useApi() const api = useApi()
@@ -157,6 +160,7 @@ export default function ItemRow({
alignContent='center' alignContent='center'
minHeight={'$7'} minHeight={'$7'}
width={'100%'} width={'100%'}
testID={item.Id ? `item-row-${item.Id}` : undefined}
onPressIn={onPressIn} onPressIn={onPressIn}
onPress={onPressCallback} onPress={onPressCallback}
onLongPress={onLongPress} onLongPress={onLongPress}
@@ -165,16 +169,8 @@ export default function ItemRow({
paddingVertical={'$2'} paddingVertical={'$2'}
paddingRight={'$2'} paddingRight={'$2'}
> >
<YStack marginHorizontal={'$3'} justifyContent='center'> <HideableArtwork item={item} circular={circular} />
<ItemImage <StickyText item={item} />
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</YStack>
<ItemRowDetails item={item} />
<XStack justifyContent='flex-end' alignItems='center' flex={2}> <XStack justifyContent='flex-end' alignItems='center' flex={2}>
{renderRunTime ? ( {renderRunTime ? (
@@ -237,3 +233,40 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
</YStack> </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 React, { useMemo, useCallback, useState } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui' import { getToken, Spacer, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text' import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes' import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' 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 { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../screens/types' import { BaseStackParamList } from '../../../screens/types'
import ItemImage from './image' import ItemImage from './image'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations' import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media' import useStreamedMediaInfo from '../../../api/queries/media'
@@ -26,7 +27,8 @@ import { useApi } from '../../../stores'
import { useCurrentTrack, usePlayQueue } from '../../../stores/player/queue' import { useCurrentTrack, usePlayQueue } from '../../../stores/player/queue'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite' import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { StackActions } from '@react-navigation/native' 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 { export interface TrackProps {
track: BaseItemDto track: BaseItemDto
@@ -62,11 +64,14 @@ export default function Track({
onRemove, onRemove,
}: TrackProps): React.JSX.Element { }: TrackProps): React.JSX.Element {
const theme = useTheme() const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const api = useApi() const api = useApi()
const deviceProfile = useStreamingDeviceProfile() const deviceProfile = useStreamingDeviceProfile()
const [hideRunTimes] = useHideRunTimesSetting()
const nowPlaying = useCurrentTrack() const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue() const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
@@ -212,6 +217,27 @@ export default function Track({
[leftSettings, rightSettings, swipeHandlers], [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 ( return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}> <Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow <SwipeableRow
@@ -227,8 +253,8 @@ export default function Track({
flex={1} flex={1}
testID={testID ?? undefined} testID={testID ?? undefined}
paddingVertical={'$2'} paddingVertical={'$2'}
justifyContent='center' justifyContent='flex-start'
marginRight={'$2'} paddingRight={'$2'}
animation={'quick'} animation={'quick'}
pressStyle={{ opacity: 0.5 }} pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'} backgroundColor={'$background'}
@@ -243,9 +269,12 @@ export default function Track({
alignContent='center' alignContent='center'
justifyContent='center' justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'} marginHorizontal={showArtwork ? '$2' : '$1'}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
> >
{showArtwork ? ( {showArtwork ? (
<ItemImage item={track} width={'$12'} height={'$12'} /> <HideableArtwork>
<ItemImage item={track} width={'$12'} height={'$12'} />
</HideableArtwork>
) : ( ) : (
<Text <Text
key={`${track.Id}-number`} key={`${track.Id}-number`}
@@ -259,53 +288,75 @@ export default function Track({
)} )}
</XStack> </XStack>
<YStack alignContent='center' justifyContent='flex-start' flex={6}> <SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<Text <YStack alignItems='flex-start' justifyContent='center' flex={6}>
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<Text <Text
key={`${track.Id}-artists`} key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard' lineBreakStrategyIOS='standard'
numberOfLines={1} numberOfLines={1}
color={'$borderColor'}
> >
{artistsText} {trackName}
</Text> </Text>
)}
</YStack>
<DownloadedIcon item={track} /> {shouldShowArtists && (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
>
{artistsText}
</Text>
)}
</YStack>
</SlidingTextArea>
<FavoriteIcon item={track} /> <XStack justifyContent='flex-end' alignItems='center' flex={2} gap='$1'>
<DownloadedIcon item={track} />
<RunTimeTicks <FavoriteIcon item={track} />
key={`${track.Id}-runtime`} {runtimeComponent}
props={{ <Icon
style: { name={showRemove ? 'close' : 'dots-horizontal'}
textAlign: 'center', onPress={handleIconPress}
flex: 1.5, />
alignSelf: 'center', </XStack>
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
flex={1}
onPress={handleIconPress}
/>
</XStack> </XStack>
</SwipeableRow> </SwipeableRow>
</Theme> </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': case 'AddToQueue':
return { return {
label: 'Add to queue', label: 'Add to queue',
icon: 'playlist-plus', // Use a distinct icon from Add to Playlist to avoid confusion
icon: 'playlist-play',
color: '$success', color: '$success',
onTrigger: handlers.addToQueue, onTrigger: handlers.addToQueue,
} }
@@ -44,7 +45,8 @@ function toQuickAction(type: SwipeActionType, handlers: SwipeHandlers): QuickAct
switch (type) { switch (type) {
case 'AddToQueue': case 'AddToQueue':
return { return {
icon: 'playlist-plus', // Distinct icon for Add to Queue quick action
icon: 'playlist-play',
color: '$success', color: '$success',
onPress: handlers.addToQueue, 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 { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { import {
ThemeSetting, ThemeSetting,
useHideRunTimesSetting,
useReducedHapticsSetting, useReducedHapticsSetting,
useSendMetricsSetting, useSendMetricsSetting,
useThemeSetting, useThemeSetting,
@@ -17,6 +18,9 @@ export default function PreferencesTab(): React.JSX.Element {
const [sendMetrics, setSendMetrics] = useSendMetricsSetting() const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting() const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
const [themeSetting, setThemeSetting] = useThemeSetting() const [themeSetting, setThemeSetting] = useThemeSetting()
const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting()
const left = useSwipeSettingsStore((s) => s.left) const left = useSwipeSettingsStore((s) => s.left)
const right = useSwipeSettingsStore((s) => s.right) const right = useSwipeSettingsStore((s) => s.right)
const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft) const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft)
@@ -27,13 +31,16 @@ export default function PreferencesTab(): React.JSX.Element {
label, label,
icon, icon,
onPress, onPress,
testID,
}: { }: {
active: boolean active: boolean
label: string label: string
icon: string icon: string
onPress: () => void onPress: () => void
testID?: string
}) => ( }) => (
<Button <Button
testID={testID}
pressStyle={{ pressStyle={{
backgroundColor: '$neutral', backgroundColor: '$neutral',
}} }}
@@ -113,18 +120,21 @@ export default function PreferencesTab(): React.JSX.Element {
<SizableText size={'$3'}>Swipe Left</SizableText> <SizableText size={'$3'}>Swipe Left</SizableText>
<XStack gap={'$2'} flexWrap='wrap'> <XStack gap={'$2'} flexWrap='wrap'>
<ActionChip <ActionChip
testID='swipe-left-favorite-toggle'
active={left.includes('ToggleFavorite')} active={left.includes('ToggleFavorite')}
label='Favorite' label='Favorite'
icon='heart' icon='heart'
onPress={() => toggleLeft('ToggleFavorite')} onPress={() => toggleLeft('ToggleFavorite')}
/> />
<ActionChip <ActionChip
testID='swipe-left-playlist-toggle'
active={left.includes('AddToPlaylist')} active={left.includes('AddToPlaylist')}
label='Add to Playlist' label='Add to Playlist'
icon='playlist-plus' icon='playlist-plus'
onPress={() => toggleLeft('AddToPlaylist')} onPress={() => toggleLeft('AddToPlaylist')}
/> />
<ActionChip <ActionChip
testID='swipe-left-queue-toggle'
active={left.includes('AddToQueue')} active={left.includes('AddToQueue')}
label='Add to Queue' label='Add to Queue'
icon='playlist-play' icon='playlist-play'
@@ -136,18 +146,21 @@ export default function PreferencesTab(): React.JSX.Element {
<SizableText size={'$3'}>Swipe Right</SizableText> <SizableText size={'$3'}>Swipe Right</SizableText>
<XStack gap={'$2'} flexWrap='wrap'> <XStack gap={'$2'} flexWrap='wrap'>
<ActionChip <ActionChip
testID='swipe-right-favorite-toggle'
active={right.includes('ToggleFavorite')} active={right.includes('ToggleFavorite')}
label='Favorite' label='Favorite'
icon='heart' icon='heart'
onPress={() => toggleRight('ToggleFavorite')} onPress={() => toggleRight('ToggleFavorite')}
/> />
<ActionChip <ActionChip
testID='swipe-right-playlist-toggle'
active={right.includes('AddToPlaylist')} active={right.includes('AddToPlaylist')}
label='Add to Playlist' label='Add to Playlist'
icon='playlist-plus' icon='playlist-plus'
onPress={() => toggleRight('AddToPlaylist')} onPress={() => toggleRight('AddToPlaylist')}
/> />
<ActionChip <ActionChip
testID='swipe-right-queue-toggle'
active={right.includes('AddToQueue')} active={right.includes('AddToQueue')}
label='Add to Queue' label='Add to Queue'
icon='playlist-play' icon='playlist-play'
@@ -159,6 +172,20 @@ export default function PreferencesTab(): React.JSX.Element {
</YStack> </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', title: 'Reduce Haptics',
iconName: reducedHaptics ? 'vibrate-off' : 'vibrate', iconName: reducedHaptics ? 'vibrate-off' : 'vibrate',
@@ -169,7 +196,7 @@ export default function PreferencesTab(): React.JSX.Element {
checked={reducedHaptics} checked={reducedHaptics}
onCheckedChange={setReducedHaptics} onCheckedChange={setReducedHaptics}
size={'$2'} size={'$2'}
label={reducedHaptics ? 'Enabled' : 'Disabled'} label={reducedHaptics ? 'Reduced' : 'Disabled'}
/> />
), ),
}, },
@@ -183,7 +210,7 @@ export default function PreferencesTab(): React.JSX.Element {
checked={sendMetrics} checked={sendMetrics}
onCheckedChange={setSendMetrics} onCheckedChange={setSendMetrics}
size={'$2'} 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 * play that exact track, since the index was offset by the headings
*/ */
const renderItem = useCallback( const renderItem = useCallback(
({ item: track }: { index: number; item: string | number | BaseItemDto }) => ({ item: track, index }: { index: number; item: string | number | BaseItemDto }) =>
typeof track === 'string' ? ( typeof track === 'string' ? (
<FlashListStickyHeader text={track.toUpperCase()} /> <FlashListStickyHeader text={track.toUpperCase()} />
) : typeof track === 'number' ? null : typeof track === 'object' ? ( ) : typeof track === 'number' ? null : typeof track === 'object' ? (
@@ -79,6 +79,7 @@ export default function Tracks({
showArtwork showArtwork
index={0} index={0}
track={track} track={track}
testID={`track-item-${index}`}
tracklist={tracksToDisplay.slice( tracklist={tracksToDisplay.slice(
tracksToDisplay.indexOf(track), tracksToDisplay.indexOf(track),
tracksToDisplay.indexOf(track) + 50, tracksToDisplay.indexOf(track) + 50,

View File

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