diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 3f02580..e571880 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -17,7 +17,9 @@ import { import { Ionicons } from '@expo/vector-icons'; import { type FlashListRef } from '@shopify/flash-list'; import { useFocusEffect, useRouter } from 'expo-router'; -import { +import Animated, { + FadeIn, + ReduceMotion, runOnJS, useAnimatedScrollHandler, useSharedValue, @@ -27,6 +29,7 @@ import { ReanimatedFlashList } from '@/components/ReanimatedFlashList'; import { ScreenHeader, ScreenHeaderAction, + ScreenHeaderExpandedAction, useScreenHeader, } from '@/components/ScreenHeader'; import { Text } from '@/components/Text'; @@ -43,7 +46,11 @@ import { type FolderActionTarget, } from '@/components/library/FoldersView'; import { LibraryContextBar } from '@/components/library/LibraryContextBar'; -import { LibrarySurfaceTransition } from '@/components/library/LibrarySurfaceTransition'; +import { + LIBRARY_SURFACE_ENTER_MS, + LIBRARY_SURFACE_EXIT_MS, + LibrarySurfaceTransition, +} from '@/components/library/LibrarySurfaceTransition'; import { MiniPlayerScrim } from '@/components/MiniPlayerScrim'; import { PlaylistOverlays, @@ -159,6 +166,11 @@ const CHROME_CONTROLS_H = 32; const CHROME_SCAN_H = 34; const CHROME_ERROR_H = 34; const CHROME_GAP = spacing.sm; +const TRACK_ACTIONS_ENTER_LAG_MS = 40; +const TRACK_ACTIONS_ENTERING = FadeIn + .delay(LIBRARY_SURFACE_EXIT_MS + TRACK_ACTIONS_ENTER_LAG_MS) + .duration(LIBRARY_SURFACE_ENTER_MS) + .reduceMotion(ReduceMotion.System); function libraryChromeHeight(options: { scanning: boolean; @@ -276,12 +288,19 @@ export default function LibraryScreen() { const sortable = viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists'; + const showTrackPlaybackActions = + viewMode === 'tracks' && totalTrackCount > 0 && !selectMode; + const showHeaderSearch = + !phoneContextBar && showScreenTitle && !showLibraryStatus; const header = useScreenHeader({ // In rail mode the rail names the destination, so the header contributes no // title and no bar — only the chrome the lists still have to clear. hasTitle: showScreenTitle, hasBack: false, - actionCount: !phoneContextBar && showScreenTitle && !showLibraryStatus ? 1 : 0, + actionCount: + (showScreenTitle && showTrackPlaybackActions ? 2 : 0) + + (showHeaderSearch ? 1 : 0), + hasExpandedActions: showScreenTitle && showTrackPlaybackActions, chromeHeight: showLibraryStatus || phoneContextBar ? 0 : libraryChromeHeight({ @@ -324,6 +343,16 @@ export default function LibraryScreen() { source: { kind: 'library', label: 'Library' }, }); }; + const playAllTracks = (shuffle: boolean) => { + void playLibraryQuery({ + kind: 'library', + sort: trackSort, + direction: trackSortDirection, + }, { + shuffle, + source: { kind: 'library', label: 'Library' }, + }); + }; const openSearch = () => openQuickSearch(); // Sits at the end of the loaded window whenever more pages exist, so a fling that @@ -761,7 +790,36 @@ export default function LibraryScreen() { ) : sortable ? ( - + + {!showScreenTitle && showTrackPlaybackActions ? ( + + playAllTracks(false)} + accessibilityRole="button" + accessibilityLabel="Play all tracks" + > + + + playAllTracks(true)} + accessibilityRole="button" + accessibilityLabel="Shuffle all tracks" + > + + + + ) : null} setSortSheetOpen(true)} @@ -1024,8 +1082,49 @@ export default function LibraryScreen() { header={header} title="Library" chrome={chrome} + expandedActions={ + showScreenTitle && showTrackPlaybackActions ? ( + + playAllTracks(false)} + accessibilityLabel="Play all tracks" + /> + playAllTracks(true)} + accessibilityLabel="Shuffle all tracks" + /> + + ) : undefined + } + collapsedActions={ + showScreenTitle && showTrackPlaybackActions ? ( + + playAllTracks(false)} + accessibilityLabel="Play all tracks" + > + + + playAllTracks(true)} + accessibilityLabel="Shuffle all tracks" + > + + + + ) : undefined + } actions={ - !phoneContextBar && showScreenTitle && !showLibraryStatus ? ( + showHeaderSearch ? ( openQuickSearch()} accessibilityLabel="Search library"> @@ -1233,6 +1332,24 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', gap: spacing.md, }, + controlsRowSplit: { + justifyContent: 'space-between', + }, + trackPlaybackActions: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + expandedTrackPlaybackActions: { + width: '100%', + height: '100%', + }, + trackPlaybackAction: { + width: CHROME_CONTROLS_H, + height: CHROME_CONTROLS_H, + alignItems: 'center', + justifyContent: 'center', + }, sortTrigger: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/ScreenHeader.tsx b/src/components/ScreenHeader.tsx index 8958ad2..17aa8eb 100644 --- a/src/components/ScreenHeader.tsx +++ b/src/components/ScreenHeader.tsx @@ -1,5 +1,11 @@ /* eslint-disable react-hooks/immutability -- Reanimated shared values are the header's scroll state. */ -import { useCallback, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; import { PixelRatio, StyleSheet, @@ -8,7 +14,9 @@ import { } from 'react-native'; import Animated, { useAnimatedScrollHandler, + useAnimatedReaction, useAnimatedStyle, + runOnJS, useSharedValue, type ScrollHandlerProcessed, type SharedValue, @@ -18,11 +26,16 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '@/components/Text'; import { useShellRailPresent } from '@/navigation/shellRailContext'; import { useShellLayout } from '@/navigation/useShellLayout'; -import { MAX_FONT_SCALE, fontSize, variantLineHeight } from '@/theme'; +import { MAX_FONT_SCALE, fontSize, radius, variantLineHeight } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable'; import { SCREEN_HEADER_ACTION_SIZE, + SCREEN_HEADER_EXPANDED_ACTION_SIZE, + compactActionsOpacityAt, + compactActionsScaleAt, + expandedActionsLiftAt, + expandedActionsOpacityAt, barOpacityAt, getScreenHeaderLayout, headerHeightAt, @@ -45,11 +58,9 @@ import { * * - **The list's top padding comes from `contentPaddingTop`, and nowhere else.** * No consumer re-derives it, and nothing here measures it back. - * - **There is no React state and no effect.** `CollapsingDetail` needs latched - * booleans only to hand pointer events between big and small buttons that swap - * places; here the chevron and the actions are pinned in the bar row from the - * start and never move, so there is nothing to hand over. That also keeps the - * subtree from re-rendering while the UI-thread scroll handler drives it. + * - **React only hears about action handoff thresholds.** Motion stays on the + * UI thread; two latched booleans move pointer events from an optional large + * expanded action row to its compact bar icons. */ export interface ScreenHeaderController { @@ -73,12 +84,15 @@ export function useScreenHeader({ hasSubtitle = false, hasBack = true, actionCount = 0, + hasExpandedActions = false, hasTitle = true, chromeHeight = 0, }: { hasSubtitle?: boolean; hasBack?: boolean; actionCount?: number; + /** Whether the expanded title reserves a standard labelled-action row. */ + hasExpandedActions?: boolean; /** False where the destination is already named elsewhere (Library + rail). */ hasTitle?: boolean; /** Declared height of the pinned controls passed as `chrome`. */ @@ -105,6 +119,7 @@ export function useScreenHeader({ hasSubtitle, hasBack, actionCount, + hasExpandedActions, hasTitle, chromeHeight, }); @@ -134,6 +149,8 @@ export function ScreenHeader({ backLabel, onBack, actions, + collapsedActions, + expandedActions, chrome, }: { header: ScreenHeaderController; @@ -142,7 +159,12 @@ export function ScreenHeader({ /** Where back goes, shown beside the chevron until the title claims the row. */ backLabel?: string; onBack?: () => void; + /** Actions that stay in the bar in both expanded and collapsed states. */ actions?: ReactNode; + /** Compact counterparts to `expandedActions`, visible only after collapse. */ + collapsedActions?: ReactNode; + /** Large labelled actions shown below the title while fully expanded. */ + expandedActions?: ReactNode; /** * Pinned controls below the title. Anchored to the container's bottom edge, so * they ride up as the title collapses and then stay put. Must be exactly @@ -159,6 +181,42 @@ export function ScreenHeader({ // `getScreenHeaderLayout` is pure, so `layout` is a fresh identity every // render, and Reanimated compares what the worklet captured. const { dist, settle, travelX, travelY, titleScale, maxHeight, minHeight } = layout; + const handoffEnabled = + layout.collapsible && expandedActions != null && collapsedActions != null; + const interactionRef = useRef({ expanded: true, compact: false }); + const [interaction, setInteraction] = useState({ expanded: true, compact: false }); + const updateInteraction = useCallback((expanded: boolean, compact: boolean) => { + if ( + interactionRef.current.expanded === expanded && + interactionRef.current.compact === compact + ) return; + interactionRef.current = { expanded, compact }; + setInteraction(interactionRef.current); + }, []); + + useEffect(() => { + if (!handoffEnabled) return; + const y = scrollY.value; + updateInteraction( + expandedActionsOpacityAt(y, settle) > 0, + compactActionsOpacityAt(y, settle) >= 0.5, + ); + }, [handoffEnabled, scrollY, settle, updateInteraction]); + + useAnimatedReaction( + () => { + if (!handoffEnabled) return 0; + const y = scrollY.value; + return ( + (expandedActionsOpacityAt(y, settle) > 0 ? 1 : 0) | + (compactActionsOpacityAt(y, settle) >= 0.5 ? 2 : 0) + ); + }, + (flags, previous) => { + if (flags === previous) return; + runOnJS(updateInteraction)((flags & 1) !== 0, (flags & 2) !== 0); + }, + ); const collapseClipStyle = useAnimatedStyle(() => ({ transform: [ @@ -187,6 +245,26 @@ export function ScreenHeader({ const barStyle = useAnimatedStyle(() => ({ opacity: barOpacityAt(scrollY.value, settle), })); + const expandedActionsStyle = useAnimatedStyle(() => ({ + opacity: layout.collapsible + ? expandedActionsOpacityAt(scrollY.value, settle) + : 0, + transform: [{ + translateY: layout.collapsible + ? expandedActionsLiftAt(scrollY.value, settle) + : 0, + }], + })); + const compactActionsStyle = useAnimatedStyle(() => ({ + opacity: layout.collapsible + ? compactActionsOpacityAt(scrollY.value, settle) + : 1, + transform: [{ + scale: layout.collapsible + ? compactActionsScaleAt(scrollY.value, settle) + : 1, + }], + })); // The title sizes itself rather than leaning on `Text`'s automatic scaling: // Android applies `maxFontSizeMultiplier` to fontSize but *not* to lineHeight @@ -223,7 +301,7 @@ export function ScreenHeader({ ) : null; const actionCluster = - actions != null ? ( + actions != null || collapsedActions != null ? ( + {collapsedActions != null ? ( + + {collapsedActions} + + ) : null} {actions} ) : null; + const expandedActionBlock = + expandedActions != null && layout.collapsible ? ( + + {expandedActions} + + ) : null; + // Anchored to the container's bottom edge, so the collapse carries it up and // it settles directly under the bar. Its height is declared, not measured — // the caller owes `chromeHeight` and an element that is exactly that tall. @@ -310,7 +420,7 @@ export function ScreenHeader({ { top: insets.top + layout.titleTop, left: layout.titleLeft, - right: layout.titleLeft, + right: layout.titleRight, height: layout.titleLine, }, titleStyle, @@ -359,6 +469,8 @@ export function ScreenHeader({ ) : null} + {expandedActionBlock} + {chevron} {actionCluster} @@ -372,7 +484,7 @@ export function ScreenHeader({ } /** - * A button in the collapsed bar's action row. + * A compact button in the collapsed bar's action row. * * Sized from the same constant the layout proves the title cannot collide with, * so a screen can add actions without re-declaring the number that keeps them @@ -403,6 +515,41 @@ export function ScreenHeaderAction({ ); } +export function ScreenHeaderExpandedAction({ + onPress, + accessibilityLabel, + icon, + variant = 'secondary', +}: { + onPress: () => void; + accessibilityLabel: string; + icon: keyof typeof Ionicons.glyphMap; + variant?: 'primary' | 'secondary'; +}) { + const styles = useStyles(); + const colors = useColors(); + const primary = variant === 'primary'; + return ( + + + + ); +} + const useStyles = createThemedStyles((colors) => ({ container: { position: 'absolute', @@ -470,6 +617,29 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', alignItems: 'center', }, + collapsedActions: { + flexDirection: 'row', + alignItems: 'center', + }, + expandedActions: { + position: 'absolute', + flexDirection: 'row', + }, + expandedAction: { + width: SCREEN_HEADER_EXPANDED_ACTION_SIZE, + height: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.pill, + }, + expandedActionPrimary: { + backgroundColor: colors.accent, + }, + expandedActionSecondary: { + backgroundColor: colors.glassBg, + borderColor: colors.accent, + borderWidth: StyleSheet.hairlineWidth, + }, chrome: { position: 'absolute', left: 0, diff --git a/src/components/screenHeaderLayout.test.mts b/src/components/screenHeaderLayout.test.mts index f0cdcea..10f9847 100644 --- a/src/components/screenHeaderLayout.test.mts +++ b/src/components/screenHeaderLayout.test.mts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { SCREEN_BAR_H, + SCREEN_HEADER_EXPANDED_ACTION_SIZE, SCREEN_HEADER_GUTTER, + compactActionsOpacityAt, + expandedActionsOpacityAt, getScreenHeaderLayout, headerHeightAt, labelOpacityAt, @@ -21,7 +24,7 @@ const WINDOWS = [ ] as const; const FONT_SCALES = [0.85, 1, 1.15, 1.2, 1.3, 2] as const; -const ACTION_COUNTS = [0, 1, 2] as const; +const ACTION_COUNTS = [0, 1, 2, 3] as const; /** How much of the width `Screen` can take away: nothing, a cutout, a docked player. */ const WIDTH_LOSSES = [0, 24, 48] as const; /** No pinned chrome, and Library's switcher + sort row. */ @@ -43,19 +46,22 @@ function cases(): Case[] { for (const actionCount of ACTION_COUNTS) { for (const loss of WIDTH_LOSSES) { for (const chromeHeight of CHROME_HEIGHTS) { - out.push({ - label: `${entry.name} ${orientation} ${width}x${height} fs${fontScale} sub:${hasSubtitle} back:${hasBack} actions:${actionCount} -${loss}w chrome:${chromeHeight}`, - input: { - availableWidth: width - loss, - windowHeight: height, - topInset, - fontScale, - hasSubtitle, - hasBack, - actionCount, - chromeHeight, - }, - }); + for (const hasExpandedActions of [false, true]) { + out.push({ + label: `${entry.name} ${orientation} ${width}x${height} fs${fontScale} sub:${hasSubtitle} back:${hasBack} actions:${actionCount} expanded:${hasExpandedActions} -${loss}w chrome:${chromeHeight}`, + input: { + availableWidth: width - loss, + windowHeight: height, + topInset, + fontScale, + hasSubtitle, + hasBack, + actionCount, + hasExpandedActions, + chromeHeight, + }, + }); + } } } } @@ -181,6 +187,36 @@ test('the collapse has room to read as motion', () => { } }); +test('expanded actions sit beside the title without adding height and hand off cleanly', () => { + const input = { + availableWidth: 360, + windowHeight: 780, + topInset: 40, + fontScale: 1, + hasSubtitle: false, + hasBack: false, + actionCount: 2, + }; + const bare = getScreenHeaderLayout(input); + const layout = getScreenHeaderLayout({ ...input, hasExpandedActions: true }); + assert.equal(layout.expandedHeight, bare.expandedHeight); + assert.equal(layout.expandedActionsHeight, SCREEN_HEADER_EXPANDED_ACTION_SIZE); + assert.ok(layout.expandedActionsTop >= layout.titleTop); + assert.ok(layout.expandedActionsTop + layout.expandedActionsHeight <= layout.titleTop + layout.titleLine); + assert.ok(layout.titleRight > bare.titleRight); + assert.equal(expandedActionsOpacityAt(0, layout.settle), 1); + assert.equal(compactActionsOpacityAt(0, layout.settle), 0); + assert.equal(expandedActionsOpacityAt(layout.settle, layout.settle), 0); + assert.equal(compactActionsOpacityAt(layout.settle, layout.settle), 1); + for (let y = 0; y <= layout.settle; y += 1) { + assert.ok( + expandedActionsOpacityAt(y, layout.settle) === 0 || + compactActionsOpacityAt(y, layout.settle) === 0, + `expanded and compact actions overlap at ${y}`, + ); + } +}); + test('a larger font setting only ever grows the header, and the cap binds', () => { for (const entry of WINDOWS) { for (const dims of [entry.portrait, entry.landscape]) { diff --git a/src/components/screenHeaderLayout.ts b/src/components/screenHeaderLayout.ts index 23a4cfd..dff036c 100644 --- a/src/components/screenHeaderLayout.ts +++ b/src/components/screenHeaderLayout.ts @@ -50,6 +50,10 @@ export const SCREEN_HEADER_ACTION_SIZE = 40; const ACTION_SIZE = SCREEN_HEADER_ACTION_SIZE; const ACTION_GAP = spacing.xs; const ACTION_RIGHT = spacing.md; +/** Compact controls seated beside the expanded title before moving into the + * bar. Kept fixed so layout and rendering share one declaration. */ +export const SCREEN_HEADER_EXPANDED_ACTION_SIZE = 40; +const EXPANDED_ACTIONS_GAP = spacing.xs; /** * Floor on how far the header travels. @@ -102,6 +106,10 @@ const LABEL_FADE_SHARE = 0.4; const LABEL_FADE_MAX = 45; const SUBTITLE_FADE_SHARE = 0.5; const SUBTITLE_LIFT = -12; +const EXPANDED_ACTIONS_FADE_SHARE = 0.48; +const EXPANDED_ACTIONS_LIFT = -14; +const COMPACT_ACTIONS_FADE_START_SHARE = 0.72; +const COMPACT_ACTIONS_FADE_END_SHARE = 0.94; export interface ScreenHeaderInput { /** Width inside `Screen`'s insets — not the window width. */ @@ -112,6 +120,8 @@ export interface ScreenHeaderInput { hasSubtitle: boolean; hasBack: boolean; actionCount: number; + /** Seats a standard pair of compact actions beside the expanded title. */ + hasExpandedActions?: boolean; /** * False on a screen whose destination is already named elsewhere — Library in * rail mode, where the rail carries the word. The header then contributes no @@ -161,6 +171,12 @@ export interface ScreenHeaderLayout { /** Top of the large title, below the inset. */ titleTop: number; titleLeft: number; + /** Trailing inset that keeps the travelling title clear of pinned actions. */ + titleRight: number; + /** Expanded-only action pair beside the title. */ + expandedActionsTop: number; + expandedActionsHeight: number; + expandedActionsWidth: number; /** Top of the subtitle line, below the inset. */ subtitleTop: number; /** Collapsed size as a share of the expanded size. Font-scale independent, so @@ -203,6 +219,7 @@ export function getScreenHeaderLayout({ hasSubtitle, hasBack, actionCount, + hasExpandedActions = false, hasTitle = true, chromeHeight = 0, }: ScreenHeaderInput): ScreenHeaderLayout { @@ -210,11 +227,16 @@ export function getScreenHeaderLayout({ const titleLine = Math.round(variantLineHeight.title * scale); const subLine = Math.round(variantLineHeight.label * scale); - const contentBlock = - TITLE_TOP + - titleLine + - (hasSubtitle ? TITLE_TO_SUB + subLine : 0) + - BLOCK_BOTTOM_PAD; + const titleBlockBottom = + TITLE_TOP + titleLine + (hasSubtitle ? TITLE_TO_SUB + subLine : 0); + const expandedActionsHeight = hasExpandedActions + ? SCREEN_HEADER_EXPANDED_ACTION_SIZE + : 0; + const expandedActionsTop = TITLE_TOP + (titleLine - expandedActionsHeight) / 2; + const expandedActionsWidth = hasExpandedActions + ? SCREEN_HEADER_EXPANDED_ACTION_SIZE * 2 + EXPANDED_ACTIONS_GAP + : 0; + const contentBlock = titleBlockBottom + BLOCK_BOTTOM_PAD; const natural = Math.max(contentBlock, SCREEN_BAR_H + MIN_COLLAPSE_DISTANCE); // The chrome is part of what the list must clear, so it counts against the @@ -236,7 +258,27 @@ export function getScreenHeaderLayout({ const titleLeft = SCREEN_HEADER_GUTTER; const travelX = hasBack ? BAR_TEXT_LEFT - titleLeft : 0; const titleScale = BAR_TITLE_SIZE / fontSize.xxl; - const titleWidth = Math.max(0, availableWidth - titleLeft * 2); + const naturalTitleWidth = Math.max(0, availableWidth - titleLeft * 2); + // The same title box is scaled and translated into the bar. With three + // pinned actions, a narrow window can no longer give that box the full + // expanded width without its collapsed edge entering the action cluster. + // Bound the expanded box from the collapsed geometry so the rendered width + // and the invariant below stay the same number. + const collapsedTitleWidth = actionsWidth > 0 + ? Math.max( + 0, + (availableWidth - actionsWidth - titleLeft - travelX) / titleScale, + ) + : naturalTitleWidth; + const titleWidth = Math.min(naturalTitleWidth, collapsedTitleWidth); + const expandedTitleRight = hasExpandedActions + ? ACTION_RIGHT + expandedActionsWidth + spacing.md + : titleLeft; + const titleRight = Math.max( + titleLeft, + availableWidth - titleLeft - titleWidth, + expandedTitleRight, + ); return { collapsible, @@ -253,6 +295,10 @@ export function getScreenHeaderLayout({ subLine, titleTop: TITLE_TOP, titleLeft, + titleRight, + expandedActionsTop, + expandedActionsHeight, + expandedActionsWidth, subtitleTop: TITLE_TOP + titleLine + TITLE_TO_SUB, titleScale, travelX, @@ -341,6 +387,46 @@ export function subtitleLiftAt(y: number, settle: number): number { return lerpClamped(y, 0, settle * SUBTITLE_FADE_SHARE, 0, SUBTITLE_LIFT); } +/** The deliberate expanded action row leaves early, before content reaches it. */ +export function expandedActionsOpacityAt(y: number, settle: number): number { + 'worklet'; + return lerpClamped(y, 0, settle * EXPANDED_ACTIONS_FADE_SHARE, 1, 0); +} + +export function expandedActionsLiftAt(y: number, settle: number): number { + 'worklet'; + return lerpClamped( + y, + 0, + settle * EXPANDED_ACTIONS_FADE_SHARE, + 0, + EXPANDED_ACTIONS_LIFT, + ); +} + +/** Compact bar icons arrive only after the expanded row has fully left. */ +export function compactActionsOpacityAt(y: number, settle: number): number { + 'worklet'; + return lerpClamped( + y, + settle * COMPACT_ACTIONS_FADE_START_SHARE, + settle * COMPACT_ACTIONS_FADE_END_SHARE, + 0, + 1, + ); +} + +export function compactActionsScaleAt(y: number, settle: number): number { + 'worklet'; + return lerpClamped( + y, + settle * COMPACT_ACTIONS_FADE_START_SHARE, + settle * COMPACT_ACTIONS_FADE_END_SHARE, + 0.78, + 1, + ); +} + /** Arrives last: the surface only earns its tint once content is behind it. */ export function barOpacityAt(y: number, settle: number): number { 'worklet';