mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-21 05:20:06 -06:00
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:
0
.env.devrelease
Normal file
0
.env.devrelease
Normal 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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
206
maestro/tests/6-quickactions.yaml
Normal file
206
maestro/tests/6-quickactions.yaml
Normal 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"
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
34
src/components/Global/components/swipeable-row-context.tsx
Normal file
34
src/components/Global/components/swipeable-row-context.tsx
Normal 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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
|||||||
Reference in New Issue
Block a user