diff --git a/ios/JellyfinAudioPlayer.xcodeproj/project.pbxproj b/ios/JellyfinAudioPlayer.xcodeproj/project.pbxproj index 15eb174..3101dd2 100644 --- a/ios/JellyfinAudioPlayer.xcodeproj/project.pbxproj +++ b/ios/JellyfinAudioPlayer.xcodeproj/project.pbxproj @@ -45,7 +45,7 @@ 00E356EE1AD99517003FC87E /* JellyfinAudioPlayerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JellyfinAudioPlayerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 00E356F21AD99517003FC87E /* JellyfinAudioPlayerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = JellyfinAudioPlayerTests.m; sourceTree = ""; }; - 13B07F961A680F5B00A75B9A /* JellyfinAudioPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JellyfinAudioPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07F961A680F5B00A75B9A /* Jellyfin Player.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Jellyfin Player.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = JellyfinAudioPlayer/AppDelegate.h; sourceTree = ""; }; 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = JellyfinAudioPlayer/AppDelegate.m; sourceTree = ""; }; 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; @@ -190,7 +190,7 @@ 83CBBA001A601CBA00E9B192 /* Products */ = { isa = PBXGroup; children = ( - 13B07F961A680F5B00A75B9A /* JellyfinAudioPlayer.app */, + 13B07F961A680F5B00A75B9A /* Jellyfin Player.app */, 00E356EE1AD99517003FC87E /* JellyfinAudioPlayerTests.xctest */, 2D02E47B1E0B4A5D006451C7 /* JellyfinAudioPlayer-tvOS.app */, 2D02E4901E0B4A5D006451C7 /* JellyfinAudioPlayer-tvOSTests.xctest */, @@ -237,7 +237,7 @@ ); name = JellyfinAudioPlayer; productName = JellyfinAudioPlayer; - productReference = 13B07F961A680F5B00A75B9A /* JellyfinAudioPlayer.app */; + productReference = 13B07F961A680F5B00A75B9A /* Jellyfin Player.app */; productType = "com.apple.product-type.application"; }; 2D02E47A1E0B4A5D006451C7 /* JellyfinAudioPlayer-tvOS */ = { @@ -651,7 +651,7 @@ "-lc++", ); PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; - PRODUCT_NAME = JellyfinAudioPlayer; + PRODUCT_NAME = "Jellyfin Player"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -674,7 +674,7 @@ "-lc++", ); PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; - PRODUCT_NAME = JellyfinAudioPlayer; + PRODUCT_NAME = "Jellyfin Player"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; diff --git a/ios/JellyfinAudioPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinAudioPlayer.xcscheme b/ios/JellyfinAudioPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinAudioPlayer.xcscheme index 6b06247..2903087 100644 --- a/ios/JellyfinAudioPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinAudioPlayer.xcscheme +++ b/ios/JellyfinAudioPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinAudioPlayer.xcscheme @@ -15,7 +15,7 @@ @@ -55,7 +55,7 @@ @@ -72,7 +72,7 @@ diff --git a/ios/JellyfinAudioPlayer/Info.plist b/ios/JellyfinAudioPlayer/Info.plist index c385a06..9f5ec40 100644 --- a/ios/JellyfinAudioPlayer/Info.plist +++ b/ios/JellyfinAudioPlayer/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - JellyfinAudioPlayer + $(PRODUCT_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3589317..b93cf5a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -325,6 +325,8 @@ PODS: - React - RNSVG (12.1.0): - React + - RNTableView (3.0.0): + - React - SDWebImage (5.8.1): - SDWebImage/Core (= 5.8.1) - SDWebImage/Core (5.8.1) @@ -393,6 +395,7 @@ DEPENDENCIES: - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) + - RNTableView (from `../node_modules/react-native-tableview`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -484,6 +487,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-screens" RNSVG: :path: "../node_modules/react-native-svg" + RNTableView: + :path: "../node_modules/react-native-tableview" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -514,9 +519,9 @@ SPEC CHECKSUMS: React-jsi: b6dc94a6a12ff98e8877287a0b7620d365201161 React-jsiexecutor: 1540d1c01bb493ae3124ed83351b1b6a155db7da React-jsinspector: 512e560d0e985d0e8c479a54a4e5c147a9c83493 - react-native-safe-area-context: e9a863c2dbbc0d42d2b12209d63fa79dfbc9662c + react-native-safe-area-context: e768fca90207ee68924b3d0877633f2ce9cc9d68 react-native-track-player: ba2416753b58f3cdf9db5a07daa65876d659f925 - react-native-webview: 938d49de66f0d5c95134f19682243cbe53294f51 + react-native-webview: 40bbeb6d011226f34cb83f845aeb0fdf515cfc5f React-RCTActionSheet: f41ea8a811aac770e0cc6e0ad6b270c644ea8b7c React-RCTAnimation: 49ab98b1c1ff4445148b72a3d61554138565bad0 React-RCTBlob: a332773f0ebc413a0ce85942a55b064471587a71 @@ -527,14 +532,15 @@ SPEC CHECKSUMS: React-RCTText: fae545b10cfdb3d247c36c56f61a94cfd6dba41d React-RCTVibration: 4356114dbcba4ce66991096e51a66e61eda51256 ReactCommon: ed4e11d27609d571e7eee8b65548efc191116eb3 - RNCAsyncStorage: db711e29e5e0500d9bd21aa0c2e397efa45302b1 - RNCMaskedView: f5c7d14d6847b7b44853f7acb6284c1da30a3459 + RNCAsyncStorage: d059c3ee71738c39834a627476322a5a8cd5bf36 + RNCMaskedView: 5a8ec07677aa885546a0d98da336457e2bea557f RNCPicker: 1c63b084bcbcd33d159a83144543f3bda4cd1793 RNFastImage: 35ae972d6727c84ee3f5c6897e07f84d0a3445e9 RNGestureHandler: 8f09cd560f8d533eb36da5a6c5a843af9f056b38 RNReanimated: b5ccb50650ba06f6e749c7c329a1bc3ae0c88b43 RNScreens: 62211832af51e0aebcf6e8c36bcf7dd65592f244 RNSVG: ce9d996113475209013317e48b05c21ee988d42e + RNTableView: e8723c30aec3b259222a12e7d05d763566286a46 SDWebImage: e3eae2eda88578db0685a0c88597fdadd9433f05 SDWebImageWebPCoder: 36f8f47bd9879a8aea6044765c1351120fd8e3a8 Yoga: 3ebccbdd559724312790e7742142d062476b698e diff --git a/package-lock.json b/package-lock.json index 2db1bc3..f779078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8823,6 +8823,11 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-2.8.0.tgz", "integrity": "sha512-fUCIQLZX+5XB0ypWX038P3zso54IFFjTsQUZJWEsjC3pp5rPItAt5SzqtJn+uVjcJaczZ+dpIuvj6IFLqkWLZQ==" }, + "react-native-section-list-get-item-layout": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-native-section-list-get-item-layout/-/react-native-section-list-get-item-layout-2.2.3.tgz", + "integrity": "sha512-fzCW5SiYP6qCZyDHebaElHonIFr8NFrZK9JDkxFLnpxMJih4d+HQ4rHyOs0Z4Gb/FjyCVbRH7RtEnjeQ0XffMg==" + }, "react-native-svg": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-12.1.0.tgz", @@ -8841,6 +8846,14 @@ "glob": "^7.1.2" } }, + "react-native-tableview": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-native-tableview/-/react-native-tableview-3.0.0.tgz", + "integrity": "sha512-EnCNq5uKLEFsg3n7ykEvbo++sW/zUtg4cfJKYOf45s2pZoSAWvBqAjyx4G7uqhaoCLcjjDr8JiHlY62oaU7eUA==", + "requires": { + "prop-types": "^15.6.2" + } + }, "react-native-track-player": { "version": "github:leinelissen/react-native-track-player#dafc8ffc0ee4bb3cdeb0fa21530c32dafa5a4dab", "from": "github:leinelissen/react-native-track-player" diff --git a/package.json b/package.json index fb9fa35..3ad4997 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "react-native-reanimated": "^1.9.0", "react-native-safe-area-context": "^3.0.5", "react-native-screens": "^2.8.0", + "react-native-section-list-get-item-layout": "^2.2.3", "react-native-svg": "^12.1.0", "react-native-swift": "^1.2.3", + "react-native-tableview": "^3.0.0", "react-native-track-player": "github:leinelissen/react-native-track-player", "react-native-webview": "^10.3.1", "react-redux": "^7.2.0", diff --git a/src/CONSTANTS.ts b/src/CONSTANTS.ts index a4f2ec2..776b4d2 100644 --- a/src/CONSTANTS.ts +++ b/src/CONSTANTS.ts @@ -1 +1,2 @@ -export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7; \ No newline at end of file +export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7; +export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ '; \ No newline at end of file diff --git a/src/components/AlphabetScroller.tsx b/src/components/AlphabetScroller.tsx new file mode 100644 index 0000000..072527f --- /dev/null +++ b/src/components/AlphabetScroller.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useRef, useState } from 'react'; +import styled from 'styled-components/native'; +import { ALPHABET_LETTERS } from 'CONSTANTS'; +import { View, LayoutChangeEvent } from 'react-native'; +import { TouchableWithoutFeedback, PanGestureHandler, PanGestureHandlerGestureEvent, TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; + +interface LetterContainerProps { + onPress: (letter: string) => void; + letter: string; +} + +const LetterContainer: React.FC = ({ children, letter, onPress }) => { + const handlePress = useCallback(() => { + onPress(letter); + }, [letter, onPress]); + + return ( + + {children} + + ); +}; + +const Container = styled.View` + position: absolute; + right: 5px; + top: 0; + height: 100%; + z-index: 10; + padding: 5px; + margin: auto 0; + justify-content: space-around; +`; + +const Letter = styled.Text` + text-align: center; + padding: 1px 0; +`; + +interface Props { + onSelect: (index: number) => void; +} + +/** + * A generic component that introduces a scrolling bar on the right side of the + * screen with all letters of the Alphabet. + */ +const AlphabetScroller: React.FC = ({ onSelect }) => { + const [ height, setHeight ] = useState(0); + const [ index, setIndex ] = useState(); + + // Handler for setting the correct height for a single alphabet item + const handleLayout = useCallback((event: LayoutChangeEvent) => { + setHeight(event.nativeEvent.layout.height); + }, []); + + // Handler for passing on a new index when it is tapped or swiped + const handleGestureEvent = useCallback((event: PanGestureHandlerGestureEvent | TapGestureHandlerGestureEvent) => { + const newIndex = Math.floor(event.nativeEvent.y / height); + + if (newIndex !== index) { + setIndex(newIndex); + onSelect(newIndex); + } + }, [height, index]); + + return ( + + + + + {ALPHABET_LETTERS.split('').map((l, i) => ( + + {l} + + ))} + + + + + ); +}; + +export default AlphabetScroller; diff --git a/src/screens/Music/stacks/Albums.tsx b/src/screens/Music/stacks/Albums.tsx index 213322d..7623a9c 100644 --- a/src/screens/Music/stacks/Albums.tsx +++ b/src/screens/Music/stacks/Albums.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, PureComponent, ReactText } from 'react'; import { useGetImage } from 'utility/JellyfinApi'; import { Album, NavigationProp } from '../types'; -import { Text, SafeAreaView, FlatList } from 'react-native'; +import { Text, SafeAreaView, SectionList, View } from 'react-native'; import { useDispatch } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { differenceInDays } from 'date-fns'; @@ -11,24 +11,164 @@ import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS'; import TouchableHandler from 'components/TouchableHandler'; import ListContainer from './components/ListContainer'; import AlbumImage, { AlbumItem } from './components/AlbumImage'; -import { useAlbumsByArtist } from 'store/music/selectors'; +import { selectAlbumsByAlphabet, SectionedId } from 'store/music/selectors'; +import AlphabetScroller from 'components/AlphabetScroller'; +import { EntityId } from '@reduxjs/toolkit'; + +interface VirtualizedItemInfo { + section: SectionedId, + // Key of the section or combined key for section + item + key: string, + // Relative index within the section + index: number, + // True if this is the section header + header?: boolean, + leadingItem?: EntityId, + leadingSection?: SectionedId, + trailingItem?: EntityId, + trailingSection?: SectionedId, +} + +type VirtualizedSectionList = { _subExtractor: (index: number) => VirtualizedItemInfo }; + +function generateSection({ section }: { section: SectionedId }) { + return ( + + ); +} + +class SectionHeading extends PureComponent<{ label: string }> { + render() { + const { label } = this.props; + + return ( + + {label} + + ); + } +} + +interface GeneratedAlbumItemProps { + id: ReactText; + imageUrl: string; + name: string; + artist: string; + onPress: (id: string) => void; +} + +class GeneratedAlbumItem extends PureComponent { + render() { + const { id, imageUrl, name, artist, onPress } = this.props; + + return ( + + + + {name} + {artist} + + + ); + } +} + +// const getItemLayout: any = sectionListGetItemLayout({ +// getItemHeight: (rowData, sectionIndex, rowIndex) => { +// console.log(sectionIndex, rowIndex, rowData); +// if (sectionIndex === 0) { return 0; } +// else if (rowIndex % 2 > 0) { return 0; } +// return 220; +// }, +// getSectionHeaderHeight: () => 50, +// // getSeparatorHeight: () => 1 / PixelRatio.get(), +// // listHeaderHeight: 0, +// }); const Albums: React.FC = () => { // Retrieve data from store const { entities: albums } = useTypedSelector((state) => state.music.albums); - const ids = useAlbumsByArtist(); const isLoading = useTypedSelector((state) => state.music.albums.isLoading); const lastRefreshed = useTypedSelector((state) => state.music.lastRefreshed); + const sections = useTypedSelector(selectAlbumsByAlphabet); // Initialise helpers const dispatch = useDispatch(); const navigation = useNavigation(); const getImage = useGetImage(); + const listRef = useRef>(null); + + const getItemLayout = useCallback((data: SectionedId[] | null, index: number): { offset: number, length: number, index: number } => { + // We must wait for the ref to become available before we can use the + // native item retriever in VirtualizedSectionList + if (!listRef.current) { + return { offset: 0, length: 0, index }; + } + + // Retrieve the right item info + // @ts-ignore + const wrapperListRef = (listRef.current?._wrapperListRef) as VirtualizedSectionList; + const info: VirtualizedItemInfo = wrapperListRef._subExtractor(index); + const { index: itemIndex, header, key } = info; + const sectionIndex = parseInt(key.split(':')[0]); + + // We can then determine the "length" (=height) of this item. Header items + // end up with an itemIndex of -1, thus are easy to identify. + const length = header ? 50 : (itemIndex % 2 === 0 ? 220 : 0); + + // We'll also need to account for any unevenly-ended lists up until the + // current item. + const previousRows = data?.filter((row, i) => i < sectionIndex) + .reduce((sum, row) => sum + Math.ceil(row.data.length / 2), 0) || 0; + + // We must also calcuate the offset, total distance from the top of the + // screen. First off, we'll account for each sectionIndex that is shown up + // until now. This only includes the heading for the current section if the + // item is not the section header + const headingOffset = 50 * (header ? sectionIndex : sectionIndex + 1); + const currentRows = itemIndex > 1 ? Math.ceil((itemIndex + 1) / 2) : 0; + const itemOffset = 220 * (previousRows + currentRows); + const offset = headingOffset + itemOffset; + + // console.log(index, sectionIndex, itemIndex, previousRows, currentRows, offset); + + return { index, length, offset }; + }, [listRef]); // Set callbacks const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]); const selectAlbum = useCallback((id: string) => navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]); - + const selectLetter = useCallback((sectionIndex: number) => { + listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, }); + }, [listRef]); + const generateItem = ({ item, index, section }: { item: EntityId, index: number, section: SectionedId }) => { + if (index % 2 === 1) { + return null; + } + const nextItem = section.data[index + 1]; + + return ( + + + {albums[nextItem] && + + } + + ); + }; + // Retrieve data on mount useEffect(() => { // GUARD: Only refresh this API call every set amounts of days @@ -40,21 +180,17 @@ const Albums: React.FC = () => { return ( - + d} - renderItem={({ item }) => ( - - - - {albums[item]?.Name} - {albums[item]?.AlbumArtist} - - - )} + getItemLayout={getItemLayout} + keyExtractor={(d) => d as string} + ref={listRef} + onScrollToIndexFailed={console.log} + renderSectionHeader={generateSection} + renderItem={generateItem} /> diff --git a/src/screens/Music/stacks/components/AlbumImage.ts b/src/screens/Music/stacks/components/AlbumImage.ts index 9735f2b..de8eae0 100644 --- a/src/screens/Music/stacks/components/AlbumImage.ts +++ b/src/screens/Music/stacks/components/AlbumImage.ts @@ -7,6 +7,7 @@ const Screen = Dimensions.get('screen'); export const AlbumItem = styled.View` width: ${Screen.width / 2 - 10}px; padding: 10px; + height: 220px; `; const AlbumImage = styled(FastImage)` diff --git a/src/store/music/selectors.ts b/src/store/music/selectors.ts index 2e8071b..d6375d2 100644 --- a/src/store/music/selectors.ts +++ b/src/store/music/selectors.ts @@ -1,6 +1,8 @@ -import { useTypedSelector } from 'store'; +import { useTypedSelector, AppState } from 'store'; import { parseISO } from 'date-fns'; -import { Album } from './types'; +import { ALPHABET_LETTERS } from 'CONSTANTS'; +import { createSelector, EntityId } from '@reduxjs/toolkit'; +import { SectionListData } from 'react-native'; /** * Retrieves a list of the n most recent albums @@ -20,9 +22,11 @@ export function useRecentAlbums(amount: number) { return sorted.slice(0, amount); } -export function useAlbumsByArtist() { - const albums = useTypedSelector((state) => state.music.albums.entities); - const albumIds = useTypedSelector((state) => state.music.albums.ids); +/** + * Sort all albums by AlbumArtist + */ +function albumsByArtist(state: AppState['music']['albums']) { + const { entities: albums, ids: albumIds } = state; const sorted = [...albumIds].sort((a, b) => { const albumA = albums[a]; @@ -39,4 +43,37 @@ export function useAlbumsByArtist() { }); return sorted; -} \ No newline at end of file +} + +export const selectAlbumsByArtist = createSelector( + (state: AppState) => state.music.albums, + albumsByArtist, +); + +export type SectionedId = SectionListData; + +/** + * Splits a set of albums into a list that is split by alphabet letters + */ +function splitByAlphabet(state: AppState['music']['albums']): SectionedId[] { + const { entities: albums } = state; + const albumIds = albumsByArtist(state); + const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] })); + + albumIds.forEach((id) => { + const album = albums[id]; + const letter = album?.AlbumArtist?.toUpperCase().charAt(0); + const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26; + (sections[index >= 0 ? index : 26].data as Array).push(id); + }); + + return sections; +} + +/** + * Wrap splitByAlphabet into a memoized selector + */ +export const selectAlbumsByAlphabet = createSelector( + (state: AppState) => state.music.albums, + splitByAlphabet, +); \ No newline at end of file