From aec4118d466ff0d20967232090c49509c3da36e2 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Sun, 10 Mar 2024 15:52:03 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=BB=20POC=20EpubJS=20RN=20reader=20(#2?= =?UTF-8?q?89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganized a bit of the reader screen to account for future readers a bit better, and added a really bare-bones EPUB reader. This frankly will not hold up in time, IMO, and I'll likely either have to move off of epubjs in favor of an alternative that works well with RN or just build something in house. --- apps/expo/package.json | 6 +- apps/expo/src/App.tsx | 3 +- .../components/reader/UnsupportedReader.tsx | 11 ++ .../components/reader/epub/EpubJSFooter.tsx | 27 ++++ .../components/reader/epub/EpubJSReader.tsx | 125 +++++++++++++++ .../reader/epub/EpubJSReaderContainer.tsx | 20 +++ .../components/reader/epub/LoadingSpinner.tsx | 39 +++++ apps/expo/src/components/reader/epub/index.ts | 1 + .../reader/image/ImageBasedReader.tsx} | 151 ++++++++++-------- .../reader/image/ReaderContainer.tsx | 10 ++ .../expo/src/components/reader/image/index.ts | 1 + apps/expo/src/components/reader/index.ts | 3 + .../authenticated/book/BookReaderScreen.tsx | 43 +++++ .../authenticated/book/BookStackNavigator.tsx | 8 +- .../authenticated/book/reader/index.ts | 1 - .../screens/authenticated/explore/Explore.tsx | 2 +- apps/expo/src/stores/index.ts | 1 + apps/expo/src/stores/reader.ts | 4 + .../components/readers/epub/EpubJsReader.tsx | 3 +- packages/client/src/index.ts | 1 + packages/client/src/utils.ts | 3 + packages/types/generated.ts | 2 +- yarn.lock | 50 +++++- 23 files changed, 433 insertions(+), 82 deletions(-) create mode 100644 apps/expo/src/components/reader/UnsupportedReader.tsx create mode 100644 apps/expo/src/components/reader/epub/EpubJSFooter.tsx create mode 100644 apps/expo/src/components/reader/epub/EpubJSReader.tsx create mode 100644 apps/expo/src/components/reader/epub/EpubJSReaderContainer.tsx create mode 100644 apps/expo/src/components/reader/epub/LoadingSpinner.tsx create mode 100644 apps/expo/src/components/reader/epub/index.ts rename apps/expo/src/{screens/authenticated/book/reader/BookReader.tsx => components/reader/image/ImageBasedReader.tsx} (61%) create mode 100644 apps/expo/src/components/reader/image/ReaderContainer.tsx create mode 100644 apps/expo/src/components/reader/image/index.ts create mode 100644 apps/expo/src/components/reader/index.ts create mode 100644 apps/expo/src/screens/authenticated/book/BookReaderScreen.tsx delete mode 100644 apps/expo/src/screens/authenticated/book/reader/index.ts create mode 100644 apps/expo/src/stores/reader.ts create mode 100644 packages/client/src/utils.ts diff --git a/apps/expo/package.json b/apps/expo/package.json index c1a4505cc..129d3609e 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -7,6 +7,8 @@ "web": "expo start --web" }, "dependencies": { + "@epubjs-react-native/core": "^1.3.0", + "@epubjs-react-native/expo-file-system": "^1.0.0", "@hookform/resolvers": "^3.3.2", "@react-native-async-storage/async-storage": "1.21.0", "@react-navigation/bottom-tabs": "^6.5.12", @@ -27,12 +29,14 @@ "react-dom": "18.2.0", "react-hook-form": "^7.50.1", "react-native": "0.73.4", - "react-native-gesture-handler": "~2.14.1", + "react-native-fs": "^2.20.0", + "react-native-gesture-handler": "~2.14.0", "react-native-reanimated": "~3.6.2", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", "react-native-svg": "14.1.0", "react-native-web": "~0.19.10", + "react-native-webview": "13.6.4", "tailwind-merge": "^1.14.0", "zod": "^3.22.4", "zustand": "^4.5.0" diff --git a/apps/expo/src/App.tsx b/apps/expo/src/App.tsx index 87e2fbe9e..0f5fac98c 100644 --- a/apps/expo/src/App.tsx +++ b/apps/expo/src/App.tsx @@ -54,8 +54,9 @@ export default function AppWrapper() { // TODO: remove, just debugging stuff useEffect(() => { - setBaseUrl('https://demo.stumpapp.dev') + // setBaseUrl('https://demo.stumpapp.dev') // setBaseUrl('http://localhost:10801') + setBaseUrl('http://192.168.0.202:10801') }, [setBaseUrl]) useEffect(() => { diff --git a/apps/expo/src/components/reader/UnsupportedReader.tsx b/apps/expo/src/components/reader/UnsupportedReader.tsx new file mode 100644 index 000000000..3a6f7ad1a --- /dev/null +++ b/apps/expo/src/components/reader/UnsupportedReader.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import { ScreenRootView, Text } from '../primitives' + +export default function UnsupportedReader() { + return ( + + The book reader for this format is not supported yet. Check back later! + + ) +} diff --git a/apps/expo/src/components/reader/epub/EpubJSFooter.tsx b/apps/expo/src/components/reader/epub/EpubJSFooter.tsx new file mode 100644 index 000000000..3d7f66a03 --- /dev/null +++ b/apps/expo/src/components/reader/epub/EpubJSFooter.tsx @@ -0,0 +1,27 @@ +import { useReader } from '@epubjs-react-native/core' +import React from 'react' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +import { Text, View } from '@/components/primitives' + +export const FOOTER_HEIGHT = 24 + +export default function EpubJSFooter() { + const { currentLocation } = useReader() + + const { bottom } = useSafeAreaInsets() + + const currentPage = currentLocation?.start?.displayed.page || 1 + const totalPages = currentLocation?.end?.displayed.page || 1 + + return ( + + + {currentPage}/{totalPages} + + + ) +} diff --git a/apps/expo/src/components/reader/epub/EpubJSReader.tsx b/apps/expo/src/components/reader/epub/EpubJSReader.tsx new file mode 100644 index 000000000..d1c269138 --- /dev/null +++ b/apps/expo/src/components/reader/epub/EpubJSReader.tsx @@ -0,0 +1,125 @@ +import { Location, Reader } from '@epubjs-react-native/core' +import { useFileSystem } from '@epubjs-react-native/expo-file-system' +import { API, isAxiosError, updateEpubProgress } from '@stump/api' +import { Media } from '@stump/types' +import { useColorScheme } from 'nativewind' +import React, { useCallback, useEffect, useState } from 'react' +import { useWindowDimensions } from 'react-native' + +import EpubJSReaderContainer from './EpubJSReaderContainer' + +type Props = { + /** + * The media which is being read + */ + book: Media + /** + * The initial CFI to start the reader on + */ + initialCfi?: string + /** + * Whether the reader should be in incognito mode + */ + incognito?: boolean +} + +/** + * A reader for books that are EPUBs, using EpubJS as the reader + * + * TODO: create a custom reader component, this is a HUGE effort but will pay off in + * the long run + */ +export default function EpubJSReader({ book, initialCfi, incognito }: Props) { + /** + * The base64 representation of the book file. The reader component does not accept + * credentials in the fetch, so we just have to fetch manually and pass the base64 + * representation to the reader as the source. + */ + const [base64, setBase64] = useState(null) + + const { width, height } = useWindowDimensions() + const { colorScheme } = useColorScheme() + + /** + * An effect that fetches the book file and loads it into the reader component + * as a base64 string + */ + useEffect(() => { + async function fetchBook() { + try { + const response = await fetch(`${API.getUri()}/media/${book.id}/file`) + const data = await response.blob() + const reader = new FileReader() + reader.onloadend = () => { + const result = reader.result as string + // Note: uncomment this line to show an infinite loader... + // setBase64(result) + const adjustedResult = result.split(',')[1] || result + setBase64(adjustedResult) + } + reader.readAsDataURL(data) + } catch (e) { + console.error(e) + } + } + + fetchBook() + }, [book.id]) + + /** + * A callback that updates the read progress of the current location + * + * If the reader is in incognito mode, this will do nothing. + */ + const handleLocationChanged = useCallback( + async (_: number, currentLocation: Location, progress: number) => { + if (!incognito) { + const { + start: { cfi }, + } = currentLocation + + try { + await updateEpubProgress({ + epubcfi: cfi, + id: book.id, + is_complete: progress >= 1.0, + percentage: progress, + }) + } catch (e) { + console.error(e) + if (isAxiosError(e)) { + console.error(e.response?.data) + } + } + } + }, + [incognito, book.id], + ) + + if (!base64) { + return null + } + + return ( + + console.error(error)} + width={width} + // height={height - height * 0.08} + height={height} + fileSystem={useFileSystem} + initialLocation={initialCfi} + onLocationChange={handleLocationChanged} + // renderLoadingFileComponent={LoadingSpinner} + defaultTheme={ + colorScheme === 'dark' + ? { + body: { background: '#0F1011 !important', color: '#E8EDF4' }, + } + : { body: { color: 'black' } } + } + /> + + ) +} diff --git a/apps/expo/src/components/reader/epub/EpubJSReaderContainer.tsx b/apps/expo/src/components/reader/epub/EpubJSReaderContainer.tsx new file mode 100644 index 000000000..bc61d6ca7 --- /dev/null +++ b/apps/expo/src/components/reader/epub/EpubJSReaderContainer.tsx @@ -0,0 +1,20 @@ +import { ReaderProvider } from '@epubjs-react-native/core' +import React from 'react' + +import { ScreenRootView, View } from '@/components/primitives' + +type Props = { + children: React.ReactNode +} + +// total ass, I hate epubjs lol maybe im just dumb? I cannot get the reader to listen to the height +export default function EpubJSReaderContainer({ children }: Props) { + return ( + + + {children} + {/* */} + + + ) +} diff --git a/apps/expo/src/components/reader/epub/LoadingSpinner.tsx b/apps/expo/src/components/reader/epub/LoadingSpinner.tsx new file mode 100644 index 000000000..a8a7facc3 --- /dev/null +++ b/apps/expo/src/components/reader/epub/LoadingSpinner.tsx @@ -0,0 +1,39 @@ +import { LoadingFileProps } from '@epubjs-react-native/core' +import React, { useEffect, useState } from 'react' + +import { Text, View } from '@/components/primitives' + +// FIXME: This causes an error... +export default function LoadingSpinner({ + // downloadProgress, + // downloadError, + downloadSuccess, +}: LoadingFileProps) { + // Setup a timeout that will check if we are stuck loading, abougt 10 seconds + const [didTimeout, setDidTimeout] = useState(false) + + // If we are still loading after 10 seconds, we are stuck + useEffect(() => { + const timeout = setTimeout(() => { + setDidTimeout(true) + }, 10000) + + return () => clearTimeout(timeout) + }, []) + + if (didTimeout && !downloadSuccess) { + return ( + + It looks like we are stuck loading the book. Check your server logs + + ) + } else if (!downloadSuccess) { + return ( + + Loading... + + ) + } else { + return null + } +} diff --git a/apps/expo/src/components/reader/epub/index.ts b/apps/expo/src/components/reader/epub/index.ts new file mode 100644 index 000000000..fd31a8108 --- /dev/null +++ b/apps/expo/src/components/reader/epub/index.ts @@ -0,0 +1 @@ +export { default as EpubJSReader } from './EpubJSReader' diff --git a/apps/expo/src/screens/authenticated/book/reader/BookReader.tsx b/apps/expo/src/components/reader/image/ImageBasedReader.tsx similarity index 61% rename from apps/expo/src/screens/authenticated/book/reader/BookReader.tsx rename to apps/expo/src/components/reader/image/ImageBasedReader.tsx index 9ae93b6b4..87e471f91 100644 --- a/apps/expo/src/screens/authenticated/book/reader/BookReader.tsx +++ b/apps/expo/src/components/reader/image/ImageBasedReader.tsx @@ -1,20 +1,17 @@ -import { RouteProp, useRoute } from '@react-navigation/native' import { getMediaPage, isAxiosError } from '@stump/api' -import { useMediaByIdQuery, useUpdateMediaProgress } from '@stump/client' +import { useUpdateMediaProgress } from '@stump/client' +import { Media } from '@stump/types' import { useColorScheme } from 'nativewind' import React, { useCallback, useMemo, useState } from 'react' -import { FlatList, useWindowDimensions } from 'react-native' +import { FlatList, TouchableWithoutFeedback, useWindowDimensions } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { ScreenRootView, Text, View } from '@/components' +import { View } from '@/components' import EntityImage from '@/components/EntityImage' import { gray } from '@/constants/colors' +import { useReaderStore } from '@/stores' -type Params = { - params: { - id: string - } -} +import ReaderContainer from './ReaderContainer' type ImageDimension = { height: number @@ -27,29 +24,39 @@ type ImageDimension = { // TODO: Account for device orientation AND reading direction +type Props = { + /** + * The media which is being read + */ + book: Media + /** + * The initial page to start the reader on + */ + initialPage: number + /** + * Whether the reader should be in incognito mode + */ + incognito?: boolean +} + /** - * A sort of weigh station that renders the corresponding reader for a given media, e.g. - * EPUBReader, ImageBasedReader, etc. + * A reader for books that are image-based, where each page should be displayed as an image */ -export default function BookReader() { - const { - params: { id }, - } = useRoute>() - +export default function ImageBasedReader({ book, initialPage, incognito }: Props) { const { height, width } = useWindowDimensions() const { colorScheme } = useColorScheme() const [imageSizes, setImageHeights] = useState>({}) // const lastPrefetchStart = useRef(0) + const readerMode = useReaderStore((state) => state.mode) const deviceOrientation = width > height ? 'landscape' : 'portrait' // TODO: an effect that whenever the device orienation changes to something different than before, // recalculate the ratios of the images? Maybe. Who knows, you will though - const { isLoading: fetchingBook, media } = useMediaByIdQuery(id) - const { updateReadProgressAsync } = useUpdateMediaProgress(id) + const { updateReadProgressAsync } = useUpdateMediaProgress(book.id) // FIXME: this was HARD erroring... @@ -73,41 +80,39 @@ export default function BookReader() { /** * A callback that updates the read progress of the current page. This will be * called whenever the user changes the page in the reader. + * + * If the reader is in incognito mode, this will do nothing. */ const handleCurrentPageChanged = useCallback( async (page: number) => { - try { - await updateReadProgressAsync(page) - // if (page - lastPrefetchStart.current > 5) { - // await prefetchPages(page, page + 5) - // } - // lastPrefetchStart.current = page - } catch (e) { - console.error(e) - if (isAxiosError(e)) { - console.error(e.response?.data) + if (!incognito) { + try { + await updateReadProgressAsync(page) + // if (page - lastPrefetchStart.current > 5) { + // await prefetchPages(page, page + 5) + // } + // lastPrefetchStart.current = page + } catch (e) { + console.error(e) + if (isAxiosError(e)) { + console.error(e.response?.data) + } } } }, - [updateReadProgressAsync], + [updateReadProgressAsync, incognito], ) - if (fetchingBook) { - return Loading... - } else if (!media) { - return Book not found - } - return ( - + i)} + data={Array.from({ length: book.pages }, (_, i) => i)} renderItem={({ item }) => ( )} keyExtractor={(item) => item.toString()} - horizontal - pagingEnabled + horizontal={readerMode === 'paged'} + pagingEnabled={readerMode === 'paged'} onViewableItemsChanged={({ viewableItems }) => { const fistVisibleItemIdx = viewableItems .filter(({ isViewable }) => isViewable) @@ -130,8 +135,9 @@ export default function BookReader() { }} initialNumToRender={10} maxToRenderPerBatch={10} + initialScrollIndex={initialPage - 1} /> - + ) } @@ -160,6 +166,15 @@ const Page = React.memo( }: PageProps) => { const insets = useSafeAreaInsets() + const { showToolBar, setShowToolBar } = useReaderStore((state) => ({ + setShowToolBar: state.setShowToolBar, + showToolBar: state.showToolBar, + })) + + const handlePress = useCallback(() => { + setShowToolBar(!showToolBar) + }, [showToolBar, setShowToolBar]) + /** * A memoized value that represents the size(s) of the image dimensions for the current page. */ @@ -193,34 +208,36 @@ const Page = React.memo( }, [deviceOrientation, pageSize, safeMaxHeight, maxWidth]) return ( - - + { - setImageHeights((prev) => ({ - ...prev, - [index + 1]: { - height, - ratio: deviceOrientation == 'landscape' ? height / width : width / height, - width, - }, - })) - }} - /> - + > + { + setImageHeights((prev) => ({ + ...prev, + [index + 1]: { + height, + ratio: deviceOrientation == 'landscape' ? height / width : width / height, + width, + }, + })) + }} + /> + + ) }, ) diff --git a/apps/expo/src/components/reader/image/ReaderContainer.tsx b/apps/expo/src/components/reader/image/ReaderContainer.tsx new file mode 100644 index 000000000..7473286b3 --- /dev/null +++ b/apps/expo/src/components/reader/image/ReaderContainer.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +import { ScreenRootView } from '@/components/primitives' + +type Props = { + children: React.ReactNode +} +export default function ReaderContainer({ children }: Props) { + return {children} +} diff --git a/apps/expo/src/components/reader/image/index.ts b/apps/expo/src/components/reader/image/index.ts new file mode 100644 index 000000000..050d06f3a --- /dev/null +++ b/apps/expo/src/components/reader/image/index.ts @@ -0,0 +1 @@ +export { default as ImageBasedReader } from './ImageBasedReader' diff --git a/apps/expo/src/components/reader/index.ts b/apps/expo/src/components/reader/index.ts new file mode 100644 index 000000000..7ac0863e1 --- /dev/null +++ b/apps/expo/src/components/reader/index.ts @@ -0,0 +1,3 @@ +export { EpubJSReader } from './epub' +export { ImageBasedReader } from './image' +export { default as UnsupportedReader } from './UnsupportedReader' diff --git a/apps/expo/src/screens/authenticated/book/BookReaderScreen.tsx b/apps/expo/src/screens/authenticated/book/BookReaderScreen.tsx new file mode 100644 index 000000000..83ad45f3a --- /dev/null +++ b/apps/expo/src/screens/authenticated/book/BookReaderScreen.tsx @@ -0,0 +1,43 @@ +import { RouteProp, useRoute } from '@react-navigation/native' +import { ARCHIVE_EXTENSION, EBOOK_EXTENSION, PDF_EXTENSION, useMediaByIdQuery } from '@stump/client' +import React from 'react' + +import { EpubJSReader, ImageBasedReader, UnsupportedReader } from '@/components/reader' + +type Params = { + params: { + id: string + restart?: boolean + incognito?: boolean + } +} + +/** + * A sort of weigh station that renders the corresponding reader for a given media + */ +export default function BookReaderScreen() { + const { + params: { id, restart, incognito }, + } = useRoute>() + + const { isLoading: fetchingBook, media: book } = useMediaByIdQuery(id) + + if (fetchingBook) { + return null + } + + if (book.extension.match(EBOOK_EXTENSION)) { + const currentProgressCfi = book.current_epubcfi || undefined + const initialCfi = restart ? undefined : currentProgressCfi + return + } else if (book.extension.match(ARCHIVE_EXTENSION) || book.extension.match(PDF_EXTENSION)) { + const currentProgressPage = book.current_page || 1 + const initialPage = restart ? 1 : currentProgressPage + return + } + + // TODO: support native PDF reader? + // else if (book.extension.match(PDF_EXTENSION)) {} + + return +} diff --git a/apps/expo/src/screens/authenticated/book/BookStackNavigator.tsx b/apps/expo/src/screens/authenticated/book/BookStackNavigator.tsx index 92269f594..81659290e 100644 --- a/apps/expo/src/screens/authenticated/book/BookStackNavigator.tsx +++ b/apps/expo/src/screens/authenticated/book/BookStackNavigator.tsx @@ -2,7 +2,7 @@ import { NavigationProp } from '@react-navigation/native' import { createNativeStackNavigator } from '@react-navigation/native-stack' import BookOverview from './BookOverview' -import { BookReader } from './reader' +import BookReaderScreen from './BookReaderScreen' const Stack = createNativeStackNavigator() @@ -26,7 +26,11 @@ export default function BookStackNavigator() { return ( - + ) } diff --git a/apps/expo/src/screens/authenticated/book/reader/index.ts b/apps/expo/src/screens/authenticated/book/reader/index.ts deleted file mode 100644 index 9c42a76dd..000000000 --- a/apps/expo/src/screens/authenticated/book/reader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as BookReader } from './BookReader' diff --git a/apps/expo/src/screens/authenticated/explore/Explore.tsx b/apps/expo/src/screens/authenticated/explore/Explore.tsx index 97b260664..54deb89b6 100644 --- a/apps/expo/src/screens/authenticated/explore/Explore.tsx +++ b/apps/expo/src/screens/authenticated/explore/Explore.tsx @@ -32,7 +32,7 @@ export default function Explore() { const windowSize = books.length > 50 ? books.length / 4 : 21 return ( - +