mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-16 18:55:44 -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 = (
|
||||
1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */,
|
||||
E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */,
|
||||
7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -308,7 +309,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2600;
|
||||
LastUpgradeCheck = 2610;
|
||||
TargetAttributes = {
|
||||
00E356ED1AD99517003FC87E = {
|
||||
CreatedOnToolsVersion = 6.2;
|
||||
@@ -612,6 +613,13 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
47C4374A2EBD5610003A655B /* DevRelease */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
PRODUCT_NAME = JellifyTests;
|
||||
};
|
||||
name = DevRelease;
|
||||
};
|
||||
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -801,6 +809,138 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CFDEVREL001 /* DevRelease */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 247;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENVFILE = .env.devrelease;
|
||||
INFOPLIST_FILE = Jellify/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.20.0;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -D DEV_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify;
|
||||
PRODUCT_NAME = Jellify;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.cosmonautical.jellify";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = DevRelease;
|
||||
};
|
||||
CFDEVRELPROJ001 /* DevRelease */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CC = "";
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = YES;
|
||||
CXX = "";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD = "";
|
||||
LDPLUSPLUS = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"\"$(SDKROOT)/usr/lib/swift\"",
|
||||
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
|
||||
"\"$(inherited)\"",
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_CPLUSPLUSFLAGS = (
|
||||
"$(OTHER_CFLAGS)",
|
||||
"-DFOLLY_NO_CONFIG",
|
||||
"-DFOLLY_MOBILE=1",
|
||||
"-DFOLLY_USE_LIBCPP=1",
|
||||
"-DFOLLY_CFG_NO_COROUTINES=1",
|
||||
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_VERSION = 5.0;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = DevRelease;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -809,6 +949,7 @@
|
||||
buildConfigurations = (
|
||||
00E356F61AD99517003FC87E /* Debug */,
|
||||
00E356F71AD99517003FC87E /* Release */,
|
||||
47C4374A2EBD5610003A655B /* DevRelease */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -818,6 +959,7 @@
|
||||
buildConfigurations = (
|
||||
13B07F941A680F5B00A75B9A /* Debug */,
|
||||
13B07F951A680F5B00A75B9A /* Release */,
|
||||
CFDEVREL001 /* DevRelease */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -827,6 +969,7 @@
|
||||
buildConfigurations = (
|
||||
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||
83CBBA211A601CBA00E9B192 /* Release */,
|
||||
CFDEVRELPROJ001 /* DevRelease */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
LastUpgradeVersion = "2610"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
LastUpgradeVersion = "2610"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -3,3 +3,4 @@ appId: com.cosmonautical.jellify
|
||||
- runFlow: ../tests/4-search.yaml
|
||||
- runFlow: ../tests/5-discover.yaml
|
||||
- runFlow: ../tests/6-settings.yaml
|
||||
- runFlow: ../tests/7-quickactions.yaml
|
||||
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 }}
|
||||
>
|
||||
<Icon small color='$primary' name='music-note-plus' />
|
||||
{/* Use same icon as swipe Add to Queue for consistency */}
|
||||
<Icon small color='$primary' name='playlist-play' />
|
||||
|
||||
<Text bold>Play Next</Text>
|
||||
</ListItem>
|
||||
@@ -231,7 +232,8 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
|
||||
}}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<Icon small color='$primary' name='music-note-plus' />
|
||||
{/* Consistent Add to Queue icon */}
|
||||
<Icon small color='$primary' name='playlist-play' />
|
||||
|
||||
<Text bold>Add to Queue</Text>
|
||||
</ListItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
cancelAnimation,
|
||||
} from 'react-native-reanimated'
|
||||
import Icon from './icon'
|
||||
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
unregisterSwipeableRow,
|
||||
} from './swipeable-row-registry'
|
||||
import { scheduleOnRN } from 'react-native-worklets'
|
||||
import { SwipeableRowProvider } from './swipeable-row-context'
|
||||
import { Pressable } from 'react-native'
|
||||
|
||||
export type SwipeAction = {
|
||||
label: string
|
||||
@@ -57,29 +60,40 @@ export default function SwipeableRow({
|
||||
}: Props) {
|
||||
const triggerHaptic = useHapticFeedback()
|
||||
const tx = useSharedValue(0)
|
||||
const menuOpen = useSharedValue(false)
|
||||
const dragging = useSharedValue(false)
|
||||
const idRef = useRef<string | undefined>(undefined)
|
||||
const menuOpenRef = useRef(false)
|
||||
// React state for menu open (avoids pointerEvents bug from treating SharedValue object as truthy)
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
// Shared value mirror for animated children consumers
|
||||
const menuOpenSV = useSharedValue(false)
|
||||
const defaultMaxLeft = 120
|
||||
const defaultMaxRight = -120
|
||||
const threshold = 80
|
||||
const [rightActionsWidth, setRightActionsWidth] = useState(0)
|
||||
const [leftActionsWidth, setLeftActionsWidth] = useState(0)
|
||||
const ACTION_SIZE = 48
|
||||
|
||||
// Compute how far we allow left swipe. If quick actions exist, use their width; else a sane default.
|
||||
const hasRightSide = !!rightAction || (rightActions && rightActions.length > 0)
|
||||
const measuredRightWidth =
|
||||
rightActions && rightActions.length > 0
|
||||
? rightActionsWidth || rightActions.length * ACTION_SIZE
|
||||
: 0
|
||||
const maxRight = hasRightSide
|
||||
? rightActions && rightActions.length > 0
|
||||
? -Math.max(0, rightActionsWidth)
|
||||
? -Math.max(0, measuredRightWidth)
|
||||
: defaultMaxRight
|
||||
: 0
|
||||
|
||||
// Compute how far we allow right swipe. If quick actions exist on left side, use their width.
|
||||
const hasLeftSide = !!leftAction || (leftActions && leftActions.length > 0)
|
||||
const measuredLeftWidth =
|
||||
leftActions && leftActions.length > 0
|
||||
? leftActionsWidth || leftActions.length * ACTION_SIZE
|
||||
: 0
|
||||
const maxLeft = hasLeftSide
|
||||
? leftActions && leftActions.length > 0
|
||||
? Math.max(0, leftActionsWidth)
|
||||
? Math.max(0, measuredLeftWidth)
|
||||
: defaultMaxLeft
|
||||
: 0
|
||||
|
||||
@@ -88,22 +102,22 @@ export default function SwipeableRow({
|
||||
}
|
||||
|
||||
const syncClosedState = useCallback(() => {
|
||||
'worklet'
|
||||
menuOpenRef.current = false
|
||||
menuOpen.set(false)
|
||||
setIsMenuOpen(false)
|
||||
menuOpenSV.value = false
|
||||
notifySwipeableRowClosed(idRef.current!)
|
||||
}, [])
|
||||
}, [menuOpenSV])
|
||||
|
||||
const close = useCallback(() => {
|
||||
syncClosedState()
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(0, { duration: 160, easing: Easing.out(Easing.cubic) })
|
||||
}, [syncClosedState, tx])
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
menuOpenRef.current = true
|
||||
menuOpen.set(true)
|
||||
setIsMenuOpen(true)
|
||||
menuOpenSV.value = true
|
||||
notifySwipeableRowOpened(idRef.current!)
|
||||
}, [])
|
||||
}, [menuOpenSV])
|
||||
|
||||
useEffect(() => {
|
||||
registerSwipeableRow(idRef.current!, close)
|
||||
@@ -112,21 +126,23 @@ export default function SwipeableRow({
|
||||
}
|
||||
}, [close])
|
||||
|
||||
useEffect(() => {
|
||||
menuOpenRef.current = menuOpen.value
|
||||
}, [menuOpen])
|
||||
// menu open state now handled in React, no SharedValue mirroring required
|
||||
|
||||
const fgOpacity = useSharedValue(1.0)
|
||||
|
||||
const tapGesture = useMemo(() => {
|
||||
// Reserve the right edge for per-row controls (e.g. three dots) by shrinking the tap area there
|
||||
// so those controls can receive presses without being swallowed by the row tap gesture.
|
||||
return Gesture.Tap()
|
||||
.runOnJS(true)
|
||||
.hitSlop({ right: -64 })
|
||||
.maxDistance(2)
|
||||
.onBegin(() => {
|
||||
fgOpacity.set(0.5)
|
||||
})
|
||||
.onEnd((e, success) => {
|
||||
if (onPress && success) {
|
||||
// If a quick-action menu is open, row-level tap should NOT trigger onPress.
|
||||
if (!isMenuOpen && onPress && success) {
|
||||
triggerHaptic('impactLight')
|
||||
onPress()
|
||||
}
|
||||
@@ -134,7 +150,7 @@ export default function SwipeableRow({
|
||||
.onFinalize(() => {
|
||||
fgOpacity.set(1.0)
|
||||
})
|
||||
}, [onPress])
|
||||
}, [onPress, isMenuOpen])
|
||||
|
||||
const longPressGesture = useMemo(() => {
|
||||
return Gesture.LongPress()
|
||||
@@ -170,8 +186,8 @@ export default function SwipeableRow({
|
||||
*/
|
||||
left: -50,
|
||||
})
|
||||
.activeOffsetX([-10, 10])
|
||||
.failOffsetY([-10, 10])
|
||||
.activeOffsetX([-15, 15])
|
||||
.failOffsetY([-8, 8])
|
||||
.onBegin(() => {
|
||||
if (disabled) return
|
||||
dragging.set(true)
|
||||
@@ -182,13 +198,17 @@ export default function SwipeableRow({
|
||||
const next = Math.max(Math.min(e.translationX, maxLeft), maxRight)
|
||||
tx.value = next
|
||||
})
|
||||
.onEnd(() => {
|
||||
.onEnd((e) => {
|
||||
if (disabled) return
|
||||
// Velocity-based assistance: fast flicks open even if displacement below threshold
|
||||
const v = e.velocityX
|
||||
const velocityTrigger = 800
|
||||
if (tx.value > threshold) {
|
||||
// Right swipe: show left quick actions if provided; otherwise trigger leftAction
|
||||
if (leftActions && leftActions.length > 0) {
|
||||
triggerHaptic('impactLight')
|
||||
// Snap open to expose quick actions, do not auto-trigger
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(maxLeft, {
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
@@ -197,11 +217,13 @@ export default function SwipeableRow({
|
||||
return
|
||||
} else if (leftAction) {
|
||||
triggerHaptic('impactLight')
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(
|
||||
maxLeft,
|
||||
{ duration: 140, easing: Easing.out(Easing.cubic) },
|
||||
() => {
|
||||
scheduleOnRN(leftAction.onTrigger)
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(0, {
|
||||
duration: 160,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
@@ -216,6 +238,7 @@ export default function SwipeableRow({
|
||||
if (rightActions && rightActions.length > 0) {
|
||||
triggerHaptic('impactLight')
|
||||
// Snap open to expose quick actions, do not auto-trigger
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(maxRight, {
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
@@ -224,11 +247,70 @@ export default function SwipeableRow({
|
||||
return
|
||||
} else if (rightAction) {
|
||||
triggerHaptic('impactLight')
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(
|
||||
maxRight,
|
||||
{ duration: 140, easing: Easing.out(Easing.cubic) },
|
||||
() => {
|
||||
scheduleOnRN(rightAction.onTrigger)
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(0, {
|
||||
duration: 160,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Velocity fallback (open quick actions if fast flick even without full displacement)
|
||||
if (v > velocityTrigger && hasLeftSide) {
|
||||
if (leftActions && leftActions.length > 0) {
|
||||
triggerHaptic('impactLight')
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(maxLeft, {
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
openMenu()
|
||||
return
|
||||
} else if (leftAction) {
|
||||
triggerHaptic('impactLight')
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(
|
||||
maxLeft,
|
||||
{ duration: 140, easing: Easing.out(Easing.cubic) },
|
||||
() => {
|
||||
scheduleOnRN(leftAction.onTrigger)
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(0, {
|
||||
duration: 160,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (v < -velocityTrigger && hasRightSide) {
|
||||
if (rightActions && rightActions.length > 0) {
|
||||
triggerHaptic('impactLight')
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(maxRight, {
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
openMenu()
|
||||
return
|
||||
} else if (rightAction) {
|
||||
triggerHaptic('impactLight')
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(
|
||||
maxRight,
|
||||
{ duration: 140, easing: Easing.out(Easing.cubic) },
|
||||
() => {
|
||||
scheduleOnRN(rightAction.onTrigger)
|
||||
cancelAnimation(tx)
|
||||
tx.value = withTiming(0, {
|
||||
duration: 160,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
@@ -265,36 +347,58 @@ export default function SwipeableRow({
|
||||
},
|
||||
],
|
||||
opacity: withTiming(fgOpacity.value, { easing: Easing.bounce }),
|
||||
zIndex: 20,
|
||||
}))
|
||||
// Keep the tap-to-close overlay anchored to the foreground so quick actions stay interactive
|
||||
const overlayStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX: tx.value,
|
||||
},
|
||||
],
|
||||
}))
|
||||
const leftUnderlayStyle = useAnimatedStyle(() => {
|
||||
// Normalize progress to [0,1] with a monotonic denominator to avoid non-monotonic ranges
|
||||
// when the available swipe distance is smaller than the threshold (e.g., 1 quick action = 48px)
|
||||
// Normalize progress to [0,1]
|
||||
const leftMax = maxLeft === 0 ? 1 : maxLeft
|
||||
const denom = Math.max(1, Math.min(threshold, leftMax))
|
||||
const progress = Math.min(1, Math.max(0, tx.value / denom))
|
||||
// Slight ease by capping at 0.9 near threshold and 1.0 when fully open
|
||||
const opacity = progress < 1 ? progress * 0.9 : 1
|
||||
return { opacity }
|
||||
return {
|
||||
opacity,
|
||||
// Quick-action buttons should sit visually beneath content
|
||||
zIndex: leftActions && leftActions.length > 0 ? 5 : 10,
|
||||
}
|
||||
})
|
||||
const rightUnderlayStyle = useAnimatedStyle(() => {
|
||||
const rightMax = maxRight === 0 ? -1 : maxRight // negative value when available
|
||||
const rightMax = maxRight === 0 ? -1 : maxRight
|
||||
const absMax = Math.abs(rightMax)
|
||||
const denom = Math.max(1, Math.min(threshold, absMax))
|
||||
const progress = Math.min(1, Math.max(0, -tx.value / denom))
|
||||
const opacity = progress < 1 ? progress * 0.9 : 1
|
||||
return { opacity }
|
||||
return {
|
||||
opacity,
|
||||
zIndex: rightActions && rightActions.length > 0 ? 5 : 10,
|
||||
}
|
||||
})
|
||||
|
||||
if (disabled) return <>{children}</>
|
||||
|
||||
const combinedGesture = Gesture.Race(panGesture, longPressGesture, tapGesture)
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={Gesture.Simultaneous(tapGesture, longPressGesture, panGesture)}>
|
||||
<GestureDetector gesture={combinedGesture}>
|
||||
<YStack position='relative' overflow='hidden'>
|
||||
{/* Left action underlay with colored background (icon-only) */}
|
||||
{leftAction && !leftActions && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
leftUnderlayStyle,
|
||||
]}
|
||||
pointerEvents='none'
|
||||
@@ -315,10 +419,16 @@ export default function SwipeableRow({
|
||||
{leftActions && leftActions.length > 0 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: measuredLeftWidth,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
leftUnderlayStyle,
|
||||
]}
|
||||
pointerEvents={menuOpen ? 'auto' : 'none'}
|
||||
pointerEvents={isMenuOpen ? 'auto' : 'none'}
|
||||
>
|
||||
{/* Underlay background matches list background for continuity */}
|
||||
<XStack
|
||||
@@ -337,6 +447,9 @@ export default function SwipeableRow({
|
||||
{leftActions.map((action, idx) => (
|
||||
<XStack
|
||||
key={`left-quick-action-${idx}`}
|
||||
// Maestro test id for left quick action button
|
||||
// pattern: quick-action-left-<index>
|
||||
testID={`quick-action-left-${idx}`}
|
||||
width={48}
|
||||
height={48}
|
||||
alignItems='center'
|
||||
@@ -344,6 +457,8 @@ export default function SwipeableRow({
|
||||
backgroundColor={action.color}
|
||||
borderRadius={0}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={`Left quick action ${action.icon}`}
|
||||
onPress={() => {
|
||||
action.onPress()
|
||||
close()
|
||||
@@ -361,7 +476,13 @@ export default function SwipeableRow({
|
||||
{rightAction && !rightActions && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
rightUnderlayStyle,
|
||||
]}
|
||||
pointerEvents='none'
|
||||
@@ -383,10 +504,16 @@ export default function SwipeableRow({
|
||||
{rightActions && rightActions.length > 0 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 },
|
||||
{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
width: Math.abs(measuredRightWidth),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
rightUnderlayStyle,
|
||||
]}
|
||||
pointerEvents={menuOpen ? 'auto' : 'none'}
|
||||
pointerEvents={isMenuOpen ? 'auto' : 'none'}
|
||||
>
|
||||
{/* Underlay background matches list background to keep continuity */}
|
||||
<XStack
|
||||
@@ -405,6 +532,7 @@ export default function SwipeableRow({
|
||||
{rightActions.map((action, idx) => (
|
||||
<XStack
|
||||
key={`quick-action-${idx}`}
|
||||
testID={`quick-action-right-${idx}`}
|
||||
width={48}
|
||||
height={48}
|
||||
alignItems='center'
|
||||
@@ -412,6 +540,8 @@ export default function SwipeableRow({
|
||||
backgroundColor={action.color}
|
||||
borderRadius={0}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={`Right quick action ${action.icon}`}
|
||||
onPress={() => {
|
||||
action.onPress()
|
||||
close()
|
||||
@@ -425,25 +555,48 @@ export default function SwipeableRow({
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Foreground content */}
|
||||
<Animated.View
|
||||
style={fgStyle}
|
||||
pointerEvents={dragging ? 'none' : 'auto'}
|
||||
accessibilityHint={leftAction || rightAction ? 'Swipe for actions' : undefined}
|
||||
{/* Foreground content (provider wraps children to expose tx & menu open shared value) */}
|
||||
<SwipeableRowProvider
|
||||
value={{
|
||||
tx,
|
||||
menuOpenSV,
|
||||
leftWidth: measuredLeftWidth,
|
||||
rightWidth: Math.abs(measuredRightWidth),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={fgStyle}
|
||||
// Disable touches only while a menu is open; allow presses when closed
|
||||
pointerEvents={isMenuOpen ? 'none' : 'auto'}
|
||||
accessibilityHint={
|
||||
leftAction || rightAction ? 'Swipe for actions' : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</SwipeableRowProvider>
|
||||
|
||||
{/* Tap-capture overlay: when a quick-action menu is open, tapping the row closes it without triggering child onPress */}
|
||||
<XStack
|
||||
position='absolute'
|
||||
left={0}
|
||||
right={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
pointerEvents={menuOpen ? 'auto' : 'none'}
|
||||
onPress={close}
|
||||
/>
|
||||
{/* Tap-capture overlay: sits above foreground, below action buttons */}
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 30,
|
||||
},
|
||||
overlayStyle,
|
||||
]}
|
||||
pointerEvents={isMenuOpen ? 'auto' : 'none'}
|
||||
>
|
||||
<Pressable
|
||||
style={{ flex: 1 }}
|
||||
onPress={close}
|
||||
pointerEvents={isMenuOpen ? 'auto' : 'none'}
|
||||
/>
|
||||
</Animated.View>
|
||||
</YStack>
|
||||
</GestureDetector>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,8 @@ import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import useItemContext from '../../../hooks/use-item-context'
|
||||
import { RouteProp, useRoute } from '@react-navigation/native'
|
||||
import { useCallback } from 'react'
|
||||
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
|
||||
import { useSwipeableRowContext } from './swipeable-row-context'
|
||||
import SwipeableRow from './SwipeableRow'
|
||||
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
|
||||
import { buildSwipeConfig } from '../helpers/swipe-actions'
|
||||
@@ -46,6 +48,7 @@ export default function ItemRow({
|
||||
circular,
|
||||
navigation,
|
||||
onPress,
|
||||
queueName,
|
||||
}: ItemRowProps): React.JSX.Element {
|
||||
const api = useApi()
|
||||
|
||||
@@ -157,6 +160,7 @@ export default function ItemRow({
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
testID={item.Id ? `item-row-${item.Id}` : undefined}
|
||||
onPressIn={onPressIn}
|
||||
onPress={onPressCallback}
|
||||
onLongPress={onLongPress}
|
||||
@@ -165,16 +169,8 @@ export default function ItemRow({
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
<ItemRowDetails item={item} />
|
||||
<HideableArtwork item={item} circular={circular} />
|
||||
<StickyText item={item} />
|
||||
|
||||
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
|
||||
{renderRunTime ? (
|
||||
@@ -237,3 +233,40 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Artwork wrapper that fades out when the quick-action menu is open
|
||||
function HideableArtwork({
|
||||
item,
|
||||
circular,
|
||||
}: {
|
||||
item: BaseItemDto
|
||||
circular?: boolean
|
||||
}): React.JSX.Element {
|
||||
const { tx } = useSwipeableRowContext()
|
||||
// Hide artwork as soon as swiping starts (any non-zero tx)
|
||||
const style = useAnimatedStyle(() => ({
|
||||
opacity: tx.value === 0 ? 1 : 0,
|
||||
}))
|
||||
return (
|
||||
<Animated.View style={style}>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
// Text/details remain visible. No counter-translation needed now that underlays are width-bound.
|
||||
function StickyText({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
const style = useAnimatedStyle(() => ({}))
|
||||
return (
|
||||
<Animated.View style={[style, { flex: 5 }]}>
|
||||
<ItemRowDetails item={item} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
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 { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
|
||||
import React, { useMemo, useCallback, useState } from 'react'
|
||||
import { getToken, Spacer, Theme, useTheme, XStack, YStack } from 'tamagui'
|
||||
import { Text } from '../helpers/text'
|
||||
import { RunTimeTicks } from '../helpers/time-codes'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
@@ -14,6 +14,7 @@ import navigationRef from '../../../../navigation'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '../../../screens/types'
|
||||
import ItemImage from './image'
|
||||
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
|
||||
import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import useStreamedMediaInfo from '../../../api/queries/media'
|
||||
@@ -26,7 +27,8 @@ import { useApi } from '../../../stores'
|
||||
import { useCurrentTrack, usePlayQueue } from '../../../stores/player/queue'
|
||||
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
|
||||
import { StackActions } from '@react-navigation/native'
|
||||
import { TouchableOpacity } from 'react-native'
|
||||
import { useSwipeableRowContext } from './swipeable-row-context'
|
||||
import { useHideRunTimesSetting } from '../../../stores/settings/app'
|
||||
|
||||
export interface TrackProps {
|
||||
track: BaseItemDto
|
||||
@@ -62,11 +64,14 @@ export default function Track({
|
||||
onRemove,
|
||||
}: TrackProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const [hideRunTimes] = useHideRunTimesSetting()
|
||||
|
||||
const nowPlaying = useCurrentTrack()
|
||||
const playQueue = usePlayQueue()
|
||||
const loadNewQueue = useLoadNewQueue()
|
||||
@@ -212,6 +217,27 @@ export default function Track({
|
||||
[leftSettings, rightSettings, swipeHandlers],
|
||||
)
|
||||
|
||||
const runtimeComponent = useMemo(
|
||||
() =>
|
||||
hideRunTimes ? (
|
||||
<></>
|
||||
) : (
|
||||
<RunTimeTicks
|
||||
key={`${track.Id}-runtime`}
|
||||
props={{
|
||||
style: {
|
||||
textAlign: 'right',
|
||||
minWidth: getToken('$10'),
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.RunTimeTicks}
|
||||
</RunTimeTicks>
|
||||
),
|
||||
[hideRunTimes, track.RunTimeTicks],
|
||||
)
|
||||
|
||||
return (
|
||||
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
|
||||
<SwipeableRow
|
||||
@@ -227,8 +253,8 @@ export default function Track({
|
||||
flex={1}
|
||||
testID={testID ?? undefined}
|
||||
paddingVertical={'$2'}
|
||||
justifyContent='center'
|
||||
marginRight={'$2'}
|
||||
justifyContent='flex-start'
|
||||
paddingRight={'$2'}
|
||||
animation={'quick'}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
backgroundColor={'$background'}
|
||||
@@ -243,9 +269,12 @@ export default function Track({
|
||||
alignContent='center'
|
||||
justifyContent='center'
|
||||
marginHorizontal={showArtwork ? '$2' : '$1'}
|
||||
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{showArtwork ? (
|
||||
<ItemImage item={track} width={'$12'} height={'$12'} />
|
||||
<HideableArtwork>
|
||||
<ItemImage item={track} width={'$12'} height={'$12'} />
|
||||
</HideableArtwork>
|
||||
) : (
|
||||
<Text
|
||||
key={`${track.Id}-number`}
|
||||
@@ -259,53 +288,75 @@ export default function Track({
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
|
||||
<Text
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{trackName}
|
||||
</Text>
|
||||
|
||||
{shouldShowArtists && (
|
||||
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
|
||||
<YStack alignItems='flex-start' justifyContent='center' flex={6}>
|
||||
<Text
|
||||
key={`${track.Id}-artists`}
|
||||
key={`${track.Id}-name`}
|
||||
bold
|
||||
color={textColor}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
color={'$borderColor'}
|
||||
>
|
||||
{artistsText}
|
||||
{trackName}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<DownloadedIcon item={track} />
|
||||
{shouldShowArtists && (
|
||||
<Text
|
||||
key={`${track.Id}-artists`}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
color={'$borderColor'}
|
||||
>
|
||||
{artistsText}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
</SlidingTextArea>
|
||||
|
||||
<FavoriteIcon item={track} />
|
||||
|
||||
<RunTimeTicks
|
||||
key={`${track.Id}-runtime`}
|
||||
props={{
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
flex: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.RunTimeTicks}
|
||||
</RunTimeTicks>
|
||||
|
||||
<Icon
|
||||
name={showRemove ? 'close' : 'dots-horizontal'}
|
||||
flex={1}
|
||||
onPress={handleIconPress}
|
||||
/>
|
||||
<XStack justifyContent='flex-end' alignItems='center' flex={2} gap='$1'>
|
||||
<DownloadedIcon item={track} />
|
||||
<FavoriteIcon item={track} />
|
||||
{runtimeComponent}
|
||||
<Icon
|
||||
name={showRemove ? 'close' : 'dots-horizontal'}
|
||||
onPress={handleIconPress}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</SwipeableRow>
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
|
||||
function HideableArtwork({ children }: { children: React.ReactNode }) {
|
||||
const { tx } = useSwipeableRowContext()
|
||||
// Hide artwork as soon as swiping starts (any non-zero tx)
|
||||
const style = useAnimatedStyle(() => ({ opacity: tx.value === 0 ? 1 : 0 }))
|
||||
return <Animated.View style={style}>{children}</Animated.View>
|
||||
}
|
||||
|
||||
function SlidingTextArea({
|
||||
leftGapWidth,
|
||||
hasArtwork,
|
||||
children,
|
||||
}: {
|
||||
leftGapWidth: number
|
||||
hasArtwork: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { tx, rightWidth } = useSwipeableRowContext()
|
||||
const style = useAnimatedStyle(() => {
|
||||
const t = tx.value
|
||||
let offset = 0
|
||||
if (t > 0 && hasArtwork) {
|
||||
// Swiping right: row content moves right; pull text left up to artwork width to fill the gap
|
||||
offset = -Math.min(t, Math.max(0, leftGapWidth))
|
||||
} else if (t < 0) {
|
||||
// Swiping left: row content moves left; push text right a bit to keep it visible
|
||||
const compensate = Math.min(-t, Math.max(0, rightWidth))
|
||||
offset = compensate * 0.7
|
||||
}
|
||||
return { transform: [{ translateX: offset }] }
|
||||
})
|
||||
return <Animated.View style={[{ flex: 5 }, style]}>{children}</Animated.View>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ function toSwipeAction(type: SwipeActionType, handlers: SwipeHandlers): SwipeAct
|
||||
case 'AddToQueue':
|
||||
return {
|
||||
label: 'Add to queue',
|
||||
icon: 'playlist-plus',
|
||||
// Use a distinct icon from Add to Playlist to avoid confusion
|
||||
icon: 'playlist-play',
|
||||
color: '$success',
|
||||
onTrigger: handlers.addToQueue,
|
||||
}
|
||||
@@ -44,7 +45,8 @@ function toQuickAction(type: SwipeActionType, handlers: SwipeHandlers): QuickAct
|
||||
switch (type) {
|
||||
case 'AddToQueue':
|
||||
return {
|
||||
icon: 'playlist-plus',
|
||||
// Distinct icon for Add to Queue quick action
|
||||
icon: 'playlist-play',
|
||||
color: '$success',
|
||||
onPress: handlers.addToQueue,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import SettingsListGroup from './settings-list-group'
|
||||
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
|
||||
import {
|
||||
ThemeSetting,
|
||||
useHideRunTimesSetting,
|
||||
useReducedHapticsSetting,
|
||||
useSendMetricsSetting,
|
||||
useThemeSetting,
|
||||
@@ -17,6 +18,9 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
|
||||
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
|
||||
const [themeSetting, setThemeSetting] = useThemeSetting()
|
||||
|
||||
const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting()
|
||||
|
||||
const left = useSwipeSettingsStore((s) => s.left)
|
||||
const right = useSwipeSettingsStore((s) => s.right)
|
||||
const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft)
|
||||
@@ -27,13 +31,16 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
label,
|
||||
icon,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
icon: string
|
||||
onPress: () => void
|
||||
testID?: string
|
||||
}) => (
|
||||
<Button
|
||||
testID={testID}
|
||||
pressStyle={{
|
||||
backgroundColor: '$neutral',
|
||||
}}
|
||||
@@ -113,18 +120,21 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
<SizableText size={'$3'}>Swipe Left</SizableText>
|
||||
<XStack gap={'$2'} flexWrap='wrap'>
|
||||
<ActionChip
|
||||
testID='swipe-left-favorite-toggle'
|
||||
active={left.includes('ToggleFavorite')}
|
||||
label='Favorite'
|
||||
icon='heart'
|
||||
onPress={() => toggleLeft('ToggleFavorite')}
|
||||
/>
|
||||
<ActionChip
|
||||
testID='swipe-left-playlist-toggle'
|
||||
active={left.includes('AddToPlaylist')}
|
||||
label='Add to Playlist'
|
||||
icon='playlist-plus'
|
||||
onPress={() => toggleLeft('AddToPlaylist')}
|
||||
/>
|
||||
<ActionChip
|
||||
testID='swipe-left-queue-toggle'
|
||||
active={left.includes('AddToQueue')}
|
||||
label='Add to Queue'
|
||||
icon='playlist-play'
|
||||
@@ -136,18 +146,21 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
<SizableText size={'$3'}>Swipe Right</SizableText>
|
||||
<XStack gap={'$2'} flexWrap='wrap'>
|
||||
<ActionChip
|
||||
testID='swipe-right-favorite-toggle'
|
||||
active={right.includes('ToggleFavorite')}
|
||||
label='Favorite'
|
||||
icon='heart'
|
||||
onPress={() => toggleRight('ToggleFavorite')}
|
||||
/>
|
||||
<ActionChip
|
||||
testID='swipe-right-playlist-toggle'
|
||||
active={right.includes('AddToPlaylist')}
|
||||
label='Add to Playlist'
|
||||
icon='playlist-plus'
|
||||
onPress={() => toggleRight('AddToPlaylist')}
|
||||
/>
|
||||
<ActionChip
|
||||
testID='swipe-right-queue-toggle'
|
||||
active={right.includes('AddToQueue')}
|
||||
label='Add to Queue'
|
||||
icon='playlist-play'
|
||||
@@ -159,6 +172,20 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Hide Runtimes',
|
||||
iconName: 'clock-digital',
|
||||
iconColor: hideRunTimes ? '$success' : '$borderColor',
|
||||
subTitle: 'Hides track runtime lengths',
|
||||
children: (
|
||||
<SwitchWithLabel
|
||||
checked={hideRunTimes}
|
||||
onCheckedChange={setHideRunTimes}
|
||||
size={'$2'}
|
||||
label={hideRunTimes ? 'Hidden' : 'Shown'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Reduce Haptics',
|
||||
iconName: reducedHaptics ? 'vibrate-off' : 'vibrate',
|
||||
@@ -169,7 +196,7 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
checked={reducedHaptics}
|
||||
onCheckedChange={setReducedHaptics}
|
||||
size={'$2'}
|
||||
label={reducedHaptics ? 'Enabled' : 'Disabled'}
|
||||
label={reducedHaptics ? 'Reduced' : 'Disabled'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -183,7 +210,7 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
checked={sendMetrics}
|
||||
onCheckedChange={setSendMetrics}
|
||||
size={'$2'}
|
||||
label={sendMetrics ? 'Enabled' : 'Disabled'}
|
||||
label={sendMetrics ? 'Sending' : 'Disabled'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Tracks({
|
||||
* play that exact track, since the index was offset by the headings
|
||||
*/
|
||||
const renderItem = useCallback(
|
||||
({ item: track }: { index: number; item: string | number | BaseItemDto }) =>
|
||||
({ item: track, index }: { index: number; item: string | number | BaseItemDto }) =>
|
||||
typeof track === 'string' ? (
|
||||
<FlashListStickyHeader text={track.toUpperCase()} />
|
||||
) : typeof track === 'number' ? null : typeof track === 'object' ? (
|
||||
@@ -79,6 +79,7 @@ export default function Tracks({
|
||||
showArtwork
|
||||
index={0}
|
||||
track={track}
|
||||
testID={`track-item-${index}`}
|
||||
tracklist={tracksToDisplay.slice(
|
||||
tracksToDisplay.indexOf(track),
|
||||
tracksToDisplay.indexOf(track) + 50,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mmkvStateStorage } from '../../constants/storage'
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
export type ThemeSetting = 'system' | 'light' | 'dark' | 'oled'
|
||||
|
||||
@@ -8,6 +9,9 @@ type AppSettingsStore = {
|
||||
sendMetrics: boolean
|
||||
setSendMetrics: (sendMetrics: boolean) => void
|
||||
|
||||
hideRunTimes: boolean
|
||||
setHideRunTimes: (hideRunTimes: boolean) => void
|
||||
|
||||
reducedHaptics: boolean
|
||||
setReducedHaptics: (reducedHaptics: boolean) => void
|
||||
|
||||
@@ -18,15 +22,18 @@ type AppSettingsStore = {
|
||||
export const useAppSettingsStore = create<AppSettingsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set): AppSettingsStore => ({
|
||||
sendMetrics: false,
|
||||
setSendMetrics: (sendMetrics) => set({ sendMetrics }),
|
||||
setSendMetrics: (sendMetrics: boolean) => set({ sendMetrics }),
|
||||
|
||||
hideRunTimes: false,
|
||||
setHideRunTimes: (hideRunTimes: boolean) => set({ hideRunTimes }),
|
||||
|
||||
reducedHaptics: false,
|
||||
setReducedHaptics: (reducedHaptics) => set({ reducedHaptics }),
|
||||
setReducedHaptics: (reducedHaptics: boolean) => set({ reducedHaptics }),
|
||||
|
||||
theme: 'system',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setTheme: (theme: ThemeSetting) => set({ theme }),
|
||||
}),
|
||||
{
|
||||
name: 'app-settings-storage',
|
||||
@@ -58,3 +65,6 @@ export const useSendMetricsSetting: () => [boolean, (sendMetrics: boolean) => vo
|
||||
|
||||
return [sendMetrics, setSendMetrics]
|
||||
}
|
||||
|
||||
export const useHideRunTimesSetting: () => [boolean, (hideRunTimes: boolean) => void] = () =>
|
||||
useAppSettingsStore(useShallow((state) => [state.hideRunTimes, state.setHideRunTimes]))
|
||||
|
||||
Reference in New Issue
Block a user