diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 883e6ea..c48a98d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,10 +30,10 @@ jobs: - name: Check dependencies run: bun expo install --check - name: Run expo-doctor - run: bun run doctor + run: bun doctor - name: Run eslint - run: bun run lint + run: bun lint - name: Run tsc run: bun tsc - name: Run prebuild - run: bun run prebuild + run: bun prebuild diff --git a/.gitignore b/.gitignore index a55e649..f95ee80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.* *.orig.* web-build/ *.apk +*.aab # macOS .DS_Store diff --git a/bun.lockb b/bun.lockb index ebd5745..6cc5924 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle/0007_numerous_wong.sql b/drizzle/0007_numerous_wong.sql new file mode 100644 index 0000000..5356177 --- /dev/null +++ b/drizzle/0007_numerous_wong.sql @@ -0,0 +1,5 @@ +CREATE TABLE `local_user_settings` ( + `user_email` text PRIMARY KEY NOT NULL, + `preferred_playback_rate` real DEFAULT 1 NOT NULL, + `sleep_timer` integer DEFAULT 600 NOT NULL +); diff --git a/drizzle/0008_hard_raider.sql b/drizzle/0008_hard_raider.sql new file mode 100644 index 0000000..18d4eb2 --- /dev/null +++ b/drizzle/0008_hard_raider.sql @@ -0,0 +1 @@ +ALTER TABLE `local_user_settings` ADD `sleep_timer_enabled` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..0f13921 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1323 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9d552b6f-d48b-446f-b1d6-26260f1950d2", + "prevId": "d50ac96f-6e17-42d5-baf4-fe98293cba27", + "tables": { + "authors": { + "name": "authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authors_person_index": { + "name": "authors_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "authors_url_person_id_people_url_id_fk": { + "name": "authors_url_person_id_people_url_id_fk", + "tableFrom": "authors", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "book_authors": { + "name": "book_authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "book_authors_author_index": { + "name": "book_authors_author_index", + "columns": [ + "url", + "author_id" + ], + "isUnique": false + }, + "book_authors_book_index": { + "name": "book_authors_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "book_authors_url_author_id_authors_url_id_fk": { + "name": "book_authors_url_author_id_authors_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": [ + "url", + "author_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_url_book_id_books_url_id_fk": { + "name": "book_authors_url_book_id_books_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "book_authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "books": { + "name": "books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "books_published_index": { + "name": "books_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_resumable_snapshot": { + "name": "download_resumable_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloads_media_index": { + "name": "downloads_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "downloads_downloaded_at_index": { + "name": "downloads_downloaded_at_index", + "columns": [ + "downloaded_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "downloads_url_media_id_media_url_id_fk": { + "name": "downloads_url_media_id_media_url_id_fk", + "tableFrom": "downloads", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "downloads_url_media_id_pk": { + "columns": [ + "url", + "media_id" + ], + "name": "downloads_url_media_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_player_states": { + "name": "local_player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "local_player_states_media_index": { + "name": "local_player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "local_player_states_url_media_id_media_url_id_fk": { + "name": "local_player_states_url_media_id_media_url_id_fk", + "tableFrom": "local_player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "local_player_states_url_media_id_user_email_pk": { + "columns": [ + "url", + "media_id", + "user_email" + ], + "name": "local_player_states_url_media_id_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_user_settings": { + "name": "local_user_settings", + "columns": { + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preferred_playback_rate": { + "name": "preferred_playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sleep_timer": { + "name": "sleep_timer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 600 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapters": { + "name": "chapters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplemental_files": { + "name": "supplemental_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_cast": { + "name": "full_cast", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "abridged": { + "name": "abridged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mpd_path": { + "name": "mpd_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hls_path": { + "name": "hls_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mp4_path": { + "name": "mp4_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_book_index": { + "name": "media_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "media_status_index": { + "name": "media_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "media_inserted_at_index": { + "name": "media_inserted_at_index", + "columns": [ + "inserted_at" + ], + "isUnique": false + }, + "media_published_index": { + "name": "media_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_url_book_id_books_url_id_fk": { + "name": "media_url_book_id_books_url_id_fk", + "tableFrom": "media", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_narrators": { + "name": "media_narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "narrator_id": { + "name": "narrator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_narrators_media_index": { + "name": "media_narrators_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "media_narrators_narrator_index": { + "name": "media_narrators_narrator_index", + "columns": [ + "url", + "narrator_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_narrators_url_media_id_media_url_id_fk": { + "name": "media_narrators_url_media_id_media_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_narrators_url_narrator_id_narrators_url_id_fk": { + "name": "media_narrators_url_narrator_id_narrators_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "narrators", + "columnsFrom": [ + "url", + "narrator_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "narrators": { + "name": "narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "narrators_person_index": { + "name": "narrators_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "narrators_url_person_id_people_url_id_fk": { + "name": "narrators_url_person_id_people_url_id_fk", + "tableFrom": "narrators", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "people": { + "name": "people", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "people_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "people_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_states": { + "name": "player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "player_states_email_index": { + "name": "player_states_email_index", + "columns": [ + "user_email" + ], + "isUnique": false + }, + "player_states_status_index": { + "name": "player_states_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "player_states_media_index": { + "name": "player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "player_states_updated_at_index": { + "name": "player_states_updated_at_index", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "player_states_url_media_id_media_url_id_fk": { + "name": "player_states_url_media_id_media_url_id_fk", + "tableFrom": "player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_states_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "player_states_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series": { + "name": "series", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series_books": { + "name": "series_books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "series_id": { + "name": "series_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_number": { + "name": "book_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "series_books_book_index": { + "name": "series_books_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "series_books_series_index": { + "name": "series_books_series_index", + "columns": [ + "url", + "series_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "series_books_url_book_id_books_url_id_fk": { + "name": "series_books_url_book_id_books_url_id_fk", + "tableFrom": "series_books", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "series_books_url_series_id_series_url_id_fk": { + "name": "series_books_url_series_id_series_url_id_fk", + "tableFrom": "series_books", + "tableTo": "series", + "columnsFrom": [ + "url", + "series_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "series_books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_down_sync": { + "name": "last_down_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_up_sync": { + "name": "last_up_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "servers_url_user_email_pk": { + "columns": [ + "url", + "user_email" + ], + "name": "servers_url_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..025e4c1 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1331 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "63b439fd-1e1c-44e2-89eb-012172bd4e5f", + "prevId": "9d552b6f-d48b-446f-b1d6-26260f1950d2", + "tables": { + "authors": { + "name": "authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authors_person_index": { + "name": "authors_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "authors_url_person_id_people_url_id_fk": { + "name": "authors_url_person_id_people_url_id_fk", + "tableFrom": "authors", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "book_authors": { + "name": "book_authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "book_authors_author_index": { + "name": "book_authors_author_index", + "columns": [ + "url", + "author_id" + ], + "isUnique": false + }, + "book_authors_book_index": { + "name": "book_authors_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "book_authors_url_author_id_authors_url_id_fk": { + "name": "book_authors_url_author_id_authors_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": [ + "url", + "author_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_url_book_id_books_url_id_fk": { + "name": "book_authors_url_book_id_books_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "book_authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "books": { + "name": "books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "books_published_index": { + "name": "books_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_resumable_snapshot": { + "name": "download_resumable_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloads_media_index": { + "name": "downloads_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "downloads_downloaded_at_index": { + "name": "downloads_downloaded_at_index", + "columns": [ + "downloaded_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "downloads_url_media_id_media_url_id_fk": { + "name": "downloads_url_media_id_media_url_id_fk", + "tableFrom": "downloads", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "downloads_url_media_id_pk": { + "columns": [ + "url", + "media_id" + ], + "name": "downloads_url_media_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_player_states": { + "name": "local_player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "local_player_states_media_index": { + "name": "local_player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "local_player_states_url_media_id_media_url_id_fk": { + "name": "local_player_states_url_media_id_media_url_id_fk", + "tableFrom": "local_player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "local_player_states_url_media_id_user_email_pk": { + "columns": [ + "url", + "media_id", + "user_email" + ], + "name": "local_player_states_url_media_id_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_user_settings": { + "name": "local_user_settings", + "columns": { + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preferred_playback_rate": { + "name": "preferred_playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sleep_timer": { + "name": "sleep_timer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 600 + }, + "sleep_timer_enabled": { + "name": "sleep_timer_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapters": { + "name": "chapters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplemental_files": { + "name": "supplemental_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_cast": { + "name": "full_cast", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "abridged": { + "name": "abridged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mpd_path": { + "name": "mpd_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hls_path": { + "name": "hls_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mp4_path": { + "name": "mp4_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_book_index": { + "name": "media_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "media_status_index": { + "name": "media_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "media_inserted_at_index": { + "name": "media_inserted_at_index", + "columns": [ + "inserted_at" + ], + "isUnique": false + }, + "media_published_index": { + "name": "media_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_url_book_id_books_url_id_fk": { + "name": "media_url_book_id_books_url_id_fk", + "tableFrom": "media", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_narrators": { + "name": "media_narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "narrator_id": { + "name": "narrator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_narrators_media_index": { + "name": "media_narrators_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "media_narrators_narrator_index": { + "name": "media_narrators_narrator_index", + "columns": [ + "url", + "narrator_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_narrators_url_media_id_media_url_id_fk": { + "name": "media_narrators_url_media_id_media_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_narrators_url_narrator_id_narrators_url_id_fk": { + "name": "media_narrators_url_narrator_id_narrators_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "narrators", + "columnsFrom": [ + "url", + "narrator_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "narrators": { + "name": "narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "narrators_person_index": { + "name": "narrators_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "narrators_url_person_id_people_url_id_fk": { + "name": "narrators_url_person_id_people_url_id_fk", + "tableFrom": "narrators", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "people": { + "name": "people", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "people_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "people_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_states": { + "name": "player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "player_states_email_index": { + "name": "player_states_email_index", + "columns": [ + "user_email" + ], + "isUnique": false + }, + "player_states_status_index": { + "name": "player_states_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "player_states_media_index": { + "name": "player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "player_states_updated_at_index": { + "name": "player_states_updated_at_index", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "player_states_url_media_id_media_url_id_fk": { + "name": "player_states_url_media_id_media_url_id_fk", + "tableFrom": "player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_states_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "player_states_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series": { + "name": "series", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series_books": { + "name": "series_books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "series_id": { + "name": "series_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_number": { + "name": "book_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "series_books_book_index": { + "name": "series_books_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "series_books_series_index": { + "name": "series_books_series_index", + "columns": [ + "url", + "series_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "series_books_url_book_id_books_url_id_fk": { + "name": "series_books_url_book_id_books_url_id_fk", + "tableFrom": "series_books", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "series_books_url_series_id_series_url_id_fk": { + "name": "series_books_url_series_id_series_url_id_fk", + "tableFrom": "series_books", + "tableTo": "series", + "columnsFrom": [ + "url", + "series_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "series_books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_down_sync": { + "name": "last_down_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_up_sync": { + "name": "last_up_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "servers_url_user_email_pk": { + "columns": [ + "url", + "user_email" + ], + "name": "servers_url_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8c90248..56cd779 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,20 @@ "when": 1729048924964, "tag": "0006_aromatic_eternals", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1730601887354, + "tag": "0007_numerous_wong", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1730677947305, + "tag": "0008_hard_raider", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/migrations.js b/drizzle/migrations.js index 1333b4e..ed6a25e 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -7,6 +7,8 @@ import m0003 from "./0003_overjoyed_typhoid_mary.sql"; import m0004 from "./0004_tricky_leo.sql"; import m0005 from "./0005_green_moondragon.sql"; import m0006 from "./0006_aromatic_eternals.sql"; +import m0007 from "./0007_numerous_wong.sql"; +import m0008 from "./0008_hard_raider.sql"; import journal from "./meta/_journal.json"; export default { @@ -19,5 +21,7 @@ export default { m0004, m0005, m0006, + m0007, + m0008, }, }; diff --git a/package.json b/package.json index b29d341..42a8160 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,9 @@ "dependencies": { "@expo/vector-icons": "^14.0.4", "@react-hook/previous": "^1.0.1", + "@react-native-community/slider": "4.5.2", "@react-navigation/native": "^6.1.18", - "drizzle-orm": "^0.35.3", + "drizzle-orm": "^0.36.0", "expo": "^51.0.38", "expo-build-properties": "~0.12.5", "expo-constants": "~16.0.2", @@ -39,6 +40,7 @@ "expo-drizzle-studio-plugin": "^0.0.2", "expo-file-system": "~17.0.1", "expo-font": "~12.0.10", + "expo-haptics": "~13.0.1", "expo-image": "~1.13.0", "expo-linking": "~6.3.1", "expo-navigation-bar": "~3.0.7", @@ -64,7 +66,7 @@ "react-native-web": "~0.19.13", "tailwindcss": "^3.4.14", "use-debounce": "^10.0.4", - "zustand": "^5.0.0" + "zustand": "^5.0.1" }, "devDependencies": { "@0no-co/graphqlsp": "^1.12.16", @@ -76,7 +78,7 @@ "@types/react": "~18.2.79", "@types/react-test-renderer": "^18.3.0", "babel-plugin-inline-import": "^3.0.0", - "drizzle-kit": "^0.26.2", + "drizzle-kit": "^0.27.1", "eslint": "^8.57.1", "eslint-config-expo": "^7.1.2", "eslint-config-prettier": "^9.1.0", diff --git a/src/app/(tabs)/(library)/_layout.tsx b/src/app/(app)/(tabs)/(library)/_layout.tsx similarity index 61% rename from src/app/(tabs)/(library)/_layout.tsx rename to src/app/(app)/(tabs)/(library)/_layout.tsx index bd323ea..4b99857 100644 --- a/src/app/(tabs)/(library)/_layout.tsx +++ b/src/app/(app)/(tabs)/(library)/_layout.tsx @@ -1,16 +1,9 @@ -import { useSessionStore } from "@/src/stores/session"; -import { Redirect, Stack } from "expo-router"; +import { Stack } from "expo-router"; const getId = ({ params }: { params?: Record | undefined }) => params?.id; -export default function AppLayout() { - const session = useSessionStore((state) => state.session); - - if (!session) { - return ; - } - +export default function LibraryStackLayout() { return ( diff --git a/src/app/(app)/(tabs)/(library)/book/[id].tsx b/src/app/(app)/(tabs)/(library)/book/[id].tsx new file mode 100644 index 0000000..1d7dce1 --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/book/[id].tsx @@ -0,0 +1,104 @@ +import NamesList from "@/src/components/NamesList"; +import { Tile } from "@/src/components/Tiles"; +import { BookDetails, useBookDetails } from "@/src/db/library"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; +import { formatPublished } from "@/src/utils/date"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; +import colors from "tailwindcss/colors"; + +export default function BookDetailsScreen() { + const session = useSession((state) => state.session); + const { id: bookId, title } = useLocalSearchParams(); + useSyncOnFocus(); + + if (!session) return null; + + return ( + <> + + + + ); +} + +type BookDetailsFlatListProps = { + bookId: string; + session: Session; +}; + +function BookDetailsFlatList({ bookId, session }: BookDetailsFlatListProps) { + const { data: book, opacity } = useBookDetails(session, bookId); + + if (!book) return null; + + return ( + item.id} + numColumns={2} + ListHeaderComponent={() =>
} + renderItem={({ item }) => { + return ; + }} + /> + ); +} + +type BookProp = BookDetails; +type HeaderProps = { book: BookProp }; + +function Header({ book }: HeaderProps) { + return ( + + + ba.author.name)} + /> + {book.published && ( + + First published{" "} + {formatPublished(book.published, book.publishedFormat)} + + )} + + + Editions + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 8, + }, + tile: { + padding: 8, + width: "50%", + marginBottom: 8, + }, + headerContainer: { + padding: 8, + gap: 32, + }, + headerAuthorsList: { + fontSize: 18, + fontWeight: 500, + color: colors.zinc[100], + }, + headerPublishedText: { + color: colors.zinc[300], + }, + headerEditionsText: { + fontSize: 22, + fontWeight: 500, + color: colors.zinc[100], + }, +}); diff --git a/src/app/(app)/(tabs)/(library)/index.tsx b/src/app/(app)/(tabs)/(library)/index.tsx new file mode 100644 index 0000000..623a052 --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/index.tsx @@ -0,0 +1,53 @@ +import { MediaTile } from "@/src/components/Tiles"; +import { useMediaList } from "@/src/db/library"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { Session, useSession } from "@/src/stores/session"; +import { StyleSheet } from "react-native"; +import Animated from "react-native-reanimated"; +import colors from "tailwindcss/colors"; + +export default function LibraryScreen() { + const session = useSession((state) => state.session); + useSyncOnFocus(); + + if (!session) return null; + + return ; +} + +type LibraryFlatlistProps = { + session: Session; +}; + +function LibraryFlatlist({ session }: LibraryFlatlistProps) { + const { data: media, updatedAt, opacity } = useMediaList(session); + + if (updatedAt !== undefined && media.length === 0) { + // TODO: there are no books on this server + return null; + } + + return ( + item.id} + numColumns={2} + renderItem={({ item }) => } + /> + ); +} + +const styles = StyleSheet.create({ + flatlist: { + padding: 8, + }, + tile: { + padding: 8, + width: "50%", + marginBottom: 8, + }, + error: { + color: colors.red[500], + }, +}); diff --git a/src/app/(app)/(tabs)/(library)/media/[id].tsx b/src/app/(app)/(tabs)/(library)/media/[id].tsx new file mode 100644 index 0000000..959d82c --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/media/[id].tsx @@ -0,0 +1,757 @@ +import Description from "@/src/components/Description"; +import IconButton from "@/src/components/IconButton"; +import Loading from "@/src/components/Loading"; +import NamesList from "@/src/components/NamesList"; +import ThumbnailImage from "@/src/components/ThumbnailImage"; +import { + BookTile, + MediaTile, + PersonTile, + SeriesBookTile, +} from "@/src/components/Tiles"; +import { + useMediaActionBarInfo, + useMediaAuthorsAndNarrators, + useMediaDescription, + useMediaHeaderInfo, + useMediaIds, + useMediaOtherEditions, + useOtherBooksByAuthor, + useOtherBooksInSeries, + useOtherMediaByNarrator, +} from "@/src/db/library"; +import { syncDown } from "@/src/db/sync"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { startDownload, useDownloads } from "@/src/stores/downloads"; +import { loadMedia, requestExpandPlayer } from "@/src/stores/player"; +import { useScreen } from "@/src/stores/screen"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; +import { formatPublished } from "@/src/utils/date"; +import { durationDisplay } from "@/src/utils/time"; +import { Stack, router, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { FlatList, Pressable, StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; +import colors from "tailwindcss/colors"; + +export default function MediaDetailsScreen() { + const session = useSession((state) => state.session); + const { id: mediaId, title } = useLocalSearchParams(); + useSyncOnFocus(); + + if (!session) return null; + + return ( + <> + + + + ); +} + +type HeaderSection = { + id: string; + type: "header"; + mediaId: string; +}; + +type ActionBarSection = { + id: string; + type: "actionBar"; + mediaId: string; +}; + +type MediaDescriptionSection = { + id: string; + type: "mediaDescription"; + mediaId: string; +}; + +type AuthorsAndNarratorsSection = { + id: string; + type: "authorsAndNarrators"; + mediaId: string; +}; + +type OtherEditionsSection = { + id: string; + type: "otherEditions"; + bookId: string; + withoutMediaId: string; +}; + +type OtherBooksInSeriesSection = { + id: string; + type: "otherBooksInSeries"; + seriesId: string; +}; + +type OtherBooksByAuthorSection = { + id: string; + type: "otherBooksByAuthor"; + authorId: string; + withoutBookId: string; + withoutSeriesIds: string[]; +}; + +type OtherMediaByNarratorSection = { + id: string; + type: "otherMediaByNarrator"; + narratorId: string; + withoutMediaId: string; + withoutSeriesIds: string[]; + withoutAuthorIds: string[]; +}; + +type Section = + | HeaderSection + | ActionBarSection + | MediaDescriptionSection + | AuthorsAndNarratorsSection + | OtherEditionsSection + | OtherBooksInSeriesSection + | OtherBooksByAuthorSection + | OtherMediaByNarratorSection; + +function useSections(mediaId: string, session: Session) { + const { ids, opacity } = useMediaIds(session, mediaId); + + const [sections, setSections] = useState(); + + useEffect(() => { + if (!ids) return; + + const sections: Section[] = [ + { id: `header-${mediaId}`, type: "header", mediaId }, + { id: `actions-${mediaId}`, type: "actionBar", mediaId }, + { + id: `description-${mediaId}`, + type: "mediaDescription", + mediaId, + }, + { + id: `authors-narrators-${mediaId}`, + type: "authorsAndNarrators", + mediaId, + }, + { + id: `editions-${mediaId}`, + type: "otherEditions", + bookId: ids.bookId, + withoutMediaId: mediaId, + }, + ...ids.seriesIds.map( + (seriesId): OtherBooksInSeriesSection => ({ + id: `books-in-series-${seriesId}`, + type: "otherBooksInSeries", + seriesId, + }), + ), + ...ids.authorIds.map( + (authorId): OtherBooksByAuthorSection => ({ + id: `other-books-${authorId}`, + type: "otherBooksByAuthor", + authorId, + withoutBookId: ids.bookId, + withoutSeriesIds: ids.seriesIds, + }), + ), + ...ids.narratorIds.map( + (narratorId): OtherMediaByNarratorSection => ({ + id: `other-media-${narratorId}`, + type: "otherMediaByNarrator", + narratorId, + withoutMediaId: mediaId, + withoutSeriesIds: ids.seriesIds, + withoutAuthorIds: ids.authorIds, + }), + ), + ]; + setSections(sections); + }, [ids, mediaId, session]); + + return { sections, opacity }; +} + +type MediaDetailsFlatListProps = { + session: Session; + mediaId: string; +}; + +function MediaDetailsFlatList({ session, mediaId }: MediaDetailsFlatListProps) { + const { sections, opacity } = useSections(mediaId, session); + + if (!sections) return null; + + return ( + item.id} + initialNumToRender={2} + ListHeaderComponent={} + ListFooterComponent={} + renderItem={({ item }) => { + switch (item.type) { + case "header": + return
; + case "actionBar": + return ; + case "mediaDescription": + return ( + + ); + case "authorsAndNarrators": + return ( + + ); + case "otherEditions": + return ( + + ); + case "otherBooksInSeries": + return ( + + ); + case "otherBooksByAuthor": + return ( + + ); + case "otherMediaByNarrator": + return ( + + ); + default: + // can't happen + console.error("unknown section type:", item); + return null; + } + }} + /> + ); +} + +type HeaderProps = { + mediaId: string; + session: Session; +}; + +function Header({ mediaId, session }: HeaderProps) { + const { data: media, opacity } = useMediaHeaderInfo(session, mediaId); + + if (!media) return null; + + return ( + + + + + {media.book.title} + + {media.book.seriesBooks.length !== 0 && ( + `${sb.series.name} #${sb.bookNumber}`, + )} + className="text-lg text-zinc-100 leading-tight" + /> + )} + ba.author.name)} + className="text-lg text-zinc-300 leading-tight" + /> + {media.mediaNarrators.length > 0 && ( + mn.narrator.name)} + className="text-zinc-400 leading-tight" + /> + )} + {media.mediaNarrators.length === 0 && media.fullCast && ( + + Read by a full cast + + )} + + {media.duration && ( + + + {durationDisplay(media.duration)} {media.abridged && "(abridged)"} + + + )} + + ); +} + +type ActionBarProps = { + mediaId: string; + session: Session; +}; + +function ActionBar({ mediaId, session }: ActionBarProps) { + const progress = useDownloads((state) => state.downloadProgresses[mediaId]); + const { data: media, opacity } = useMediaActionBarInfo(session, mediaId); + + if (!media) return null; + + if (progress) { + return ( + + router.navigate("/downloads")} + > + + + + + + Downloading... + + + + + ); + } else if (media.download && media.download.status !== "error") { + return ( + + + + { + await syncDown(session, true); + await loadMedia(session, media.id); + requestExpandPlayer(); + }} + > + + Play + + + + + + You have this audiobook downloaded, it will play from your device and + not require an internet connection. + + + ); + } else { + return ( + + + + { + await syncDown(session, true); + await loadMedia(session, media.id); + requestExpandPlayer(); + }} + > + + Stream + + + + + { + if (!media.mp4Path) return; + startDownload( + session, + media.id, + media.mp4Path, + media.thumbnails, + ); + router.navigate("/downloads"); + }} + > + + Download + + + + + + Playing this audiobook will stream it and require an internet + connection and may use your data plan. + + + ); + } +} + +type MediaDescriptionProps = { + mediaId: string; + session: Session; +}; + +function MediaDescription({ mediaId, session }: MediaDescriptionProps) { + const { data: media, opacity } = useMediaDescription(session, mediaId); + + if (!media?.description) return null; + + return ( + + + + {media.book.published && ( + + First published{" "} + {formatPublished(media.book.published, media.book.publishedFormat)} + + )} + {media.published && ( + + This edition published{" "} + {formatPublished(media.published, media.publishedFormat)} + + )} + {media.publisher && ( + by {media.publisher} + )} + {media.notes && ( + Note: {media.notes} + )} + + + ); +} + +type AuthorsAndNarratorsProps = { + mediaId: string; + session: Session; +}; + +function AuthorsAndNarrators({ mediaId, session }: AuthorsAndNarratorsProps) { + const screenWidth = useScreen((state) => state.screenWidth); + const { media, authorSet, narratorSet, opacity } = + useMediaAuthorsAndNarrators(session, mediaId); + + if (!media) return null; + + return ( + + + Author{media.book.bookAuthors.length > 1 && "s"} & Narrator + {media.mediaNarrators.length > 1 && "s"} + + item.id} + horizontal={true} + renderItem={({ item }) => { + if ("author" in item) { + const label = narratorSet.has(item.author.person.id) + ? "Author & Narrator" + : "Author"; + return ( + + + + ); + } + + if ("narrator" in item) { + // skip if this person is also an author, as they were already rendered + if (authorSet.has(item.narrator.person.id)) return null; + + return ( + + + + ); + } + + // can't happen: + console.error("unknown item:", item); + return null; + }} + /> + + ); +} + +type OtherEditionsProps = { + bookId: string; + session: Session; + withoutMediaId: string; +}; + +function OtherEditions(props: OtherEditionsProps) { + const { bookId, session, withoutMediaId } = props; + const screenWidth = useScreen((state) => state.screenWidth); + const { media, opacity } = useMediaOtherEditions( + session, + bookId, + withoutMediaId, + ); + + if (media.length === 0) return null; + + const navigateToBook = () => { + router.navigate({ + pathname: "/book/[id]", + params: { id: media[0].book.id, title: media[0].book.title }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type OtherBooksInSeriesProps = { + seriesId: string; + session: Session; +}; + +function OtherBooksInSeries({ seriesId, session }: OtherBooksInSeriesProps) { + const screenWidth = useScreen((state) => state.screenWidth); + const { data: series, opacity } = useOtherBooksInSeries(session, seriesId); + + if (!series) return null; + + const navigateToSeries = () => { + router.navigate({ + pathname: "/series/[id]", + params: { id: series.id, title: series.name }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type OtherBooksByAuthorProps = { + authorId: string; + session: Session; + withoutBookId: string; + withoutSeriesIds: string[]; +}; + +function OtherBooksByAuthor(props: OtherBooksByAuthorProps) { + const { authorId, session, withoutBookId, withoutSeriesIds } = props; + const screenWidth = useScreen((state) => state.screenWidth); + const { books, author, opacity } = useOtherBooksByAuthor( + session, + authorId, + withoutBookId, + withoutSeriesIds, + ); + + if (!author) return null; + if (books.length === 0) return null; + + const navigateToPerson = () => { + router.navigate({ + pathname: "/person/[id]", + params: { id: author.person.id, title: author.person.name }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type OtherMediaByNarratorProps = { + narratorId: string; + session: Session; + withoutMediaId: string; + withoutSeriesIds: string[]; + withoutAuthorIds: string[]; +}; + +function OtherMediaByNarrator(props: OtherMediaByNarratorProps) { + const { + narratorId, + session, + withoutMediaId, + withoutSeriesIds, + withoutAuthorIds, + } = props; + const screenWidth = useScreen((state) => state.screenWidth); + const { media, narrator, opacity } = useOtherMediaByNarrator( + session, + narratorId, + withoutMediaId, + withoutSeriesIds, + withoutAuthorIds, + ); + + if (!narrator) return null; + if (media.length === 0) return null; + + const navigateToPerson = () => { + router.navigate({ + pathname: "/person/[id]", + params: { id: narrator.person.id, title: narrator.person.name }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type HeaderButtonProps = { + label: string; + onPress: () => void; +}; + +function HeaderButton({ label, onPress }: HeaderButtonProps) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + headerContainer: { + gap: 8, + }, +}); diff --git a/src/app/(app)/(tabs)/(library)/person/[id].tsx b/src/app/(app)/(tabs)/(library)/person/[id].tsx new file mode 100644 index 0000000..034b7a8 --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/person/[id].tsx @@ -0,0 +1,269 @@ +import Description from "@/src/components/Description"; +import ThumbnailImage from "@/src/components/ThumbnailImage"; +import { BookTile, MediaTile } from "@/src/components/Tiles"; +import { + useBooksByAuthor, + useMediaByNarrator, + usePersonDescription, + usePersonHeaderInfo, + usePersonIds, +} from "@/src/db/library"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { FlatList, StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; + +export default function PersonDetailsScreen() { + const session = useSession((state) => state.session); + const { id: personId, title } = useLocalSearchParams(); + useSyncOnFocus(); + + if (!session) return null; + + return ( + <> + + + + ); +} + +type HeaderSection = { + id: string; + type: "header"; + personId: string; +}; + +type PersonDescriptionSection = { + id: string; + type: "personDescription"; + personId: string; +}; + +type BooksByAuthorSection = { + id: string; + type: "booksByAuthor"; + authorId: string; +}; + +type MediaByNarratorSection = { + id: string; + type: "mediaByNarrator"; + narratorId: string; +}; + +type Section = + | HeaderSection + | PersonDescriptionSection + | BooksByAuthorSection + | MediaByNarratorSection; + +function useSections(personId: string, session: Session) { + const { ids, opacity } = usePersonIds(session, personId); + const [sections, setSections] = useState(); + + useEffect(() => { + if (!ids) return; + + const sections: Section[] = [ + { id: `header-${personId}`, type: "header", personId }, + { + id: `description-${personId}`, + type: "personDescription", + personId, + }, + ...ids.authorIds.map( + (authorId): BooksByAuthorSection => ({ + id: `books-${authorId}`, + type: "booksByAuthor", + authorId, + }), + ), + ...ids.narratorIds.map( + (narratorId): MediaByNarratorSection => ({ + id: `media-${narratorId}`, + type: "mediaByNarrator", + narratorId, + }), + ), + ]; + setSections(sections); + }, [ids, personId]); + + return { sections, opacity }; +} + +type PersonDetailsFlatListProps = { + session: Session; + personId: string; +}; + +function PersonDetailsFlatList(props: PersonDetailsFlatListProps) { + const { personId, session } = props; + const { sections, opacity } = useSections(personId, session); + + if (!sections) return null; + + return ( + item.id} + initialNumToRender={2} + ListHeaderComponent={} + ListFooterComponent={} + renderItem={({ item }) => { + switch (item.type) { + case "header": + return
; + case "personDescription": + return ( + + ); + case "booksByAuthor": + return ; + case "mediaByNarrator": + return ( + + ); + default: + // can't happen + console.error("unknown section type:", item); + return null; + } + }} + /> + ); +} + +type HeaderProps = { + personId: string; + session: Session; +}; + +function Header({ personId, session }: HeaderProps) { + const { data: person, opacity } = usePersonHeaderInfo(session, personId); + + if (!person) return null; + + return ( + + + + ); +} + +type PersonDescriptionProps = { + personId: string; + session: Session; +}; + +function PersonDescription({ personId, session }: PersonDescriptionProps) { + const { data: person, opacity } = usePersonDescription(session, personId); + + if (!person?.description) return null; + + return ( + + + + ); +} + +type BooksByAuthorProps = { + authorId: string; + session: Session; +}; + +function BooksByAuthor({ authorId, session }: BooksByAuthorProps) { + const { books, author, opacity } = useBooksByAuthor(session, authorId); + + if (!author) return null; + if (books.length === 0) return null; + + return ( + + + {author.name === author.person.name + ? `By ${author.name}` + : `As ${author.name}`} + + + item.id} + numColumns={2} + renderItem={({ item }) => { + return ; + }} + /> + + ); +} + +type MediaByNarratorProps = { + narratorId: string; + session: Session; +}; + +function MediaByNarrator({ narratorId, session }: MediaByNarratorProps) { + const { media, narrator, opacity } = useMediaByNarrator(session, narratorId); + + if (!narrator) return null; + if (media.length === 0) return null; + + return ( + + + {narrator.name === narrator.person.name + ? `Read by ${narrator.name}` + : `Read as ${narrator.name}`} + + + item.id} + numColumns={2} + renderItem={({ item }) => { + return ; + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + spacingTop: { + marginTop: 32, + }, + tile: { + padding: 8, + width: "50%", + marginBottom: 8, + }, +}); diff --git a/src/app/(tabs)/(library)/series/[id].tsx b/src/app/(app)/(tabs)/(library)/series/[id].tsx similarity index 67% rename from src/app/(tabs)/(library)/series/[id].tsx rename to src/app/(app)/(tabs)/(library)/series/[id].tsx index 4db3ca6..dfd05d1 100644 --- a/src/app/(tabs)/(library)/series/[id].tsx +++ b/src/app/(app)/(tabs)/(library)/series/[id].tsx @@ -1,20 +1,17 @@ import NamesList from "@/src/components/NamesList"; import { PersonTile, SeriesBookTile } from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; +import { useSeriesDetails } from "@/src/db/library"; import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { and, eq, sql } from "drizzle-orm"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; import { Stack, useLocalSearchParams } from "expo-router"; import { FlatList, StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; -export default function SeriesDetails() { - const session = useSessionStore((state) => state.session); - const { id: seriesId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); +export default function SeriesDetailsScreen() { + const session = useSession((state) => state.session); + const { id: seriesId, title } = useLocalSearchParams(); useSyncOnFocus(); if (!session) return null; @@ -47,67 +44,7 @@ function SeriesDetailsFlatList({ seriesId, session, }: SeriesDetailsFlatListProps) { - const { data: series } = useLiveTablesQuery( - db.query.series.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.series.url, session.url), - eq(schema.series.id, seriesId), - ), - with: { - seriesBooks: { - columns: { id: true, bookNumber: true }, - orderBy: sql`CAST(book_number AS FLOAT)`, - with: { - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { id: true, name: true }, - with: { - person: { - columns: { id: true, name: true, thumbnails: true }, - }, - }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { id: true, name: true }, - with: { - person: { - columns: { - id: true, - name: true, - thumbnails: true, - }, - }, - }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }, - }, - }, - }, - }), - ["series"], - ); + const { data: series, opacity } = useSeriesDetails(session, seriesId); if (!series) return null; @@ -161,8 +98,8 @@ function SeriesDetailsFlatList({ ); return ( - item.id} numColumns={2} @@ -248,6 +185,9 @@ function Footer({ authors, narrators }: FooterProps) { } const styles = StyleSheet.create({ + container: { + paddingHorizontal: 8, + }, tile: { padding: 8, width: "50%", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(app)/(tabs)/_layout.tsx similarity index 71% rename from src/app/(tabs)/_layout.tsx rename to src/app/(app)/(tabs)/_layout.tsx index 4a8edcd..7c6f60f 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(app)/(tabs)/_layout.tsx @@ -1,12 +1,21 @@ import TabBar from "@/src/components/TabBar"; import TabBarWithPlayer from "@/src/components/TabBarWithPlayer"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { usePlayer } from "@/src/stores/player"; +import { Session, useSession } from "@/src/stores/session"; import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; import { Tabs } from "expo-router"; import colors from "tailwindcss/colors"; -export default function TabLayout() { - const mediaId = useTrackPlayerStore((state) => state.mediaId); +export default function AppTabLayout() { + const session = useSession((state) => state.session); + + if (!session) return null; + + return ; +} + +function AppTabs({ session }: { session: Session }) { + const mediaId = usePlayer((state) => state.mediaId); const playerVisible = !!mediaId; return ( @@ -14,9 +23,14 @@ export default function TabLayout() { screenOptions={{ tabBarActiveTintColor: colors.lime[400], tabBarStyle: playerVisible ? { borderTopWidth: 0 } : {}, + tabBarLabelStyle: { paddingBottom: 4 }, }} tabBar={(props) => - playerVisible ? : + playerVisible ? ( + + ) : ( + + ) } > state.session); + const session = useSession((state) => state.session); if (!session) return null; @@ -29,17 +26,9 @@ export default function DownloadsScreen() { } function DownloadsList({ session }: { session: Session }) { - const { data, updatedAt } = useLiveDownloadsList(session); - - if (updatedAt === undefined) { - return ( - - - - ); - } + const { data, updatedAt, opacity } = useDownloadsList(session); - if (data.length === 0) { + if (updatedAt !== undefined && data.length === 0) { return ( @@ -57,8 +46,8 @@ function DownloadsList({ session }: { session: Session }) { } return ( - download.media.id} renderItem={({ item }) => ( @@ -74,11 +63,9 @@ type DownloadRowProps = { }; function DownloadRow({ session, download }: DownloadRowProps) { - const progress = useDownloadsStore( + const progress = useDownloads( (state) => state.downloadProgresses[download.media.id], ); - const removeDownload = useDownloadsStore((state) => state.removeDownload); - const cancelDownload = useDownloadsStore((state) => state.cancelDownload); const [isModalVisible, setIsModalVisible] = useState(false); const navigateToBook = () => { diff --git a/src/app/(tabs)/settings.tsx b/src/app/(app)/(tabs)/settings.tsx similarity index 68% rename from src/app/(tabs)/settings.tsx rename to src/app/(app)/(tabs)/settings.tsx index e13b0de..925a2a1 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(app)/(tabs)/settings.tsx @@ -1,11 +1,12 @@ -import { useSessionStore } from "@/src/stores/session"; -import { useRouter } from "expo-router"; +import { useSession } from "@/src/stores/session"; +import { router } from "expo-router"; import { Button, StyleSheet, Text, View } from "react-native"; import colors from "tailwindcss/colors"; export default function SettingsScreen() { - const signOut = useSessionStore((state) => state.signOut); - const router = useRouter(); + const session = useSession((state) => state.session); + + if (!session) return null; return ( @@ -13,12 +14,10 @@ export default function SettingsScreen() { Settings, like your preferred playback speed, will be here. + You are signed in as: {session.email} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.zinc[950], + height: "100%", + }, + chapterList: { + paddingHorizontal: 16, + }, + chapterRowContainer: { + height: chapterRowHeight, + }, + chapterButton: { + paddingVertical: 16, + borderColor: colors.zinc[600], + borderBottomWidth: StyleSheet.hairlineWidth, + }, + chapterRow: { + display: "flex", + flexDirection: "row", + alignItems: "center", + }, + chapterTitle: { + flex: 1, + fontSize: 16, + color: colors.zinc[100], + }, + chapterTime: { + color: colors.zinc[400], + }, + iconContainer: { + height: 16, + width: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginRight: 8, + }, +}); diff --git a/src/app/(app)/playback-rate.tsx b/src/app/(app)/playback-rate.tsx new file mode 100644 index 0000000..0ecf538 --- /dev/null +++ b/src/app/(app)/playback-rate.tsx @@ -0,0 +1,181 @@ +import Button from "@/src/components/Button"; +import useBackHandler from "@/src/hooks/use.back.handler"; +import { setPlaybackRate, usePlayer } from "@/src/stores/player"; +import { useSession } from "@/src/stores/session"; +import { formatPlaybackRate } from "@/src/utils/rate"; +import { secondsDisplay } from "@/src/utils/time"; +import Slider from "@react-native-community/slider"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import colors from "tailwindcss/colors"; +import { useShallow } from "zustand/react/shallow"; + +export default function PlaybackRateModal() { + useBackHandler(() => { + router.back(); + return true; + }); + + const session = useSession((state) => state.session); + + const { position, duration, playbackRate } = usePlayer( + useShallow(({ position, duration, playbackRate }) => ({ + position, + duration, + playbackRate, + })), + ); + const [displayPlaybackRate, setDisplayPlaybackRate] = useState(1.0); + + useEffect(() => { + setDisplayPlaybackRate(playbackRate); + }, [playbackRate]); + + const setPlaybackRateAndDisplay = useCallback( + (value: number) => { + if (!session) return; + setDisplayPlaybackRate(value); + setPlaybackRate(session, value); + }, + [session], + ); + + if (!session) return null; + + return ( + + + + {formatPlaybackRate(displayPlaybackRate)}× + + { + setDisplayPlaybackRate(parseFloat(value.toFixed(2))); + }} + onSlidingComplete={(value) => { + setPlaybackRateAndDisplay(parseFloat(value.toFixed(2))); + }} + /> + + + + + + + + + + + + Finish in{" "} + {secondsDisplay(Math.max(duration - position, 0) / displayPlaybackRate)} + + + + + ); +} + +type PlaybackRateButtonProps = { + rate: number; + active: boolean; + setPlaybackRateAndDisplay: (value: number) => void; +}; + +function PlaybackRateButton(props: PlaybackRateButtonProps) { + const { rate, active, setPlaybackRateAndDisplay } = props; + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 32, + backgroundColor: colors.zinc[950], + height: "100%", + display: "flex", + justifyContent: "center", + gap: 16, + }, + title: { + color: colors.zinc[100], + margin: 16, + fontSize: 18, + textAlign: "center", + }, + rateButtonRow: { + display: "flex", + flexDirection: "row", + gap: 4, + }, + rateButton: { + backgroundColor: colors.zinc[800], + borderRadius: 999, + paddingHorizontal: 16, + flexGrow: 1, + }, + rateButtonActive: { + backgroundColor: colors.lime[400], + }, + rateButtonTextActive: { + color: colors.black, + }, + text: { + color: colors.zinc[100], + fontSize: 12, + }, + timeLeftText: { + color: colors.zinc[400], + textAlign: "center", + }, + closeButton: { + marginTop: 32, + }, + closeButtonText: { + color: colors.lime[400], + }, +}); diff --git a/src/app/(app)/sleep-timer.tsx b/src/app/(app)/sleep-timer.tsx new file mode 100644 index 0000000..76aa208 --- /dev/null +++ b/src/app/(app)/sleep-timer.tsx @@ -0,0 +1,185 @@ +import Button from "@/src/components/Button"; +import useBackHandler from "@/src/hooks/use.back.handler"; +import { + setSleepTimer, + setSleepTimerState, + usePlayer, +} from "@/src/stores/player"; +import Slider from "@react-native-community/slider"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { StyleSheet, Switch, Text, View } from "react-native"; +import colors from "tailwindcss/colors"; + +function formatSeconds(seconds: number) { + return Math.round(seconds / 60); +} + +export default function SleepTimerModal() { + useBackHandler(() => { + router.back(); + return true; + }); + + const { sleepTimer, sleepTimerEnabled } = usePlayer((state) => state); + + const [displaySleepTimerSeconds, setDisplaySleepTimerSeconds] = + useState(sleepTimer); + + useEffect(() => { + setDisplaySleepTimerSeconds(sleepTimer); + }, [sleepTimer]); + + const setSleepTimerSecondsAndDisplay = useCallback((value: number) => { + setDisplaySleepTimerSeconds(value); + setSleepTimer(value); + }, []); + + return ( + + + + {formatSeconds(displaySleepTimerSeconds)}m + + setDisplaySleepTimerSeconds(value)} + onSlidingComplete={(value) => setSleepTimerSecondsAndDisplay(value)} + /> + + + + + + + + + + + + Sleep Timer is {sleepTimerEnabled ? "enabled" : "disabled"} + + { + setSleepTimerState(value); + }} + /> + + + + + ); +} + +type SleepTimerSecondsButtonProps = { + seconds: number; + active: boolean; + setSleepTimerSecondsAndDisplay: (value: number) => void; +}; + +function SleepTimerSecondsButton(props: SleepTimerSecondsButtonProps) { + const { seconds, active, setSleepTimerSecondsAndDisplay } = props; + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 32, + backgroundColor: colors.zinc[950], + height: "100%", + display: "flex", + justifyContent: "center", + gap: 16, + }, + title: { + color: colors.zinc[100], + margin: 16, + fontSize: 18, + textAlign: "center", + }, + sleepTimerButtonRow: { + display: "flex", + flexDirection: "row", + gap: 4, + }, + sleepTimerButton: { + backgroundColor: colors.zinc[800], + borderRadius: 999, + paddingHorizontal: 16, + flexGrow: 1, + }, + sleepTimerButtonActive: { + backgroundColor: colors.lime[400], + color: colors.black, + }, + sleepTimerButtonActiveText: { + color: colors.black, + }, + sleepTimerEnabledText: { + color: colors.zinc[400], + fontSize: 16, + }, + text: { + color: colors.zinc[100], + fontSize: 12, + }, + closeButton: { + marginTop: 32, + }, + closeButtonText: { + color: colors.lime[400], + }, +}); diff --git a/src/app/(tabs)/(library)/book/[id].tsx b/src/app/(tabs)/(library)/book/[id].tsx deleted file mode 100644 index 765091a..0000000 --- a/src/app/(tabs)/(library)/book/[id].tsx +++ /dev/null @@ -1,178 +0,0 @@ -import NamesList from "@/src/components/NamesList"; -import { Tile } from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; -import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; -import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { formatPublished } from "@/src/utils/date"; -import { and, eq } from "drizzle-orm"; -import { Stack, useLocalSearchParams } from "expo-router"; -import { FlatList, StyleSheet, Text, View } from "react-native"; - -export default function BookDetails() { - const session = useSessionStore((state) => state.session); - const { id: bookId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); - useSyncOnFocus(); - - if (!session) return null; - - return ( - <> - - - - ); -} - -function BookDetailsFlatList({ - bookId, - session, -}: { - bookId: string; - session: Session; -}) { - const { data: book } = useLiveTablesQuery( - db.query.books.findFirst({ - columns: { - id: true, - title: true, - published: true, - publishedFormat: true, - }, - where: and( - eq(schema.books.url, session.url), - eq(schema.books.id, bookId), - ), - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { id: true, name: true }, - with: { - person: { - columns: { id: true, name: true, thumbnails: true }, - }, - }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { id: true, name: true }, - with: { - person: { - columns: { - id: true, - name: true, - thumbnails: true, - }, - }, - }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }), - ["books"], - ); - - if (!book) return null; - - return ( - item.id} - numColumns={2} - ListHeaderComponent={() =>
} - renderItem={({ item }) => { - return ; - }} - /> - ); -} - -type BookProp = { - title: string; - published: Date; - publishedFormat: "full" | "year_month" | "year"; - bookAuthors: { - author: { - id: string; - name: string; - person: { - id: string; - name: string; - thumbnails: schema.Thumbnails | null; - }; - }; - }[]; - media: { - id: string; - thumbnails: schema.Thumbnails | null; - mediaNarrators: { - narrator: { - id: string; - name: string; - person: { - id: string; - name: string; - thumbnails: schema.Thumbnails | null; - }; - }; - }[]; - download: { - thumbnails: schema.DownloadedThumbnails | null; - } | null; - }[]; -}; - -type HeaderProps = { - book: BookProp; -}; - -function Header({ book }: HeaderProps) { - return ( - - - ba.author.name)} - /> - {book.published && ( - - First published{" "} - {formatPublished(book.published, book.publishedFormat)} - - )} - - - Editions - - - ); -} - -const styles = StyleSheet.create({ - tile: { - padding: 8, - width: "50%", - marginBottom: 8, - }, -}); diff --git a/src/app/(tabs)/(library)/index.tsx b/src/app/(tabs)/(library)/index.tsx deleted file mode 100644 index 38feba2..0000000 --- a/src/app/(tabs)/(library)/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import Loading from "@/src/components/Loading"; -import ScreenCentered from "@/src/components/ScreenCentered"; -import { MediaTile } from "@/src/components/Tiles"; -import { MediaForIndex, listMediaForIndex } from "@/src/db/library"; -import { syncDown } from "@/src/db/sync"; -import { useSessionStore } from "@/src/stores/session"; -import { useFocusEffect } from "expo-router"; -import { useCallback, useState } from "react"; -import { FlatList, StyleSheet, Text } from "react-native"; -import colors from "tailwindcss/colors"; - -export default function LibraryScreen() { - const session = useSessionStore((state) => state.session); - const [media, setMedia] = useState(); - const [error, setError] = useState(false); - - const loadMedia = useCallback(() => { - if (!session) return; - - listMediaForIndex(session) - .then(setMedia) - .catch((error) => { - console.error("Failed to load media:", error); - setError(true); - }); - }, [session]); - - useFocusEffect( - useCallback(() => { - console.log("index focused!"); - if (!session) return; - - // load what's in the DB right now - loadMedia(); - - // sync in background, then load again - // if network is down, we just ignore the error - syncDown(session) - .then(loadMedia) - .catch((error) => { - console.error("sync error:", error); - }); - - return () => { - console.log("index unfocused"); - }; - }, [loadMedia, session]), - ); - - if (media === undefined) { - return ( - - - - ); - } - - if (error) { - return ( - - Failed to load audiobooks! - - ); - } - - return ( - item.id} - numColumns={2} - renderItem={({ item }) => } - /> - ); -} - -const styles = StyleSheet.create({ - flatlist: { - padding: 8, - }, - tile: { - padding: 8, - width: "50%", - marginBottom: 8, - }, - error: { - color: colors.red[500], - }, -}); diff --git a/src/app/(tabs)/(library)/media/[id].tsx b/src/app/(tabs)/(library)/media/[id].tsx deleted file mode 100644 index 20eccb3..0000000 --- a/src/app/(tabs)/(library)/media/[id].tsx +++ /dev/null @@ -1,1253 +0,0 @@ -import Description from "@/src/components/Description"; -import IconButton from "@/src/components/IconButton"; -import Loading from "@/src/components/Loading"; -import NamesList from "@/src/components/NamesList"; -import ThumbnailImage from "@/src/components/ThumbnailImage"; -import { - BookTile, - MediaTile, - PersonTile, - SeriesBookTile, -} from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; -import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; -import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { useDownloadsStore } from "@/src/stores/downloads"; -import { useScreenStore } from "@/src/stores/screen"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; -import { formatPublished } from "@/src/utils/date"; -import { durationDisplay } from "@/src/utils/time"; -import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; -import { - and, - desc, - eq, - inArray, - isNull, - ne, - notInArray, - or, - sql, -} from "drizzle-orm"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useState } from "react"; -import { FlatList, Pressable, Text, View } from "react-native"; -import colors from "tailwindcss/colors"; - -export default function MediaDetails() { - const session = useSessionStore((state) => state.session); - const { id: mediaId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); - useSyncOnFocus(); - - if (!session) return null; - - return ( - <> - - - - ); -} - -type HeaderSection = { - id: string; - type: "header"; - mediaId: string; -}; - -type ActionBarSection = { - id: string; - type: "actionBar"; - mediaId: string; -}; - -type MediaDescriptionSection = { - id: string; - type: "mediaDescription"; - mediaId: string; -}; - -type AuthorsAndNarratorsSection = { - id: string; - type: "authorsAndNarrators"; - mediaId: string; -}; - -type OtherEditionsSection = { - id: string; - type: "otherEditions"; - bookId: string; - withoutMediaId: string; -}; - -type OtherBooksInSeriesSection = { - id: string; - type: "otherBooksInSeries"; - seriesId: string; -}; - -type OtherBooksByAuthorSection = { - id: string; - type: "otherBooksByAuthor"; - authorId: string; - withoutBookId: string; - withoutSeriesIds: string[]; -}; - -type OtherMediaByNarratorSection = { - id: string; - type: "otherMediaByNarrator"; - narratorId: string; - withoutMediaId: string; - withoutSeriesIds: string[]; - withoutAuthorIds: string[]; -}; - -type Section = - | HeaderSection - | ActionBarSection - | MediaDescriptionSection - | AuthorsAndNarratorsSection - | OtherEditionsSection - | OtherBooksInSeriesSection - | OtherBooksByAuthorSection - | OtherMediaByNarratorSection; - -function useSections(mediaId: string, session: Session) { - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: { bookId: true }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - book: { - columns: {}, - with: { - bookAuthors: { - columns: { authorId: true }, - }, - seriesBooks: { - columns: { seriesId: true }, - }, - }, - }, - mediaNarrators: { - columns: { narratorId: true }, - }, - }, - }), - ); - - const [sections, setSections] = useState(); - - useEffect(() => { - if (!media) return; - - const collectedIds = { - mediaId, - bookId: media.bookId, - authorIds: media.book.bookAuthors.map((ba) => ba.authorId), - seriesIds: media.book.seriesBooks.map((sb) => sb.seriesId), - narratorIds: media.mediaNarrators.map((mn) => mn.narratorId), - }; - - const sections: Section[] = [ - { id: `header-${mediaId}`, type: "header", mediaId }, - { id: `actions-${mediaId}`, type: "actionBar", mediaId }, - { - id: `description-${mediaId}`, - type: "mediaDescription", - mediaId, - }, - { - id: `authors-narrators-${mediaId}`, - type: "authorsAndNarrators", - mediaId, - }, - { - id: `editions-${mediaId}`, - type: "otherEditions", - bookId: collectedIds.bookId, - withoutMediaId: mediaId, - }, - ...collectedIds.seriesIds.map( - (seriesId): OtherBooksInSeriesSection => ({ - id: `books-in-series-${seriesId}`, - type: "otherBooksInSeries", - seriesId, - }), - ), - ...collectedIds.authorIds.map( - (authorId): OtherBooksByAuthorSection => ({ - id: `other-books-${authorId}`, - type: "otherBooksByAuthor", - authorId, - withoutBookId: collectedIds.bookId, - withoutSeriesIds: collectedIds.seriesIds, - }), - ), - ...collectedIds.narratorIds.map( - (narratorId): OtherMediaByNarratorSection => ({ - id: `other-media-${narratorId}`, - type: "otherMediaByNarrator", - narratorId, - withoutMediaId: mediaId, - withoutSeriesIds: collectedIds.seriesIds, - withoutAuthorIds: collectedIds.authorIds, - }), - ), - ]; - setSections(sections); - }, [media, mediaId, session]); - - return sections; -} - -function MediaDetailsFlatList({ - session, - mediaId, -}: { - session: Session; - mediaId: string; -}) { - const sections = useSections(mediaId, session); - - if (!sections) return null; - - return ( - item.id} - initialNumToRender={3} - ListHeaderComponent={} - ListFooterComponent={} - renderItem={({ item }) => { - switch (item.type) { - case "header": - return
; - case "actionBar": - return ; - case "mediaDescription": - return ( - - ); - case "authorsAndNarrators": - return ( - - ); - case "otherEditions": - return ( - - ); - case "otherBooksInSeries": - return ( - - ); - case "otherBooksByAuthor": - return ( - - ); - case "otherMediaByNarrator": - return ( - - ); - default: - // can't happen - console.error("unknown section type:", item); - return null; - } - }} - /> - ); -} - -function Header({ mediaId, session }: { mediaId: string; session: Session }) { - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: { - fullCast: true, - abridged: true, - thumbnails: true, - duration: true, - }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - download: { - columns: { thumbnails: true }, - }, - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - book: { - columns: { title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - seriesBooks: { - columns: { bookNumber: true }, - with: { series: { columns: { name: true } } }, - }, - }, - }, - }, - }), - ); - - if (!media) return null; - - return ( - - - - - {media.book.title} - - {media.book.seriesBooks.length !== 0 && ( - `${sb.series.name} #${sb.bookNumber}`, - )} - className="text-lg text-zinc-100 leading-tight" - /> - )} - ba.author.name)} - className="text-lg text-zinc-300 leading-tight" - /> - {media.mediaNarrators.length > 0 && ( - mn.narrator.name)} - className="text-zinc-400 leading-tight" - /> - )} - {media.mediaNarrators.length === 0 && media.fullCast && ( - - Read by a full cast - - )} - - {media.duration && ( - - - {durationDisplay(media.duration)} {media.abridged && "(abridged)"} - - - )} - - ); -} - -function ActionBar({ - mediaId, - session, -}: { - mediaId: string; - session: Session; -}) { - const progress = useDownloadsStore( - (state) => state.downloadProgresses[mediaId], - ); - const { loadMedia: loadMediaIntoPlayer, requestExpandPlayer } = - useTrackPlayerStore((state) => state); - const { startDownload } = useDownloadsStore(); - const router = useRouter(); - - const { data: media } = useLiveTablesQuery( - db.query.media.findFirst({ - columns: { - id: true, - thumbnails: true, - mp4Path: true, - }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - download: { - columns: { status: true }, - }, - }, - }), - ["media", "downloads"], - ); - - if (!media) return null; - - if (progress) { - return ( - - router.navigate("/downloads")} - > - - - - - - Downloading... - - - - - ); - } else if (media.download && media.download.status !== "error") { - return ( - - - - { - loadMediaIntoPlayer(session, media.id); - requestExpandPlayer(); - }} - > - - Play - - - - - - You have this audiobook downloaded, it will play from your device and - not require an internet connection. - - - ); - } else { - return ( - - - - { - loadMediaIntoPlayer(session, media.id); - requestExpandPlayer(); - }} - > - - Stream - - - - - { - if (!media.mp4Path) return; - startDownload( - session, - media.id, - media.mp4Path, - media.thumbnails, - ); - router.navigate("/downloads"); - }} - > - - Download - - - - - - Playing this audiobook will stream it and require an internet - connection and may use your data plan. - - - ); - } -} - -function MediaDescription({ - mediaId, - session, -}: { - mediaId: string; - session: Session; -}) { - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: { - description: true, - published: true, - publishedFormat: true, - publisher: true, - notes: true, - }, - with: { - book: { - columns: { published: true, publishedFormat: true }, - }, - }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - }), - ); - - if (!media?.description) return null; - - return ( - - - - {media.book.published && ( - - First published{" "} - {formatPublished(media.book.published, media.book.publishedFormat)} - - )} - {media.published && ( - - This edition published{" "} - {formatPublished(media.published, media.publishedFormat)} - - )} - {media.publisher && ( - by {media.publisher} - )} - {media.notes && ( - Note: {media.notes} - )} - - - ); -} - -function AuthorsAndNarrators({ - mediaId, - session, -}: { - mediaId: string; - session: Session; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: {}, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - book: { - columns: {}, - with: { - bookAuthors: { - columns: { id: true }, - with: { - author: { - columns: { name: true }, - with: { - person: { - columns: { id: true, name: true, thumbnails: true }, - }, - }, - }, - }, - }, - }, - }, - mediaNarrators: { - columns: { id: true }, - with: { - narrator: { - columns: { name: true }, - with: { - person: { columns: { id: true, name: true, thumbnails: true } }, - }, - }, - }, - }, - }, - }), - ); - - const [authorSet, setAuthorSet] = useState>(new Set()); - const [narratorSet, setNarratorSet] = useState>( - new Set(), - ); - - useEffect(() => { - if (!media) return; - - const newAuthorSet = new Set(); - for (const ba of media.book.bookAuthors) { - newAuthorSet.add(ba.author.person.id); - } - setAuthorSet(newAuthorSet); - - const newNarratorSet = new Set(); - for (const mn of media.mediaNarrators) { - newNarratorSet.add(mn.narrator.person.id); - } - setNarratorSet(newNarratorSet); - }, [media]); - - if (!media) return null; - - return ( - - - Author{media.book.bookAuthors.length > 1 && "s"} & Narrator - {media.mediaNarrators.length > 1 && "s"} - - item.id} - horizontal={true} - renderItem={({ item }) => { - if ("author" in item) { - const label = narratorSet.has(item.author.person.id) - ? "Author & Narrator" - : "Author"; - return ( - - - - ); - } - - if ("narrator" in item) { - // skip if this person is also an author, as they were already rendered - if (authorSet.has(item.narrator.person.id)) return null; - - return ( - - - - ); - } - - // can't happen: - console.error("unknown item:", item); - return null; - }} - /> - - ); -} - -function OtherEditions({ - bookId, - session, - withoutMediaId, -}: { - bookId: string; - session: Session; - withoutMediaId: string; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: mediaIds } = useLiveQuery( - db - .select({ id: schema.media.id }) - .from(schema.media) - .limit(10) - .where( - and( - eq(schema.media.url, session.url), - eq(schema.media.bookId, bookId), - ne(schema.media.id, withoutMediaId), - ), - ), - ); - - const { data: media } = useLiveQuery( - db.query.media.findMany({ - columns: { id: true, thumbnails: true }, - where: and( - eq(schema.media.url, session.url), - inArray( - schema.media.id, - mediaIds.map((media) => media.id), - ), - ), - orderBy: desc(schema.media.published), - with: { - download: { - columns: { thumbnails: true }, - }, - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - }), - [mediaIds], - ); - - const router = useRouter(); - - if (media.length === 0) return null; - - return ( - - { - router.navigate({ - pathname: "/book/[id]", - params: { id: media[0].book.id }, - }); - }} - > - - - Other Editions - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} - -function OtherBooksInSeries({ - seriesId, - session, -}: { - seriesId: string; - session: Session; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: series } = useLiveQuery( - db.query.series.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.series.url, session.url), - eq(schema.series.id, seriesId), - ), - with: { - seriesBooks: { - columns: { id: true, bookNumber: true }, - orderBy: sql`CAST(book_number AS FLOAT)`, - limit: 10, - with: { - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }, - }, - }, - }, - }), - ); - - const router = useRouter(); - - if (!series) return null; - - return ( - - { - router.navigate({ - pathname: "/series/[id]", - params: { id: series.id }, - }); - }} - > - - - {series.name} - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} - -function OtherBooksByAuthor({ - authorId, - session, - withoutBookId, - withoutSeriesIds, -}: { - authorId: string; - session: Session; - withoutBookId: string; - withoutSeriesIds: string[]; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: booksIds } = useLiveQuery( - db - .selectDistinct({ id: schema.books.id }) - .from(schema.authors) - .innerJoin( - schema.bookAuthors, - and( - eq(schema.authors.url, schema.bookAuthors.url), - eq(schema.authors.id, schema.bookAuthors.authorId), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.bookAuthors.url, schema.books.url), - eq(schema.bookAuthors.bookId, schema.books.id), - ), - ) - .leftJoin( - schema.seriesBooks, - and( - eq(schema.books.url, schema.seriesBooks.url), - eq(schema.books.id, schema.seriesBooks.bookId), - ), - ) - .limit(10) - .where( - and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ne(schema.books.id, withoutBookId), - or( - isNull(schema.seriesBooks.seriesId), - notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), - ), - ), - ), - ); - - const { data: author } = useLiveQuery( - db.query.authors.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: books } = useLiveQuery( - db.query.books.findMany({ - columns: { id: true, title: true }, - where: and( - eq(schema.books.url, session.url), - inArray( - schema.books.id, - booksIds.map((book) => book.id), - ), - ), - orderBy: desc(schema.books.published), - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }), - [booksIds], - ); - - const router = useRouter(); - - if (!author) return null; - - if (books.length === 0) return null; - - return ( - - { - router.navigate({ - pathname: "/person/[id]", - params: { id: author.person.id }, - }); - }} - > - - - More by {author.name} - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} - -function OtherMediaByNarrator({ - narratorId, - session, - withoutMediaId, - withoutSeriesIds, - withoutAuthorIds, -}: { - narratorId: string; - session: Session; - withoutMediaId: string; - withoutSeriesIds: string[]; - withoutAuthorIds: string[]; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: mediaIds } = useLiveQuery( - db - .selectDistinct({ id: schema.media.id }) - .from(schema.narrators) - .innerJoin( - schema.mediaNarrators, - and( - eq(schema.narrators.url, schema.mediaNarrators.url), - eq(schema.narrators.id, schema.mediaNarrators.narratorId), - ), - ) - .innerJoin( - schema.media, - and( - eq(schema.mediaNarrators.url, schema.media.url), - eq(schema.mediaNarrators.mediaId, schema.media.id), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.media.url, schema.books.url), - eq(schema.media.bookId, schema.books.id), - ), - ) - .innerJoin( - schema.bookAuthors, - and( - eq(schema.books.url, schema.bookAuthors.url), - eq(schema.books.id, schema.bookAuthors.bookId), - ), - ) - .leftJoin( - schema.seriesBooks, - and( - eq(schema.books.url, schema.seriesBooks.url), - eq(schema.books.id, schema.seriesBooks.bookId), - ), - ) - .limit(10) - .where( - and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ne(schema.media.id, withoutMediaId), - notInArray(schema.bookAuthors.authorId, withoutAuthorIds), - or( - isNull(schema.seriesBooks.seriesId), - notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), - ), - ), - ), - ); - - const { data: narrator } = useLiveQuery( - db.query.narrators.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: media } = useLiveQuery( - db.query.media.findMany({ - columns: { id: true, thumbnails: true }, - where: and( - eq(schema.media.url, session.url), - inArray( - schema.media.id, - mediaIds.map((media) => media.id), - ), - ), - orderBy: desc(schema.media.published), - with: { - download: { - columns: { thumbnails: true }, - }, - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - }), - [mediaIds], - ); - - const router = useRouter(); - - if (!narrator) return null; - - if (media.length === 0) return null; - - return ( - - { - router.navigate({ - pathname: "/person/[id]", - params: { id: narrator.person.id }, - }); - }} - > - - - More read by {narrator.name} - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} diff --git a/src/app/(tabs)/(library)/person/[id].tsx b/src/app/(tabs)/(library)/person/[id].tsx deleted file mode 100644 index 904194d..0000000 --- a/src/app/(tabs)/(library)/person/[id].tsx +++ /dev/null @@ -1,472 +0,0 @@ -import Description from "@/src/components/Description"; -import ThumbnailImage from "@/src/components/ThumbnailImage"; -import { BookTile, MediaTile } from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; -import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; -import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { and, desc, eq, inArray } from "drizzle-orm"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { Stack, useLocalSearchParams } from "expo-router"; -import { useEffect, useState } from "react"; -import { FlatList, StyleSheet, Text, View } from "react-native"; - -export default function PersonDetails() { - const session = useSessionStore((state) => state.session); - const { id: personId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); - useSyncOnFocus(); - - if (!session) return null; - - return ( - <> - - - - ); -} - -type HeaderSection = { - id: string; - type: "header"; - personId: string; -}; - -type PersonDescriptionSection = { - id: string; - type: "personDescription"; - personId: string; -}; - -type BooksByAuthorSection = { - id: string; - type: "booksByAuthor"; - authorId: string; -}; - -type MediaByNarratorSection = { - id: string; - type: "mediaByNarrator"; - narratorId: string; -}; - -type Section = - | HeaderSection - | PersonDescriptionSection - | BooksByAuthorSection - | MediaByNarratorSection; - -function useSections(personId: string, session: Session) { - const { data: person } = useLiveTablesQuery( - db.query.people.findFirst({ - columns: {}, - where: and( - eq(schema.people.url, session.url), - eq(schema.people.id, personId), - ), - with: { - authors: { - columns: { id: true }, - }, - narrators: { - columns: { id: true }, - }, - }, - }), - ["people", "authors", "narrators"], - ); - - const [sections, setSections] = useState(); - - useEffect(() => { - if (!person) return; - - const collectedIds = { - personId, - authorIds: person.authors.map((a) => a.id), - narratorIds: person.narrators.map((n) => n.id), - }; - - const sections: Section[] = [ - { id: `header-${personId}`, type: "header", personId }, - { - id: `description-${personId}`, - type: "personDescription", - personId, - }, - ...collectedIds.authorIds.map( - (authorId): BooksByAuthorSection => ({ - id: `books-${authorId}`, - type: "booksByAuthor", - authorId, - }), - ), - ...collectedIds.narratorIds.map( - (narratorId): MediaByNarratorSection => ({ - id: `media-${narratorId}`, - type: "mediaByNarrator", - narratorId, - }), - ), - ]; - setSections(sections); - }, [person, personId, session]); - - return sections; -} - -function PersonDetailsFlatList({ - session, - personId, -}: { - session: Session; - personId: string; -}) { - const sections = useSections(personId, session); - - if (!sections) return null; - - return ( - item.id} - initialNumToRender={2} - ListHeaderComponent={} - ListFooterComponent={} - renderItem={({ item }) => { - switch (item.type) { - case "header": - return
; - case "personDescription": - return ( - - ); - case "booksByAuthor": - return ; - case "mediaByNarrator": - return ( - - ); - default: - // can't happen - console.error("unknown section type:", item); - return null; - } - }} - /> - ); -} - -function Header({ personId, session }: { personId: string; session: Session }) { - const { data: person } = useLiveQuery( - db.query.people.findFirst({ - columns: { - name: true, - thumbnails: true, - }, - where: and( - eq(schema.people.url, session.url), - eq(schema.people.id, personId), - ), - }), - ); - - if (!person) return null; - - return ( - - ); -} - -function PersonDescription({ - personId, - session, -}: { - personId: string; - session: Session; -}) { - const { data: person } = useLiveQuery( - db.query.people.findFirst({ - columns: { - description: true, - }, - where: and( - eq(schema.people.url, session.url), - eq(schema.people.id, personId), - ), - }), - ); - - if (!person?.description) return null; - - return ( - - - - ); -} - -function BooksByAuthor({ - authorId, - session, -}: { - authorId: string; - session: Session; -}) { - const { data: booksIds } = useLiveQuery( - db - .selectDistinct({ id: schema.books.id }) - .from(schema.authors) - .innerJoin( - schema.bookAuthors, - and( - eq(schema.authors.url, schema.bookAuthors.url), - eq(schema.authors.id, schema.bookAuthors.authorId), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.bookAuthors.url, schema.books.url), - eq(schema.bookAuthors.bookId, schema.books.id), - ), - ) - .where( - and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ), - ), - ); - - const { data: author } = useLiveQuery( - db.query.authors.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: books } = useLiveQuery( - db.query.books.findMany({ - columns: { id: true, title: true }, - where: and( - eq(schema.books.url, session.url), - inArray( - schema.books.id, - booksIds.map((book) => book.id), - ), - ), - orderBy: desc(schema.books.published), - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }), - [booksIds], - ); - - if (!author) return null; - - if (books.length === 0) return null; - - return ( - - - {author.name === author.person.name - ? `By ${author.name}` - : `As ${author.name}`} - - - item.id} - numColumns={2} - renderItem={({ item }) => { - return ; - }} - /> - - ); -} - -function MediaByNarrator({ - narratorId, - session, -}: { - narratorId: string; - session: Session; -}) { - const { data: mediaIds } = useLiveQuery( - db - .selectDistinct({ id: schema.media.id }) - .from(schema.narrators) - .innerJoin( - schema.mediaNarrators, - and( - eq(schema.narrators.url, schema.mediaNarrators.url), - eq(schema.narrators.id, schema.mediaNarrators.narratorId), - ), - ) - .innerJoin( - schema.media, - and( - eq(schema.mediaNarrators.url, schema.media.url), - eq(schema.mediaNarrators.mediaId, schema.media.id), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.media.url, schema.books.url), - eq(schema.media.bookId, schema.books.id), - ), - ) - .where( - and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ), - ), - ); - - const { data: narrator } = useLiveQuery( - db.query.narrators.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: media } = useLiveQuery( - db.query.media.findMany({ - columns: { id: true, thumbnails: true }, - where: and( - eq(schema.media.url, session.url), - inArray( - schema.media.id, - mediaIds.map((media) => media.id), - ), - ), - orderBy: desc(schema.media.published), - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - }), - [mediaIds], - ); - - if (!narrator) return null; - - if (media.length === 0) return null; - - return ( - - - {narrator.name === narrator.person.name - ? `Read by ${narrator.name}` - : `Read as ${narrator.name}`} - - - item.id} - numColumns={2} - renderItem={({ item }) => { - return ; - }} - /> - - ); -} - -const styles = StyleSheet.create({ - tile: { - padding: 8, - width: "50%", - marginBottom: 8, - }, -}); diff --git a/src/app/+native-intent.tsx b/src/app/+native-intent.tsx index 029ced6..0fced04 100644 --- a/src/app/+native-intent.tsx +++ b/src/app/+native-intent.tsx @@ -1,4 +1,4 @@ -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { requestExpandPlayer } from "@/src/stores/player"; type PathArgs = { path: string; @@ -7,7 +7,7 @@ type PathArgs = { export function redirectSystemPath({ path }: PathArgs) { if (path === "trackplayer://notification.click") { - useTrackPlayerStore.getState().requestExpandPlayer(); + requestExpandPlayer(); return null; } else { return path; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ceb1adf..af93c4d 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,6 +1,7 @@ import "@/assets/global.css"; import Loading from "@/src/components/Loading"; import MeasureScreenHeight from "@/src/components/MeasureScreenHeight"; +import ScreenCentered from "@/src/components/ScreenCentered"; import { expoDb } from "@/src/db/db"; import { useAppBoot } from "@/src/hooks/use.app.boot"; import { ThemeProvider } from "@react-navigation/native"; @@ -27,7 +28,7 @@ const Theme = { }, }; -export default function App() { +export default function RootStackLayout() { useEffect(() => { if (Platform.OS === "android") { NavigationBar.setBackgroundColorAsync(colors.transparent); @@ -60,15 +61,20 @@ function Root() { ); } - return isReady ? ( + if (!isReady) { + return ( + + + + ); + } + + return ( - + + - ) : ( - - - ); } diff --git a/src/app/sign-in.tsx b/src/app/sign-in.tsx index 16c7e9b..834bab2 100644 --- a/src/app/sign-in.tsx +++ b/src/app/sign-in.tsx @@ -1,15 +1,13 @@ import Logo from "@/assets/images/logo.svg"; import Loading from "@/src/components/Loading"; -import { useSessionStore } from "@/src/stores/session"; +import { clearError, signIn, useSession } from "@/src/stores/session"; import { Redirect } from "expo-router"; import { useState } from "react"; import { Button, Text, TextInput, View } from "react-native"; import colors from "tailwindcss/colors"; export default function SignInScreen() { - const { session, error, isLoading, signIn, clearError } = useSessionStore( - (state) => state, - ); + const { session, error, isLoading } = useSession((state) => state); const [email, setEmail] = useState(session?.email || ""); const [host, setHost] = useState(session?.url || ""); const [password, setPassword] = useState(""); diff --git a/src/app/sign-out.tsx b/src/app/sign-out.tsx new file mode 100644 index 0000000..cbeceb0 --- /dev/null +++ b/src/app/sign-out.tsx @@ -0,0 +1,22 @@ +import Loading from "@/src/components/Loading"; +import ScreenCentered from "@/src/components/ScreenCentered"; +import { unloadPlayer } from "@/src/stores/player"; +import { signOut } from "@/src/stores/session"; +import { router } from "expo-router"; +import { useEffect } from "react"; + +export default function SignOutScreen() { + useEffect(() => { + (async function () { + await unloadPlayer(); + await signOut(); + router.navigate("/sign-in"); + })(); + }); + + return ( + + + + ); +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..46c1641 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,36 @@ +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from "react-native"; + +type ButtonProps = { + size: number; + style?: StyleProp; + onPress: () => void; + children?: React.ReactNode; +}; + +export default function Button(props: ButtonProps) { + const { size, style, onPress, children } = props; + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + display: "flex", + alignItems: "center", + justifyContent: "center", + // backgroundColor: "orange", + }, +}); diff --git a/src/components/Description.tsx b/src/components/Description.tsx index 99a7a75..5e24927 100644 --- a/src/components/Description.tsx +++ b/src/components/Description.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { Image, StyleSheet, Text, View } from "react-native"; import Markdown from "react-native-markdown-display"; import colors from "tailwindcss/colors"; +import Button from "./Button"; export default function Description({ description }: { description: string }) { const [expanded, setExpanded] = useState(false); @@ -23,9 +24,13 @@ export default function Description({ description }: { description: string }) { /> )} - setExpanded(!expanded)}> + ); } diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index b7d44e6..4a39d82 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -1,27 +1,33 @@ import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; -import { StyleSheet, TouchableOpacity, View } from "react-native"; +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from "react-native"; type IconButtonProps = { size: number; icon: string; color: string; + style?: StyleProp; onPress: () => void; - padding?: number; + onLongPress?: () => void; children?: React.ReactNode; }; export default function IconButton(props: IconButtonProps) { - const { size, icon, color, onPress, padding = size / 2, children } = props; + const { size, icon, color, style, onPress, onLongPress, children } = props; return ( - - - + + + {/* NOTE: for some reason the some icons get cut off when height and + width is exactly equal to the icon size */} + + + {children} @@ -33,5 +39,6 @@ const styles = StyleSheet.create({ display: "flex", alignItems: "center", justifyContent: "center", + // backgroundColor: "purple", }, }); diff --git a/src/components/MeasureScreenHeight.tsx b/src/components/MeasureScreenHeight.tsx index c86d65f..6e01c62 100644 --- a/src/components/MeasureScreenHeight.tsx +++ b/src/components/MeasureScreenHeight.tsx @@ -1,12 +1,10 @@ +import { setDimensions } from "@/src/stores/screen"; import { StyleSheet, View } from "react-native"; -import { useScreenStore } from "../stores/screen"; // This is a workaround due to Android screen height currently being broken: // https://github.com/facebook/react-native/issues/47080 export default function MeasureScreenHeight() { - const { setDimensions } = useScreenStore((state) => state); - return ( { diff --git a/src/components/PlayButton.tsx b/src/components/PlayButton.tsx index a4e13af..7c1a778 100644 --- a/src/components/PlayButton.tsx +++ b/src/components/PlayButton.tsx @@ -1,8 +1,6 @@ -import { StyleSheet, View } from "react-native"; -import TrackPlayer, { - State, - usePlaybackState, -} from "react-native-track-player"; +import { playOrPause, usePlayer } from "@/src/stores/player"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { State } from "react-native-track-player"; import { useDebounce } from "use-debounce"; import IconButton from "./IconButton"; import Loading from "./Loading"; @@ -10,35 +8,33 @@ import Loading from "./Loading"; type PlayButtonProps = { size: number; color: string; - padding?: number; + style?: StyleProp; }; export default function PlayButton(props: PlayButtonProps) { - const { size, color, padding = size / 2 } = props; - const { state } = usePlaybackState(); + const { size, color, style } = props; + const state = usePlayer((state) => state.state); const [debouncedState] = useDebounce(state, 50); const icon = stateIcon(debouncedState); if (!debouncedState || !icon || icon === "spinner") { return ( - - + + {/* NOTE: this sizing has to match the sizing of the IconButton component */} + + + ); } return ( ); } @@ -71,25 +67,3 @@ function stateIcon(state: State | undefined): string | undefined { } return; } - -function stateAction(state: State | undefined): () => void { - switch (state) { - case State.Paused: - case State.Stopped: - case State.Ready: - case State.Error: - return () => { - TrackPlayer.play(); - }; - case State.Playing: - return () => { - TrackPlayer.pause(); - }; - case State.Buffering: - case State.Loading: - case State.None: - case State.Ended: - return () => {}; - } - return () => {}; -} diff --git a/src/components/PlayerButtons.tsx b/src/components/PlayerButtons.tsx index 2c22e66..99533e5 100644 --- a/src/components/PlayerButtons.tsx +++ b/src/components/PlayerButtons.tsx @@ -18,7 +18,7 @@ export default function PlayerButtons() { size={32} color={colors.zinc[100]} /> - + state.chapterState); + + if (!chapterState) return null; + + return ( + <> + + + + + + {/* maybe this is a bit much... */} + {/* + + {secondsDisplay(position - chapterState.currentChapter.startTime)} + + + - + {secondsDisplay( + ((chapterState.currentChapter.endTime || duration) - position) / + playbackRate, + )} + + */} + + ); +} + +const styles = StyleSheet.create({ + container: { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginHorizontal: -12, // or -20 if the below is used + // backgroundColor: colors.zinc[800], + // borderRadius: 999, + // paddingHorizontal: 8, + }, + chapterText: { + color: colors.zinc[100], + }, + timeDisplayContainer: { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginTop: -8, + marginHorizontal: -2, + }, + timeDisplayText: { + fontSize: 12, + color: colors.zinc[600], + }, +}); diff --git a/src/components/PlayerProgressBar.tsx b/src/components/PlayerProgressBar.tsx index 3939647..f7414c9 100644 --- a/src/components/PlayerProgressBar.tsx +++ b/src/components/PlayerProgressBar.tsx @@ -1,24 +1,34 @@ -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { usePlayer } from "@/src/stores/player"; import { secondsDisplay } from "@/src/utils/time"; import { StyleSheet, Text, View } from "react-native"; import colors from "tailwindcss/colors"; +import { useShallow } from "zustand/react/shallow"; export default function PlayerProgressBar() { - const { position, duration } = useTrackPlayerStore((state) => state); + const { position, duration, playbackRate } = usePlayer( + useShallow(({ position, duration, playbackRate }) => ({ + position, + duration, + playbackRate, + })), + ); const percent = duration > 0 ? (position / duration) * 100 : 0; return ( - <> + {secondsDisplay(position)} - -{secondsDisplay(Math.max(duration - position, 0))} + -{secondsDisplay(Math.max(duration - position, 0) / playbackRate)} + + + {percent.toFixed(1)}% - + ); } @@ -38,8 +48,15 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", paddingTop: 4, + position: "relative", }, timeDisplayText: { color: colors.zinc[400], }, + percentText: { + position: "absolute", + top: 4, + width: "100%", + textAlign: "center", + }, }); diff --git a/src/components/PlayerScrubber.tsx b/src/components/PlayerScrubber.tsx index 9901d0c..7da9dc4 100644 --- a/src/components/PlayerScrubber.tsx +++ b/src/components/PlayerScrubber.tsx @@ -1,11 +1,16 @@ -import TrackPlayer from "react-native-track-player"; +import { seekTo, usePlayer } from "@/src/stores/player"; import colors from "tailwindcss/colors"; -import { useTrackPlayerStore } from "../stores/trackPlayer"; +import { useShallow } from "zustand/react/shallow"; import Scrubber from "./Scrubber"; export default function PlayerScrubber() { - const { playbackRate, position, duration } = useTrackPlayerStore( - (state) => state, + const { playbackRate, position, duration, chapterState } = usePlayer( + useShallow(({ playbackRate, position, duration, chapterState }) => ({ + playbackRate, + position, + duration, + chapterState, + })), ); const theme = { accent: colors.lime[400], @@ -15,14 +20,16 @@ export default function PlayerScrubber() { dimmed: colors.gray[500], weak: colors.gray[800], }; + const markers = + chapterState?.chapters.map((chapter) => chapter.startTime) || []; return ( TrackPlayer.seekTo(newPosition)} - markers={[]} + onChange={(newPosition: number) => seekTo(newPosition)} + markers={markers} theme={theme} /> ); diff --git a/src/components/PlayerSettingButtons.tsx b/src/components/PlayerSettingButtons.tsx new file mode 100644 index 0000000..c5a6334 --- /dev/null +++ b/src/components/PlayerSettingButtons.tsx @@ -0,0 +1,110 @@ +import { setSleepTimerState, usePlayer } from "@/src/stores/player"; +import { formatPlaybackRate } from "@/src/utils/rate"; +import * as Haptics from "expo-haptics"; +import { router } from "expo-router"; +import { useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import colors from "tailwindcss/colors"; +import { secondsDisplayMinutesOnly } from "../utils/time"; +import IconButton from "./IconButton"; + +export default function PlayerSettingButtons() { + const playbackRate = usePlayer((state) => state.playbackRate); + const sleepTimer = usePlayer((state) => state.sleepTimer); + const sleepTimerEnabled = usePlayer((state) => state.sleepTimerEnabled); + const sleepTimerTriggerTime = usePlayer( + (state) => state.sleepTimerTriggerTime, + ); + const position = usePlayer((state) => state.position); + const [countdown, setCountdown] = useState(null); + + useEffect(() => { + if (sleepTimerEnabled && sleepTimerTriggerTime !== null) { + const newCountdown = (sleepTimerTriggerTime - Date.now()) / 1000; + setCountdown(Math.max(0, newCountdown)); + } else { + setCountdown(null); + } + }, [position, sleepTimerEnabled, sleepTimerTriggerTime]); + + return ( + + { + router.navigate("/sleep-timer"); + }} + onLongPress={() => { + setSleepTimerState(!sleepTimerEnabled); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }} + > + + + { + router.navigate("/playback-rate"); + }} + > + + {formatPlaybackRate(playbackRate)}× + + + + ); +} + +type SleepTimerLabelProps = { + sleepTimer: number; + sleepTimerEnabled: boolean; + countdown: number | null; +}; + +function SleepTimerLabel(props: SleepTimerLabelProps) { + const { sleepTimer, sleepTimerEnabled, countdown } = props; + + if (!sleepTimerEnabled) return null; + + if (countdown === null) + return ( + + {secondsDisplayMinutesOnly(sleepTimer)} + + ); + + return ( + + {secondsDisplayMinutesOnly(countdown)} + + ); +} + +const styles = StyleSheet.create({ + container: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + marginHorizontal: -20, + }, + button: { + backgroundColor: colors.zinc[800], + borderRadius: 999, + paddingHorizontal: 16, + flexDirection: "row", + gap: 4, + }, + sleepTimerText: { + color: colors.zinc[100], + }, +}); diff --git a/src/components/Scrubber.tsx b/src/components/Scrubber.tsx index e68b773..55ecdaf 100644 --- a/src/components/Scrubber.tsx +++ b/src/components/Scrubber.tsx @@ -1,5 +1,4 @@ import AnimatedText from "@/src/components/AnimatedText"; -import usePrevious from "@react-hook/previous"; import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, StyleSheet } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -175,10 +174,11 @@ export default function Scrubber(props: ScrubberProps) { ); const [isScrubbing, setIsScrubbing] = useIsScrubbing(); const maxTranslateX = timeToTranslateX(duration); - const previousPosition = usePrevious(positionInput); + // const previousPosition = usePrevious(positionInput); const startX = useSharedValue(0); const isAnimating = useSharedValue(false); + const timecodeOpacity = useSharedValue(0); const panGestureHandler = Gesture.Pan() .onStart((_event) => { @@ -282,6 +282,10 @@ export default function Scrubber(props: ScrubberProps) { return { transform: [{ translateX: HALF_WIDTH + value }] }; }); + const animatedTimecodeStyle = useAnimatedStyle(() => { + return { opacity: withTiming(timecodeOpacity.value) }; + }); + const timecode = useDerivedValue(() => { const total = clamp(translateXToTime(translateX.value), 0, duration); const hours = Math.floor(total / 3600).toString(); @@ -297,6 +301,7 @@ export default function Scrubber(props: ScrubberProps) { useEffect(() => { if (!isScrubbing) { + timecodeOpacity.value = 0; translateX.value = timeToTranslateX(positionInput); // if (Math.abs(positionInput - (previousPosition || 0)) <= 3) { // console.log("linear"); @@ -316,15 +321,28 @@ export default function Scrubber(props: ScrubberProps) { // console.log("jump"); // translateX.value = timeToTranslateX(positionInput); // } + } else { + timecodeOpacity.value = 1; } - }, [translateX, isScrubbing, positionInput, previousPosition, playbackRate]); + }, [ + translateX, + isScrubbing, + positionInput, + // previousPosition, + playbackRate, + timecodeOpacity, + ]); return ( ; }; export default function SeekButton(props: SeekButtonProps) { - const { icon, size, color, amount, padding = size / 2 } = props; - const { seekRelative } = useTrackPlayerStore((state) => state); + const { icon, size, color, amount, style } = props; return ( ); } diff --git a/src/components/TabBarWithPlayer.tsx b/src/components/TabBarWithPlayer.tsx index ef34e94..da248c0 100644 --- a/src/components/TabBarWithPlayer.tsx +++ b/src/components/TabBarWithPlayer.tsx @@ -5,14 +5,22 @@ import PlayerProgressBar from "@/src/components/PlayerProgressBar"; import PlayerScrubber from "@/src/components/PlayerScrubber"; import ThumbnailImage from "@/src/components/ThumbnailImage"; import TitleAuthorsNarrators from "@/src/components/TitleAuthorNarrator"; +import { useMediaDetails } from "@/src/db/library"; import useBackHandler from "@/src/hooks/use.back.handler"; -import { useMediaDetails } from "@/src/hooks/use.media.details"; -import { useScreenStore } from "@/src/stores/screen"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { expandPlayerHandled, usePlayer } from "@/src/stores/player"; +import { useScreen } from "@/src/stores/screen"; +import { Session } from "@/src/stores/session"; +import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; import { BottomTabBar, BottomTabBarProps } from "@react-navigation/bottom-tabs"; import { router } from "expo-router"; import { useCallback, useEffect, useState } from "react"; -import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native"; +import { + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { Easing, @@ -23,21 +31,29 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import { useProgress } from "react-native-track-player"; import colors from "tailwindcss/colors"; +import { useShallow } from "zustand/react/shallow"; +import PlayerChapterControls from "./PlayerChapterControls"; +import PlayerSettingButtons from "./PlayerSettingButtons"; export default function TabBarWithPlayer({ state, descriptors, navigation, insets, -}: BottomTabBarProps) { - const { mediaId, lastPlayerExpandRequest, expandPlayerHandled } = - useTrackPlayerStore((state) => state); - const { media } = useMediaDetails(mediaId); + session, + mediaId, +}: BottomTabBarProps & { session: Session; mediaId: string }) { + const { lastPlayerExpandRequest, streaming } = usePlayer( + useShallow(({ lastPlayerExpandRequest, streaming }) => ({ + lastPlayerExpandRequest, + streaming, + })), + ); + const { data: media, opacity } = useMediaDetails(session, mediaId); const [expanded, setExpanded] = useState(true); const expansion = useSharedValue(1.0); - const { screenHeight, screenWidth } = useScreenStore((state) => state); + const { screenHeight, screenWidth } = useScreen((state) => state); const whereItWas = useSharedValue(0); const onPanEndAction = useSharedValue<"none" | "expand" | "collapse">("none"); @@ -64,12 +80,14 @@ export default function TabBarWithPlayer({ expandLocal(); } expandPlayerHandled(); - }, [expandLocal, expanded, lastPlayerExpandRequest, expandPlayerHandled]); + }, [expandLocal, expanded, lastPlayerExpandRequest]); const tabBarHeight = 50 + insets.bottom; const playerHeight = 70; - const eightyPercentScreenWidth = screenWidth * 0.8; - const tenPercentScreenWidth = screenWidth * 0.1; + const shortScreen = screenHeight / screenWidth < 1.8; + const largeImageSize = shortScreen ? screenWidth * 0.6 : screenWidth * 0.8; + const imageGutterWidth = (screenWidth - largeImageSize) / 2; + const miniControlsWidth = screenWidth - playerHeight; const debugBackgrounds = false; @@ -125,22 +143,20 @@ export default function TabBarWithPlayer({ }); const playerStyle = useAnimatedStyle(() => { - const interpolatedHeight = interpolate( - expansion.value, - [0, 1], - [playerHeight, screenHeight], - ); - - const interpolatedBottom = interpolate( - expansion.value, - [0, 1], - [tabBarHeight, 0], - ); - return { - height: interpolatedHeight, - bottom: interpolatedBottom, + opacity: opacity.value, + height: interpolate( + expansion.value, + [0, 1], + [playerHeight, screenHeight], + ), + bottom: interpolate(expansion.value, [0, 1], [tabBarHeight, 0]), paddingTop: interpolate(expansion.value, [0, 1], [0, insets.top]), + borderTopWidth: interpolate( + expansion.value, + [0, 1], + [StyleSheet.hairlineWidth, 0], + ), }; }); @@ -155,7 +171,7 @@ export default function TabBarWithPlayer({ width: interpolate( expansion.value, [0, 0.75], - [0, tenPercentScreenWidth], + [0, imageGutterWidth], Extrapolation.CLAMP, ), }; @@ -166,12 +182,12 @@ export default function TabBarWithPlayer({ height: interpolate( expansion.value, [0, 1], - [playerHeight, eightyPercentScreenWidth], + [playerHeight, largeImageSize], ), width: interpolate( expansion.value, [0, 1], - [playerHeight, eightyPercentScreenWidth], + [playerHeight, largeImageSize], ), padding: interpolate(expansion.value, [0, 1], [8, 0]), }; @@ -182,7 +198,7 @@ export default function TabBarWithPlayer({ width: interpolate( expansion.value, [0, 1], - [miniControlsWidth, tenPercentScreenWidth], + [miniControlsWidth, imageGutterWidth], ), opacity: interpolate( expansion.value, @@ -195,6 +211,12 @@ export default function TabBarWithPlayer({ const controlsStyle = useAnimatedStyle(() => { return { + transform: [ + { + translateY: interpolate(expansion.value, [0, 1], [256, 0]), + }, + ], + marginBottom: interpolate(expansion.value, [0, 1], [-512, 0]), opacity: interpolate( expansion.value, [0.75, 1], @@ -206,7 +228,7 @@ export default function TabBarWithPlayer({ const topActionBarStyle = useAnimatedStyle(() => { return { - height: interpolate(expansion.value, [0, 0.75], [0, 64]), + height: interpolate(expansion.value, [0, 0.75], [0, 36]), opacity: interpolate( expansion.value, [0.75, 1], @@ -218,7 +240,7 @@ export default function TabBarWithPlayer({ const infoStyle = useAnimatedStyle(() => { return { - paddingTop: interpolate(expansion.value, [0.75, 1], [32, 8]), + paddingTop: interpolate(expansion.value, [0.75, 1], [64, 8]), opacity: interpolate( expansion.value, [0.75, 1], @@ -239,7 +261,6 @@ export default function TabBarWithPlayer({ return ( <> - collapseLocal()} /> - {/* + + + {streaming ? "streaming" : "downloaded"} + + + )} + + console.log("TODO: context menu")} - /> */} + style={{ opacity: 0 }} + /> ba.author.name)} @@ -423,9 +468,8 @@ export default function TabBarWithPlayer({ - + + + + + + + + - @@ -456,14 +509,3 @@ export default function TabBarWithPlayer({ ); } - -function TrackPlayerProgressSubscriber() { - const { playbackRate, updateProgress } = useTrackPlayerStore( - (state) => state, - ); - const { position, duration } = useProgress(1000 / playbackRate); - useEffect(() => { - updateProgress(position, duration); - }, [duration, position, updateProgress]); - return null; -} diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 2aa0d2e..ac6e8d9 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -1,5 +1,5 @@ import { DownloadedThumbnails, Thumbnails } from "@/src/db/schema"; -import { useSessionStore } from "@/src/stores/session"; +import { useSession } from "@/src/stores/session"; import { Image, ImageStyle } from "expo-image"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import colors from "tailwindcss/colors"; @@ -14,7 +14,7 @@ type ThumbnailImageProps = { export default function ThumbnailImage(props: ThumbnailImageProps) { const { downloadedThumbnails, thumbnails, size, style, imageStyle } = props; - const session = useSessionStore((state) => state.session); + const session = useSession((state) => state.session); if (session && downloadedThumbnails) { return ( diff --git a/src/components/Tiles.tsx b/src/components/Tiles.tsx index 4345e0a..8532b8d 100644 --- a/src/components/Tiles.tsx +++ b/src/components/Tiles.tsx @@ -1,6 +1,6 @@ import MultiThumbnailImage from "@/src/components/MultiThumbnailImage"; import { DownloadedThumbnails, Thumbnails } from "@/src/db/schema"; -import { useRouter } from "expo-router"; +import { router } from "expo-router"; import { StyleProp, StyleSheet, @@ -90,8 +90,6 @@ export function SeriesBookTile({ seriesBook, style }: SeriesBookTileProps) { } export function Tile({ book, media, seriesBook, style }: TileProps) { - const router = useRouter(); - const navigateToBook = () => { if (media.length === 1) { router.navigate({ @@ -145,7 +143,6 @@ export function Tile({ book, media, seriesBook, style }: TileProps) { export function PersonTile(props: PersonTileProps) { const { personId, name, realName, thumbnails, label } = props; - const router = useRouter(); const navigateToPerson = () => { router.navigate({ diff --git a/src/db/downloads.ts b/src/db/downloads.ts index d70b83e..36dae54 100644 --- a/src/db/downloads.ts +++ b/src/db/downloads.ts @@ -1,8 +1,8 @@ import { db } from "@/src/db/db"; import * as schema from "@/src/db/schema"; +import useFadeInQuery from "@/src/hooks/use.fade.in.query"; import { Session } from "@/src/stores/session"; import { and, desc, eq } from "drizzle-orm"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; export type MediaNarrator = { id: string; @@ -42,39 +42,31 @@ export type Download = { media: Media; }; -export type LiveDownloadsList = { - readonly data: Download[]; - readonly error: Error | undefined; - readonly updatedAt: Date | undefined; -}; - -export function useLiveDownloadsList(session: Session): LiveDownloadsList { - return useLiveQuery( - db.query.downloads.findMany({ - columns: { status: true, thumbnails: true, filePath: true }, - where: eq(schema.downloads.url, session.url), - orderBy: desc(schema.downloads.downloadedAt), - with: { - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: { id: true }, - with: { - narrator: { - columns: { id: true, name: true }, - }, +export function useDownloadsList(session: Session) { + const query = db.query.downloads.findMany({ + columns: { status: true, thumbnails: true, filePath: true }, + where: eq(schema.downloads.url, session.url), + orderBy: desc(schema.downloads.downloadedAt), + with: { + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: { id: true }, + with: { + narrator: { + columns: { id: true, name: true }, }, }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: { id: true }, - with: { - author: { - columns: { id: true, name: true }, - }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: { id: true }, + with: { + author: { + columns: { id: true, name: true }, }, }, }, @@ -82,8 +74,18 @@ export function useLiveDownloadsList(session: Session): LiveDownloadsList { }, }, }, - }), - ); + }, + }); + + return useFadeInQuery(query, [ + "downloads", + "media", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + ]); } export async function getDownload( diff --git a/src/db/library.ts b/src/db/library.ts index c68b6d2..137c133 100644 --- a/src/db/library.ts +++ b/src/db/library.ts @@ -1,96 +1,25 @@ import { db } from "@/src/db/db"; import * as schema from "@/src/db/schema"; +import useFadeInQuery, { fadeInTime } from "@/src/hooks/use.fade.in.query"; +import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; import { Session } from "@/src/stores/session"; -import { and, desc, eq } from "drizzle-orm"; +import { + and, + desc, + eq, + inArray, + isNull, + ne, + notInArray, + or, + sql, +} from "drizzle-orm"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; +import { useEffect, useState } from "react"; +import { useSharedValue, withTiming } from "react-native-reanimated"; -export type Person = { - id: string; -}; - -export type Author = { - id: string; - name: string; - person: Person; -}; - -export type BookAuthor = { - id: string; - author: Author; -}; - -export type Series = { - id: string; - name: string; -}; - -export type SeriesBook = { - id: string; - bookNumber: string; - series: Series; -}; - -export type Book = { - id: string; - title: string; - bookAuthors: BookAuthor[]; - seriesBooks: SeriesBook[]; -}; - -export type Narrator = { - id: string; - name: string; - person: Person; -}; - -export type MediaNarrator = { - id: string; - narrator: Narrator; -}; - -export type MediaForIndex = { - id: string; - thumbnails: schema.Thumbnails | null; - book: Book; - mediaNarrators: MediaNarrator[]; - download: Download | null; -}; - -export type Download = { - status: string; - thumbnails: schema.DownloadedThumbnails | null; -}; - -export type MediaForDetails = { - id: string; - description: string | null; - thumbnails: schema.Thumbnails | null; - mp4Path: string | null; - duration: string | null; - book: Book; - mediaNarrators: MediaNarrator[]; - download: Download | null; - published: Date | null; - publishedFormat: "full" | "year_month" | "year"; - publisher: string | null; - notes: string | null; -}; - -export type PersonForDetails = { - id: string; - name: string; - thumbnails: schema.Thumbnails | null; - description: string | null; -}; - -export type SeriesForDetails = { - id: string; - name: string; -}; - -export async function listMediaForIndex( - session: Session, -): Promise { - return db.query.media.findMany({ +export function useMediaList(session: Session) { + const query = db.query.media.findMany({ columns: { id: true, thumbnails: true }, where: and( eq(schema.media.url, session.url), @@ -130,13 +59,23 @@ export async function listMediaForIndex( }, }, }); + + return useFadeInQuery(query, [ + "media", + "downloads", + "media_narrators", + "narrators", + "people", + "books", + "book_authors", + "authors", + "series_books", + "series", + ]); } -export async function getMediaForDetails( - session: Session, - mediaId: string, -): Promise { - return db.query.media.findFirst({ +export function useMediaDetails(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ columns: { id: true, thumbnails: true, @@ -182,30 +121,1166 @@ export async function getMediaForDetails( }, }, }); + + return useFadeInQuery( + query, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "people", + "books", + "book_authors", + "authors", + "series_books", + "series", + ], + [mediaId], + ); } -export async function getPersonForDetails( - session: Session, - personId: string, -): Promise { - return db.query.people.findFirst({ - columns: { id: true, name: true, thumbnails: true, description: true }, +export type BookDetails = { + title: string; + published: Date; + publishedFormat: "full" | "year_month" | "year"; + bookAuthors: { + author: { + id: string; + name: string; + person: { + id: string; + name: string; + thumbnails: schema.Thumbnails | null; + }; + }; + }[]; + media: { + id: string; + thumbnails: schema.Thumbnails | null; + mediaNarrators: { + narrator: { + id: string; + name: string; + person: { + id: string; + name: string; + thumbnails: schema.Thumbnails | null; + }; + }; + }[]; + download: { + thumbnails: schema.DownloadedThumbnails | null; + } | null; + }[]; +}; + +export function useBookDetails(session: Session, bookId: string) { + const query = db.query.books.findFirst({ + columns: { + id: true, + title: true, + published: true, + publishedFormat: true, + }, + where: and(eq(schema.books.url, session.url), eq(schema.books.id, bookId)), + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { id: true, name: true }, + with: { + person: { + columns: { id: true, name: true, thumbnails: true }, + }, + }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { id: true, name: true }, + with: { + person: { + columns: { + id: true, + name: true, + thumbnails: true, + }, + }, + }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "books", + "book_authors", + "authors", + "people", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [bookId], + ); +} + +// TODO: break this up into smaller hooks +export function useSeriesDetails(session: Session, seriesId: string) { + const query = db.query.series.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.series.url, session.url), + eq(schema.series.id, seriesId), + ), + with: { + seriesBooks: { + columns: { id: true, bookNumber: true }, + orderBy: sql`CAST(book_number AS FLOAT)`, + with: { + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { id: true, name: true }, + with: { + person: { + columns: { id: true, name: true, thumbnails: true }, + }, + }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { id: true, name: true }, + with: { + person: { + columns: { + id: true, + name: true, + thumbnails: true, + }, + }, + }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "series", + "series_books", + "books", + "book_authors", + "authors", + "people", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [seriesId], + ); +} + +export function usePersonIds(session: Session, personId: string) { + const query = db.query.people.findFirst({ + columns: {}, where: and( eq(schema.people.url, session.url), eq(schema.people.id, personId), ), + with: { + authors: { + columns: { id: true }, + }, + narrators: { + columns: { id: true }, + }, + }, }); + + const { data: person, opacity } = useFadeInQuery( + query, + ["people", "authors", "narrators"], + [personId], + ); + + const [ids, setIds] = useState<{ + personId: string; + authorIds: string[]; + narratorIds: string[]; + } | null>(null); + + useEffect(() => { + if (!person) return; + + setIds({ + personId, + authorIds: person.authors.map((a) => a.id), + narratorIds: person.narrators.map((n) => n.id), + }); + }, [person, personId]); + + return { ids, opacity }; } -export async function getSeriesForDetails( +export function usePersonHeaderInfo(session: Session, personId: string) { + const query = db.query.people.findFirst({ + columns: { + name: true, + thumbnails: true, + }, + where: and( + eq(schema.people.url, session.url), + eq(schema.people.id, personId), + ), + }); + + return useFadeInQuery(query, ["people"], [personId]); +} + +export function usePersonDescription(session: Session, personId: string) { + const query = db.query.people.findFirst({ + columns: { description: true }, + where: and( + eq(schema.people.url, session.url), + eq(schema.people.id, personId), + ), + }); + + return useFadeInQuery(query, ["people"], [personId]); +} + +export function useBooksByAuthor(session: Session, authorId: string) { + const bookIdsQuery = db + .selectDistinct({ id: schema.books.id }) + .from(schema.authors) + .innerJoin( + schema.bookAuthors, + and( + eq(schema.authors.url, schema.bookAuthors.url), + eq(schema.authors.id, schema.bookAuthors.authorId), + ), + ) + .innerJoin( + schema.books, + and( + eq(schema.bookAuthors.url, schema.books.url), + eq(schema.bookAuthors.bookId, schema.books.id), + ), + ) + .where( + and(eq(schema.authors.url, session.url), eq(schema.authors.id, authorId)), + ); + + const { data: bookIds, updatedAt: bookIdsUpdatedAt } = useLiveTablesQuery( + bookIdsQuery, + ["authors", "book_authors", "books"], + [authorId], + ); + + const authorQuery = db.query.authors.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.authors.url, session.url), + eq(schema.authors.id, authorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, + }); + + const { data: author, updatedAt: authorUpdatedAt } = useLiveTablesQuery( + authorQuery, + ["authors", "people"], + [authorId], + ); + + const booksQuery = db.query.books.findMany({ + columns: { id: true, title: true }, + where: and( + eq(schema.books.url, session.url), + inArray( + schema.books.id, + bookIds.map((book) => book.id), + ), + ), + orderBy: desc(schema.books.published), + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }); + + const { data: books, updatedAt: booksUpdatedAt } = useLiveTablesQuery( + booksQuery, + [ + "books", + "book_authors", + "authors", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [bookIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + bookIdsUpdatedAt !== undefined && + authorUpdatedAt !== undefined && + booksUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, bookIdsUpdatedAt, authorUpdatedAt, booksUpdatedAt]); + + return { books, author, opacity }; +} + +export function useMediaByNarrator(session: Session, narratorId: string) { + const mediaIdsQuery = db + .selectDistinct({ id: schema.media.id }) + .from(schema.narrators) + .innerJoin( + schema.mediaNarrators, + and( + eq(schema.narrators.url, schema.mediaNarrators.url), + eq(schema.narrators.id, schema.mediaNarrators.narratorId), + ), + ) + .innerJoin( + schema.media, + and( + eq(schema.mediaNarrators.url, schema.media.url), + eq(schema.mediaNarrators.mediaId, schema.media.id), + ), + ) + .where( + and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ), + ); + + const { data: mediaIds, updatedAt: mediaIdsUpdatedAt } = useLiveTablesQuery( + mediaIdsQuery, + ["narrators", "media_narrators", "media", "books"], + [narratorId], + ); + + const narratorQuery = db.query.narrators.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, + }); + + const { data: narrator, updatedAt: narratorUpdatedAt } = useLiveTablesQuery( + narratorQuery, + ["narrators", "people"], + [narratorId], + ); + + const mediaQuery = db.query.media.findMany({ + columns: { id: true, thumbnails: true }, + where: and( + eq(schema.media.url, session.url), + inArray( + schema.media.id, + mediaIds.map((media) => media.id), + ), + ), + orderBy: desc(schema.media.published), + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + const { data: media, updatedAt: mediaUpdatedAt } = useLiveTablesQuery( + mediaQuery, + [ + "media", + "media_narrators", + "narrators", + "downloads", + "books", + "book_authors", + "authors", + ], + [mediaIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + mediaIdsUpdatedAt !== undefined && + narratorUpdatedAt !== undefined && + mediaUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, mediaIdsUpdatedAt, narratorUpdatedAt, mediaUpdatedAt]); + + return { media, narrator, opacity }; +} + +export function useMediaIds(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { bookId: true }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + book: { + columns: {}, + with: { + bookAuthors: { + columns: { authorId: true }, + }, + seriesBooks: { + columns: { seriesId: true }, + }, + }, + }, + mediaNarrators: { + columns: { narratorId: true }, + }, + }, + }); + + const { data: media, opacity } = useFadeInQuery( + query, + ["media", "books", "book_authors", "series_books", "media_narrators"], + [mediaId], + ); + + const [ids, setIds] = useState<{ + mediaId: string; + bookId: string; + authorIds: string[]; + seriesIds: string[]; + narratorIds: string[]; + } | null>(null); + + useEffect(() => { + if (!media) return; + + setIds({ + mediaId, + bookId: media.bookId, + authorIds: media.book.bookAuthors.map((ba) => ba.authorId), + seriesIds: media.book.seriesBooks.map((sb) => sb.seriesId), + narratorIds: media.mediaNarrators.map((mn) => mn.narratorId), + }); + }, [media, mediaId]); + + return { ids, opacity }; +} + +export function useMediaHeaderInfo(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { + fullCast: true, + abridged: true, + thumbnails: true, + duration: true, + }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + download: { + columns: { thumbnails: true }, + }, + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + book: { + columns: { title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + seriesBooks: { + columns: { bookNumber: true }, + with: { series: { columns: { name: true } } }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + "series_books", + "series", + ], + [mediaId], + ); +} + +export function useMediaActionBarInfo(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { + id: true, + thumbnails: true, + mp4Path: true, + }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + download: { + columns: { status: true }, + }, + }, + }); + + return useFadeInQuery(query, ["media", "downloads"], [mediaId]); +} + +export function useMediaDescription(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { + description: true, + published: true, + publishedFormat: true, + publisher: true, + notes: true, + }, + with: { + book: { + columns: { published: true, publishedFormat: true }, + }, + }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + }); + + return useFadeInQuery(query, ["media", "books"], [mediaId]); +} + +export function useMediaAuthorsAndNarrators(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: {}, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + book: { + columns: {}, + with: { + bookAuthors: { + columns: { id: true }, + with: { + author: { + columns: { name: true }, + with: { + person: { + columns: { id: true, name: true, thumbnails: true }, + }, + }, + }, + }, + }, + }, + }, + mediaNarrators: { + columns: { id: true }, + with: { + narrator: { + columns: { name: true }, + with: { + person: { columns: { id: true, name: true, thumbnails: true } }, + }, + }, + }, + }, + }, + }); + + const { data: media, opacity } = useFadeInQuery( + query, + [ + "media", + "books", + "book_authors", + "authors", + "people", + "media_narrators", + "narrators", + ], + [mediaId], + ); + + const [authorSet, setAuthorSet] = useState>(new Set()); + const [narratorSet, setNarratorSet] = useState>( + new Set(), + ); + + useEffect(() => { + if (!media) return; + + const newAuthorSet = new Set(); + for (const ba of media.book.bookAuthors) { + newAuthorSet.add(ba.author.person.id); + } + setAuthorSet(newAuthorSet); + + const newNarratorSet = new Set(); + for (const mn of media.mediaNarrators) { + newNarratorSet.add(mn.narrator.person.id); + } + setNarratorSet(newNarratorSet); + }, [media]); + + return { media, authorSet, narratorSet, opacity }; +} + +export function useMediaOtherEditions( session: Session, - seriesId: string, -): Promise { - return db.query.series.findFirst({ + bookId: string, + withoutMediaId: string, +) { + const mediaIdsQuery = db + .select({ id: schema.media.id }) + .from(schema.media) + .limit(10) + .where( + and( + eq(schema.media.url, session.url), + eq(schema.media.bookId, bookId), + ne(schema.media.id, withoutMediaId), + ), + ); + + const { data: mediaIds, updatedAt: mediaIdsUpdatedAt } = useLiveQuery( + mediaIdsQuery, + [bookId, withoutMediaId], + ); + + const mediaQuery = db.query.media.findMany({ + columns: { id: true, thumbnails: true }, + where: and( + eq(schema.media.url, session.url), + inArray( + schema.media.id, + mediaIds.map((media) => media.id), + ), + ), + orderBy: desc(schema.media.published), + with: { + download: { + columns: { thumbnails: true }, + }, + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + const { data: media, updatedAt: mediaUpdatedAt } = useLiveTablesQuery( + mediaQuery, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + ], + [mediaIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if (mediaIdsUpdatedAt !== undefined && mediaUpdatedAt !== undefined) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, mediaIdsUpdatedAt, mediaUpdatedAt]); + + return { media, opacity }; +} + +export function useOtherBooksInSeries(session: Session, seriesId: string) { + const query = db.query.series.findFirst({ columns: { id: true, name: true }, where: and( eq(schema.series.url, session.url), eq(schema.series.id, seriesId), ), + with: { + seriesBooks: { + columns: { id: true, bookNumber: true }, + orderBy: sql`CAST(book_number AS FLOAT)`, + limit: 10, + with: { + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "series", + "series_books", + "books", + "book_authors", + "authors", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [seriesId], + ); +} + +export function useOtherBooksByAuthor( + session: Session, + authorId: string, + withoutBookId: string, + withoutSeriesIds: string[], +) { + const bookIdsQuery = db + .selectDistinct({ id: schema.books.id }) + .from(schema.authors) + .innerJoin( + schema.bookAuthors, + and( + eq(schema.authors.url, schema.bookAuthors.url), + eq(schema.authors.id, schema.bookAuthors.authorId), + ), + ) + .innerJoin( + schema.books, + and( + eq(schema.bookAuthors.url, schema.books.url), + eq(schema.bookAuthors.bookId, schema.books.id), + ), + ) + .leftJoin( + schema.seriesBooks, + and( + eq(schema.books.url, schema.seriesBooks.url), + eq(schema.books.id, schema.seriesBooks.bookId), + ), + ) + .limit(10) + .where( + and( + eq(schema.authors.url, session.url), + eq(schema.authors.id, authorId), + ne(schema.books.id, withoutBookId), + or( + isNull(schema.seriesBooks.seriesId), + notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), + ), + ), + ); + + const { data: bookIds, updatedAt: bookIdsUpdatedAt } = useLiveTablesQuery( + bookIdsQuery, + ["authors", "book_authors", "books", "series_books"], + [authorId, withoutBookId, withoutSeriesIds], + ); + + const authorQuery = db.query.authors.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.authors.url, session.url), + eq(schema.authors.id, authorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, }); + + const { data: author, updatedAt: authorUpdatedAt } = useLiveTablesQuery( + authorQuery, + ["authors", "people"], + [authorId], + ); + + const booksQuery = db.query.books.findMany({ + columns: { id: true, title: true }, + where: and( + eq(schema.books.url, session.url), + inArray( + schema.books.id, + bookIds.map((book) => book.id), + ), + ), + orderBy: desc(schema.books.published), + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }); + + const { data: books, updatedAt: booksUpdatedAt } = useLiveTablesQuery( + booksQuery, + [ + "books", + "book_authors", + "authors", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [bookIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + bookIdsUpdatedAt !== undefined && + authorUpdatedAt !== undefined && + booksUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, bookIdsUpdatedAt, authorUpdatedAt, booksUpdatedAt]); + + return { books, author, opacity }; +} + +export function useOtherMediaByNarrator( + session: Session, + narratorId: string, + withoutMediaId: string, + withoutSeriesIds: string[], + withoutAuthorIds: string[], +) { + const mediaIdsQuery = db + .selectDistinct({ id: schema.media.id }) + .from(schema.narrators) + .innerJoin( + schema.mediaNarrators, + and( + eq(schema.narrators.url, schema.mediaNarrators.url), + eq(schema.narrators.id, schema.mediaNarrators.narratorId), + ), + ) + .innerJoin( + schema.media, + and( + eq(schema.mediaNarrators.url, schema.media.url), + eq(schema.mediaNarrators.mediaId, schema.media.id), + ), + ) + .innerJoin( + schema.books, + and( + eq(schema.media.url, schema.books.url), + eq(schema.media.bookId, schema.books.id), + ), + ) + .innerJoin( + schema.bookAuthors, + and( + eq(schema.books.url, schema.bookAuthors.url), + eq(schema.books.id, schema.bookAuthors.bookId), + ), + ) + .leftJoin( + schema.seriesBooks, + and( + eq(schema.books.url, schema.seriesBooks.url), + eq(schema.books.id, schema.seriesBooks.bookId), + ), + ) + .limit(10) + .where( + and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ne(schema.media.id, withoutMediaId), + notInArray(schema.bookAuthors.authorId, withoutAuthorIds), + or( + isNull(schema.seriesBooks.seriesId), + notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), + ), + ), + ); + + const { data: mediaIds, updatedAt: mediaIdsUpdatedAt } = useLiveTablesQuery( + mediaIdsQuery, + [ + "narrators", + "media_narrators", + "media", + "books", + "book_authors", + "series_books", + ], + [narratorId, withoutMediaId, withoutSeriesIds, withoutAuthorIds], + ); + + const narratorQuery = db.query.narrators.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, + }); + + const { data: narrator, updatedAt: narratorUpdatedAt } = useLiveTablesQuery( + narratorQuery, + ["narrators", "people"], + [narratorId], + ); + + const mediaQuery = db.query.media.findMany({ + columns: { id: true, thumbnails: true }, + where: and( + eq(schema.media.url, session.url), + inArray( + schema.media.id, + mediaIds.map((media) => media.id), + ), + ), + orderBy: desc(schema.media.published), + with: { + download: { + columns: { thumbnails: true }, + }, + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + const { data: media, updatedAt: mediaUpdatedAt } = useLiveTablesQuery( + mediaQuery, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + ], + [mediaIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + mediaIdsUpdatedAt !== undefined && + narratorUpdatedAt !== undefined && + mediaUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, mediaIdsUpdatedAt, narratorUpdatedAt, mediaUpdatedAt]); + + return { media, narrator, opacity }; } diff --git a/src/db/playerStates.ts b/src/db/playerStates.ts index 4f8239a..15542d0 100644 --- a/src/db/playerStates.ts +++ b/src/db/playerStates.ts @@ -38,6 +38,7 @@ type Media = { duration: string | null; book: Book; download: Download | null; + chapters: schema.Chapter[]; }; interface PlayerState { @@ -74,6 +75,7 @@ export async function getSyncedPlayerState( mpdPath: true, hlsPath: true, duration: true, + chapters: true, }, with: { download: { @@ -118,6 +120,7 @@ export async function getLocalPlayerState( mpdPath: true, hlsPath: true, duration: true, + chapters: true, }, with: { download: { diff --git a/src/db/schema.ts b/src/db/schema.ts index 13e39ce..0f77acb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -462,6 +462,9 @@ export const servers = sqliteTable( }, ); +// downloads are associated to a server but _not_ a user. If you log into a +// different account, but login to the same server, you have access to all +// downloads associated with that server. export const downloads = sqliteTable( "downloads", { @@ -499,3 +502,17 @@ export const downloadsRelations = relations(downloads, ({ one }) => ({ references: [media.url, media.id], }), })); + +export const defaultSleepTimer = 600; +export const defaultSleepTimerEnabled = false; + +// Local settings are associated to a user. If you log into a different account, +// you will have different local settings. +export const localUserSettings = sqliteTable("local_user_settings", { + userEmail: text("user_email").notNull().primaryKey(), + preferredPlaybackRate: real("preferred_playback_rate").notNull().default(1), + sleepTimer: integer("sleep_timer").notNull().default(defaultSleepTimer), + sleepTimerEnabled: integer("sleep_timer_enabled", { mode: "boolean" }) + .notNull() + .default(defaultSleepTimerEnabled), +}); diff --git a/src/db/settings.ts b/src/db/settings.ts new file mode 100644 index 0000000..652c076 --- /dev/null +++ b/src/db/settings.ts @@ -0,0 +1,75 @@ +import { db } from "@/src/db/db"; +import * as schema from "@/src/db/schema"; +import { eq, sql } from "drizzle-orm"; + +export async function setPreferredPlaybackRate( + userEmail: string, + rate: number, +) { + await db + .insert(schema.localUserSettings) + .values({ + userEmail, + preferredPlaybackRate: rate, + }) + .onConflictDoUpdate({ + target: [schema.localUserSettings.userEmail], + set: { + preferredPlaybackRate: sql`excluded.preferred_playback_rate`, + }, + }); +} + +export async function setSleepTimerEnabled( + userEmail: string, + enabled: boolean, +) { + await db + .insert(schema.localUserSettings) + .values({ + userEmail, + sleepTimerEnabled: enabled, + }) + .onConflictDoUpdate({ + target: [schema.localUserSettings.userEmail], + set: { + sleepTimerEnabled: sql`excluded.sleep_timer_enabled`, + }, + }); +} + +export async function setSleepTimerTime(userEmail: string, seconds: number) { + await db + .insert(schema.localUserSettings) + .values({ + userEmail, + sleepTimer: seconds, + }) + .onConflictDoUpdate({ + target: [schema.localUserSettings.userEmail], + set: { + sleepTimer: sql`excluded.sleep_timer`, + }, + }); +} + +export async function getSleepTimerSettings(userEmail: string) { + const response = await db.query.localUserSettings.findFirst({ + columns: { + sleepTimer: true, + sleepTimerEnabled: true, + }, + where: eq(schema.localUserSettings.userEmail, userEmail), + }); + + console.log("settings response", response); + + if (response) { + return response; + } else { + return { + sleepTimer: schema.defaultSleepTimer, + sleepTimerEnabled: schema.defaultSleepTimerEnabled, + }; + } +} diff --git a/src/db/sync.ts b/src/db/sync.ts index dce27f5..b2cc009 100644 --- a/src/db/sync.ts +++ b/src/db/sync.ts @@ -16,7 +16,7 @@ const deletionsTables = { PERSON: schema.people, }; -export async function syncDown(session: Session) { +export async function syncDown(session: Session, force: boolean = false) { console.log("down syncing..."); const server = await db.query.servers.findFirst({ @@ -30,7 +30,7 @@ export async function syncDown(session: Session) { // but it's ok because this is just a debounce const now = Date.now(); const lastSyncTime = lastSync.getTime(); - if (now - lastSyncTime < 60 * 1000) { + if (now - lastSyncTime < 60 * 1000 && !force) { console.log("down synced less than a minute ago, skipping sync"); return; } @@ -372,7 +372,7 @@ export async function syncDown(session: Session) { console.log("down sync complete"); } -export async function syncUp(session: Session) { +export async function syncUp(session: Session, force: boolean = false) { console.log("up syncing..."); const server = await db.query.servers.findFirst({ @@ -384,7 +384,7 @@ export async function syncUp(session: Session) { if (lastSync) { const now = Date.now(); const lastSyncTime = lastSync.getTime(); - if (now - lastSyncTime < 60 * 1000) { + if (now - lastSyncTime < 60 * 1000 && !force) { console.log("up synced less than a minute ago, skipping sync"); return; } diff --git a/src/graphql/client/execute.ts b/src/graphql/client/execute.ts index 8267057..22264a1 100644 --- a/src/graphql/client/execute.ts +++ b/src/graphql/client/execute.ts @@ -1,3 +1,4 @@ +import { router } from "expo-router"; import type { TypedDocumentString } from "./graphql"; export async function executeAuthenticated( @@ -19,6 +20,10 @@ export async function executeAuthenticated( }), }); + if (response.status === 401) { + return router.navigate("/sign-out"); + } + if (!response.ok) { throw new Error("Network response was not ok"); } diff --git a/src/hooks/use.app.boot.ts b/src/hooks/use.app.boot.ts index ec3eb69..60ee8b0 100644 --- a/src/hooks/use.app.boot.ts +++ b/src/hooks/use.app.boot.ts @@ -1,8 +1,8 @@ import migrations from "@/drizzle/migrations"; import { db } from "@/src/db/db"; import { syncDown } from "@/src/db/sync"; -import { useSessionStore } from "@/src/stores/session"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { loadMostRecentMedia, setupPlayer } from "@/src/stores/player"; +import { useSession } from "@/src/stores/session"; import { useMigrations } from "drizzle-orm/expo-sqlite/migrator"; import { useEffect, useState } from "react"; @@ -12,9 +12,7 @@ const useAppBoot = () => { db, migrations, ); - const session = useSessionStore((_) => _.session); - const setupTrackPlayer = useTrackPlayerStore((_) => _.setupTrackPlayer); - const loadMostRecentMedia = useTrackPlayerStore((_) => _.loadMostRecentMedia); + const session = useSession((state) => state.session); useEffect(() => { if (migrateError) @@ -26,18 +24,12 @@ const useAppBoot = () => { console.log("[AppBoot] starting..."); syncDown(session) .then(() => console.log("[AppBoot] db sync complete")) - .then(() => setupTrackPlayer()) + .then(() => setupPlayer(session)) .then(() => loadMostRecentMedia(session)) .then(() => console.log("[AppBoot] trackPlayer setup complete")) .catch((e) => console.error("[AppBoot] error", e)) .finally(() => setIsReady(true)); - }, [ - loadMostRecentMedia, - setupTrackPlayer, - migrateSuccess, - migrateError, - session, - ]); + }, [migrateSuccess, migrateError, session]); return { isReady, migrateError }; }; diff --git a/src/hooks/use.fade.in.query.ts b/src/hooks/use.fade.in.query.ts new file mode 100644 index 0000000..1382cdf --- /dev/null +++ b/src/hooks/use.fade.in.query.ts @@ -0,0 +1,27 @@ +import { type AnySQLiteSelect } from "drizzle-orm/sqlite-core"; +import { SQLiteRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/query"; +import { useEffect } from "react"; +import { useSharedValue, withTiming } from "react-native-reanimated"; +import { useLiveTablesQuery } from "./use.live.tables.query"; + +export const fadeInTime = 500; + +/** + * This hook is a wrapper around useLiveTablesQuery that fades in an opacity + * value when the query first returns. + */ +export default function useFadeInQuery< + T extends + | Pick + | SQLiteRelationalQuery<"sync", unknown>, +>(query: T, tables: string[], deps: unknown[] = []) { + const opacity = useSharedValue(0); + const { data, updatedAt, error } = useLiveTablesQuery(query, tables, deps); + + useEffect(() => { + if (updatedAt !== undefined) + opacity.value = withTiming(1, { duration: fadeInTime }); + }, [opacity, updatedAt]); + + return { data, updatedAt, error, opacity } as const; +} diff --git a/src/hooks/use.media.details.ts b/src/hooks/use.media.details.ts deleted file mode 100644 index 0933c02..0000000 --- a/src/hooks/use.media.details.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MediaForDetails, getMediaForDetails } from "@/src/db/library"; -import { useSessionStore } from "@/src/stores/session"; -import { useEffect, useState } from "react"; - -export function useMediaDetails(mediaId: string | null) { - const session = useSessionStore((state) => state.session); - const [media, setMedia] = useState(); - const [error, setError] = useState(false); - - useEffect(() => { - if (!session) return; - if (!mediaId) return; - - getMediaForDetails(session, mediaId) - .then(setMedia) - .catch((error) => { - console.error("Failed to load media:", error); - setError(true); - }); - }, [mediaId, session]); - - return { media, error }; -} diff --git a/src/hooks/use.sync.on.focus.ts b/src/hooks/use.sync.on.focus.ts index b293358..60aaf88 100644 --- a/src/hooks/use.sync.on.focus.ts +++ b/src/hooks/use.sync.on.focus.ts @@ -1,10 +1,10 @@ import { syncDown } from "@/src/db/sync"; -import { useSessionStore } from "@/src/stores/session"; +import { useSession } from "@/src/stores/session"; import { useFocusEffect } from "expo-router"; import { useCallback } from "react"; export default function useSyncOnFocus() { - const session = useSessionStore((state) => state.session); + const session = useSession((state) => state.session); useFocusEffect( useCallback(() => { diff --git a/src/services/PlaybackService.ts b/src/services/PlaybackService.ts index 6416ae2..692f08b 100644 --- a/src/services/PlaybackService.ts +++ b/src/services/PlaybackService.ts @@ -1,74 +1,51 @@ -import { updatePlayerState } from "@/src/db/playerStates"; +import { + onPlaybackProgressUpdated, + onPlaybackQueueEnded, + onPlaybackState, + pause, + play, + seekRelative, +} from "@/src/stores/player"; import TrackPlayer, { Event } from "react-native-track-player"; -import { syncUp } from "../db/sync"; -import { useSessionStore } from "../stores/session"; -import { useTrackPlayerStore } from "../stores/trackPlayer"; - -async function updatePlayerStateFromTrackPlayer() { - const progress = await TrackPlayer.getProgress(); - const session = useSessionStore.getState().session; - const mediaId = useTrackPlayerStore.getState().mediaId; - - if (!session || !mediaId) return; - - updatePlayerState(session, mediaId, { - position: progress.position, - }); -} export const PlaybackService = async function () { - TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { - // TODO: - console.debug("Service: playback queue ended"); - }); - - TrackPlayer.addEventListener(Event.RemoteStop, async () => { - console.debug("Service: stopping"); - await TrackPlayer.pause(); - await updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemoteStop, () => { + console.debug("[TrackPlayer Service] remote stop"); + pause(); }); - TrackPlayer.addEventListener(Event.RemotePause, async () => { - console.debug("Service: pausing"); - await TrackPlayer.pause(); - updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemotePause, () => { + console.debug("[TrackPlayer Service] remote pause"); + pause(); }); TrackPlayer.addEventListener(Event.RemotePlay, () => { - console.debug("Service: playing"); - TrackPlayer.play(); + console.debug("[TrackPlayer Service] remote play"); + play(); }); - TrackPlayer.addEventListener(Event.RemoteJumpBackward, (interval) => { - console.debug("Service: jump backward", interval); - - // TODO: - // await seekRelative(REMOTE_JUMP_INTERVAL * -1) - // updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemoteJumpBackward, ({ interval }) => { + console.debug("[TrackPlayer Service] remote jump backward", -interval); + seekRelative(-interval); }); - TrackPlayer.addEventListener(Event.RemoteJumpForward, (interval) => { - console.debug("Service: jump forward", interval); - - // TODO: - // await seekRelative(REMOTE_JUMP_INTERVAL) - // updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemoteJumpForward, ({ interval }) => { + console.debug("[TrackPlayer Service] remote jump forward", interval); + seekRelative(interval); }); - TrackPlayer.addEventListener( - Event.PlaybackProgressUpdated, - async (data): Promise => { - console.debug("Service: playback progress updated", data); - const session = useSessionStore.getState().session; - const mediaId = useTrackPlayerStore.getState().mediaId; - - if (!session || !mediaId) return; + TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, (args) => { + const { position, duration } = args; + onPlaybackProgressUpdated(position, duration); + }); - await updatePlayerState(session, mediaId, { - position: data.position, - }); + TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => { + console.debug("[TrackPlayer Service] playback state changed", state); + onPlaybackState(state); + }); - await syncUp(session); - }, - ); + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { + console.debug("[TrackPlayer Service] playback ended"); + onPlaybackQueueEnded(); + }); }; diff --git a/src/stores/downloads.ts b/src/stores/downloads.ts index a660c32..84177e0 100644 --- a/src/stores/downloads.ts +++ b/src/stores/downloads.ts @@ -9,123 +9,124 @@ import * as FileSystem from "expo-file-system"; import { create } from "zustand"; import { Session } from "./session"; -interface DownloadsState { - downloadProgresses: Record; - downloadResumables: Record; - startDownload: ( - session: Session, - mediaId: string, - uri: string, - thumbnails: Thumbnails | null, - ) => Promise; - removeDownload: (session: Session, mediaId: string) => Promise; - cancelDownload: (session: Session, mediaId: string) => Promise; +export type DownloadProgresses = Partial>; +export type DownloadResumables = Partial< + Record +>; + +export interface DownloadsState { + downloadProgresses: DownloadProgresses; + downloadResumables: DownloadResumables; } -export const useDownloadsStore = create((set, get) => ({ +export const useDownloads = create(() => ({ downloadProgresses: {}, downloadResumables: {}, - startDownload: async ( - session: Session, - mediaId: string, - uri: string, - thumbnails: Thumbnails | null, - ) => { - set((state) => ({ +})); + +export async function startDownload( + session: Session, + mediaId: string, + uri: string, + thumbnails: Thumbnails | null, +) { + useDownloads.setState((state) => ({ + downloadProgresses: { + ...state.downloadProgresses, + [mediaId]: 0, + }, + })); + + const filePath = FileSystem.documentDirectory + `${mediaId}.mp4`; + + console.log("Downloading to", filePath); + + await createDownload(session, mediaId, filePath); + if (thumbnails) { + const downloadedThumbnails = await downloadThumbnails( + session, + mediaId, + thumbnails, + ); + await updateDownload(session, mediaId, { + thumbnails: downloadedThumbnails, + }); + } + + const progressCallback = (downloadProgress: any) => { + const progress = + downloadProgress.totalBytesWritten / + downloadProgress.totalBytesExpectedToWrite; + useDownloads.setState((state) => ({ downloadProgresses: { ...state.downloadProgresses, - [mediaId]: 0, + [mediaId]: progress, }, })); + }; - const filePath = FileSystem.documentDirectory + `${mediaId}.mp4`; + const downloadResumable = FileSystem.createDownloadResumable( + `${session.url}/${uri}`, + filePath, + { headers: { Authorization: `Bearer ${session.token}` } }, + progressCallback, + ); - console.log("Downloading to", filePath); + useDownloads.setState((state) => ({ + downloadResumables: { + ...state.downloadResumables, + [mediaId]: downloadResumable, + }, + })); - await createDownload(session, mediaId, filePath); - if (thumbnails) { - const downloadedThumbnails = await downloadThumbnails( - session, - mediaId, - thumbnails, - ); - await updateDownload(session, mediaId, { - thumbnails: downloadedThumbnails, - }); - } + try { + const result = await downloadResumable.downloadAsync(); - const progressCallback = (downloadProgress: any) => { - const progress = - downloadProgress.totalBytesWritten / - downloadProgress.totalBytesExpectedToWrite; - set((state) => ({ - downloadProgresses: { - ...state.downloadProgresses, - [mediaId]: progress, - }, - })); - }; - - const downloadResumable = FileSystem.createDownloadResumable( - `${session.url}/${uri}`, - filePath, - { headers: { Authorization: `Bearer ${session.token}` } }, - progressCallback, - ); + if (result) { + console.log("Download succeeded"); + await updateDownload(session, mediaId, { status: "ready" }); + } else { + console.log("Download was canceled"); + } + } catch (error) { + console.error("Download failed:", error); + await updateDownload(session, mediaId, { status: "error" }); + } finally { + useDownloads.setState((state) => { + const { [mediaId]: _dp, ...downloadProgresses } = + state.downloadProgresses; + const { [mediaId]: _dr, ...downloadResumables } = + state.downloadResumables; + return { downloadProgresses, downloadResumables }; + }); + } +} - set((state) => ({ - downloadResumables: { - ...state.downloadResumables, - [mediaId]: downloadResumable, - }, - })); +export async function cancelDownload(session: Session, mediaId: string) { + const downloadResumable = useDownloads.getState().downloadResumables[mediaId]; + if (downloadResumable) { try { - const result = await downloadResumable.downloadAsync(); - - if (result) { - console.log("Download succeeded"); - await updateDownload(session, mediaId, { status: "ready" }); - } else { - console.log("Download was canceled"); - } - } catch (error) { - console.error("Download failed:", error); - await updateDownload(session, mediaId, { status: "error" }); - } finally { - set((state) => { - const { [mediaId]: _dp, ...downloadProgresses } = - state.downloadProgresses; - const { [mediaId]: _dr, ...downloadResumables } = - state.downloadResumables; - return { downloadProgresses, downloadResumables }; - }); - } - }, - removeDownload: async (session: Session, mediaId: string) => { - const download = await getDownload(session, mediaId); - if (download) await tryDelete(download.filePath); - if (download?.thumbnails) { - await tryDelete(download.thumbnails.extraSmall); - await tryDelete(download.thumbnails.small); - await tryDelete(download.thumbnails.medium); - await tryDelete(download.thumbnails.large); - await tryDelete(download.thumbnails.extraLarge); - } - await deleteDownload(session, mediaId); - }, - cancelDownload: async (session: Session, mediaId: string) => { - const downloadResumable = get().downloadResumables[mediaId]; - if (downloadResumable) { - try { - await downloadResumable.cancelAsync(); - } catch (e) { - console.error("Error canceling download resumable:", e); - } + await downloadResumable.cancelAsync(); + } catch (e) { + console.error("Error canceling download resumable:", e); } - get().removeDownload(session, mediaId); - }, -})); + } + removeDownload(session, mediaId); +} + +export async function removeDownload(session: Session, mediaId: string) { + const download = await getDownload(session, mediaId); + if (download) await tryDelete(download.filePath); + if (download?.thumbnails) { + await tryDelete(download.thumbnails.extraSmall); + await tryDelete(download.thumbnails.small); + await tryDelete(download.thumbnails.medium); + await tryDelete(download.thumbnails.large); + await tryDelete(download.thumbnails.extraLarge); + } + await deleteDownload(session, mediaId); +} async function downloadThumbnails( session: Session, diff --git a/src/stores/player.ts b/src/stores/player.ts new file mode 100644 index 0000000..dfd00ea --- /dev/null +++ b/src/stores/player.ts @@ -0,0 +1,724 @@ +import { + LocalPlayerState, + createInitialPlayerState, + createPlayerState, + getLocalPlayerState, + getMostRecentInProgressLocalMedia, + getMostRecentInProgressSyncedMedia, + getSyncedPlayerState, + updatePlayerState, +} from "@/src/db/playerStates"; +import * as schema from "@/src/db/schema"; +import { + getSleepTimerSettings, + setSleepTimerEnabled, + setSleepTimerTime, +} from "@/src/db/settings"; +import { syncUp } from "@/src/db/sync"; +import { Platform } from "react-native"; +import TrackPlayer, { + AndroidAudioContentType, + Capability, + IOSCategory, + IOSCategoryMode, + PitchAlgorithm, + State, + TrackType, +} from "react-native-track-player"; +import { create } from "zustand"; +import { Session, useSession } from "./session"; + +export type ChapterState = { + chapters: schema.Chapter[]; + currentChapter: schema.Chapter; + previousChapterStartTime: number; +}; + +export interface PlayerState { + setup: boolean; + setupError: unknown | null; + position: number; + duration: number; + state: State | undefined; + mediaId: string | null; + playbackRate: number; + lastPlayerExpandRequest: Date | undefined; + streaming: boolean | undefined; + chapterState: ChapterState | null; + sleepTimer: number; + sleepTimerEnabled: boolean; + sleepTimerTriggerTime: number | null; +} + +interface TrackLoadResult { + mediaId: string; + duration: number; + position: number; + playbackRate: number; + streaming: boolean; + chapters: schema.Chapter[]; +} + +export const usePlayer = create()((set, get) => ({ + setup: false, + setupError: null, + position: 0, + duration: 0, + state: undefined, + mediaId: null, + playbackRate: 1, + lastPlayerExpandRequest: undefined, + streaming: undefined, + chapterState: null, + sleepTimer: schema.defaultSleepTimer, + sleepTimerEnabled: schema.defaultSleepTimerEnabled, + sleepTimerTriggerTime: null, +})); + +export async function setupPlayer(session: Session) { + if (usePlayer.getState().setup) { + console.debug("[Player] already set up"); + return; + } + + try { + const response = await setupTrackPlayer(session); + + if (response === true) { + const { sleepTimer, sleepTimerEnabled } = await getSleepTimerSettings( + session.email, + ); + + usePlayer.setState({ setup: true, sleepTimer, sleepTimerEnabled }); + } else { + const { sleepTimer, sleepTimerEnabled } = await getSleepTimerSettings( + session.email, + ); + + usePlayer.setState({ + setup: true, + mediaId: response.mediaId, + duration: response.duration, + position: response.position, + playbackRate: response.playbackRate, + streaming: response.streaming, + chapterState: initializeChapterState( + response.chapters, + response.position, + response.duration, + ), + sleepTimer, + sleepTimerEnabled, + }); + } + } catch (error) { + usePlayer.setState({ setupError: error }); + } +} + +export async function loadMostRecentMedia(session: Session) { + if (!usePlayer.getState().setup) return; + + const track = await loadMostRecentMediaIntoTrackPlayer(session); + + if (track) { + usePlayer.setState({ + mediaId: track.mediaId, + duration: track.duration, + position: track.position, + playbackRate: track.playbackRate, + streaming: track.streaming, + chapterState: initializeChapterState( + track.chapters, + track.position, + track.duration, + ), + }); + } +} + +export async function loadMedia(session: Session, mediaId: string) { + const track = await loadMediaIntoTrackPlayer(session, mediaId); + + usePlayer.setState({ + mediaId: track.mediaId, + duration: track.duration, + position: track.position, + playbackRate: track.playbackRate, + streaming: track.streaming, + chapterState: initializeChapterState( + track.chapters, + track.position, + track.duration, + ), + }); +} + +export function requestExpandPlayer() { + usePlayer.setState({ lastPlayerExpandRequest: new Date() }); +} + +export function expandPlayerHandled() { + usePlayer.setState({ lastPlayerExpandRequest: undefined }); +} + +export function playOrPause() { + const { state } = usePlayer.getState(); + + switch (state) { + case State.Paused: + case State.Stopped: + case State.Ready: + case State.Error: + return play(); + case State.Playing: + return pause(); + case State.Buffering: + case State.Loading: + case State.None: + case State.Ended: + } + return Promise.resolve(); +} + +export function play() { + maybeStartSleepTimer(); + return TrackPlayer.play(); +} + +export async function pause() { + stopSleepTimer(); + await TrackPlayer.pause(); + await seekRelative(-1); + return savePosition(true); +} + +export function onPlaybackProgressUpdated(position: number, duration: number) { + updateProgress(position, duration); + if (maybeHandleSleepTimer()) return Promise.resolve(); + return savePosition(); +} + +export function onPlaybackState(state: State) { + usePlayer.setState({ state }); +} + +export function onPlaybackQueueEnded() { + stopSleepTimer(); + const { duration } = usePlayer.getState(); + updateProgress(duration, duration); + return savePosition(true); +} + +export function updateProgress(position: number, duration: number) { + usePlayer.setState({ position, duration }); + + const chapterState = usePlayer.getState().chapterState; + + if (chapterState) { + usePlayer.setState({ + chapterState: updateChapterState(chapterState, position, duration), + }); + } +} + +export async function seekTo(position: number) { + maybeResetSleepTimer(); + const { duration, state } = usePlayer.getState(); + if (!shouldSeek(state)) return; + const newPosition = Math.max(0, Math.min(position, duration)); + updateProgress(newPosition, duration); + + return TrackPlayer.seekTo(newPosition); +} + +export async function seekRelative(amount: number) { + const { position, playbackRate } = usePlayer.getState(); + + return seekTo(position + amount * playbackRate); +} + +export async function skipToEndOfChapter() { + const { chapterState, duration } = usePlayer.getState(); + if (!chapterState) return; + const { currentChapter } = chapterState; + + return seekTo(currentChapter.endTime || duration); +} + +export async function skipToBeginningOfChapter() { + const { chapterState, position } = usePlayer.getState(); + if (!chapterState) return; + + const { currentChapter, previousChapterStartTime } = chapterState; + const newPosition = + position === currentChapter.startTime + ? previousChapterStartTime + : currentChapter.startTime; + + return seekTo(newPosition); +} + +export async function setPlaybackRate(session: Session, playbackRate: number) { + usePlayer.setState({ playbackRate }); + await Promise.all([ + TrackPlayer.setRate(playbackRate), + updatePlayerState(session, usePlayer.getState().mediaId!, { playbackRate }), + ]); + return syncUp(session, true); +} + +export async function setSleepTimerState(enabled: boolean) { + usePlayer.setState({ + sleepTimerEnabled: enabled, + sleepTimerTriggerTime: null, + }); + + const session = useSession.getState().session; + + if (!session) return; + + await setSleepTimerEnabled(session.email, enabled); + + const { state } = usePlayer.getState(); + + if (state === State.Playing) { + maybeStartSleepTimer(); + } +} + +export async function setSleepTimer(sleepTimer: number) { + usePlayer.setState({ sleepTimer }); + + const session = useSession.getState().session; + + if (!session) return; + + await setSleepTimerTime(session.email, sleepTimer); + + const { state } = usePlayer.getState(); + + if (state === State.Playing) { + maybeResetSleepTimer(); + } +} + +export async function unloadPlayer() { + await pause(); + await TrackPlayer.reset(); + usePlayer.setState({ + position: 0, + duration: 0, + state: undefined, + mediaId: null, + playbackRate: 1, + streaming: undefined, + chapterState: null, + }); + return Promise.resolve(); +} + +async function savePosition(force: boolean = false) { + const session = useSession.getState().session; + const { mediaId, position, duration } = usePlayer.getState(); + + if (!session || !mediaId) return; + + // mimic server-side logic here by computing the status + const status = + position < 60 + ? "not_started" + : duration - position < 120 + ? "finished" + : "in_progress"; + + await updatePlayerState(session, mediaId, { position, status }); + return syncUp(session, force); +} + +function shouldSeek(state: State | undefined): boolean { + switch (state) { + case State.Paused: + case State.Stopped: + case State.Ready: + case State.Playing: + case State.Ended: + case State.Buffering: + case State.Loading: + return true; + case State.None: + case State.Error: + case undefined: + return false; + } +} + +async function setupTrackPlayer( + session: Session, +): Promise { + try { + // just checking to see if it's already initialized + const track = await TrackPlayer.getTrack(0); + + if (track) { + const streaming = track.url.startsWith("http"); + const mediaId = track.description!; + const progress = await TrackPlayer.getProgress(); + const position = progress.position; + const duration = progress.duration; + const playbackRate = await TrackPlayer.getRate(); + const playerState = await getLocalPlayerState(session, mediaId); + return { + mediaId, + position, + duration, + playbackRate, + streaming, + chapters: playerState?.media.chapters || [], + }; + } + } catch (error) { + console.debug("[Player] player not yet set up", error); + // it's ok, we'll set it up now + } + + await TrackPlayer.setupPlayer({ + androidAudioContentType: AndroidAudioContentType.Speech, + iosCategory: IOSCategory.Playback, + iosCategoryMode: IOSCategoryMode.SpokenAudio, + autoHandleInterruptions: true, + }); + + await TrackPlayer.updateOptions({ + android: { + alwaysPauseOnInterruption: true, + }, + capabilities: [ + Capability.Play, + Capability.Pause, + Capability.JumpForward, + Capability.JumpBackward, + ], + compactCapabilities: [ + Capability.Play, + Capability.Pause, + Capability.JumpBackward, + Capability.JumpForward, + ], + forwardJumpInterval: 10, + backwardJumpInterval: 10, + progressUpdateEventInterval: 1, + }); + + console.debug("[Player] setup succeeded"); + return true; +} + +/** + * Loads the given PlayerState into the player. + */ +async function loadPlayerState( + session: Session, + playerState: LocalPlayerState, +): Promise { + console.debug("[Player] Loading player state into player..."); + let streaming: boolean; + + await TrackPlayer.reset(); + if (playerState.media.download?.status === "ready") { + // the media is downloaded, load the local file + streaming = false; + await TrackPlayer.add({ + url: playerState.media.download.filePath, + pitchAlgorithm: PitchAlgorithm.Voice, + duration: playerState.media.duration + ? parseFloat(playerState.media.duration) + : undefined, + title: playerState.media.book.title, + artist: playerState.media.book.bookAuthors + .map((bookAuthor) => bookAuthor.author.name) + .join(", "), + artwork: playerState.media.download.thumbnails + ? playerState.media.download.thumbnails.extraLarge + : undefined, + description: playerState.media.id, + }); + } else { + // the media is not downloaded, load the stream + streaming = true; + await TrackPlayer.add({ + url: + Platform.OS === "ios" + ? `${session.url}${playerState.media.hlsPath}` + : `${session.url}${playerState.media.mpdPath}`, + type: TrackType.Dash, + pitchAlgorithm: PitchAlgorithm.Voice, + duration: playerState.media.duration + ? parseFloat(playerState.media.duration) + : undefined, + title: playerState.media.book.title, + artist: playerState.media.book.bookAuthors + .map((bookAuthor) => bookAuthor.author.name) + .join(", "), + artwork: playerState.media.thumbnails + ? `${session.url}/${playerState.media.thumbnails.extraLarge}` + : undefined, + description: playerState.media.id, + headers: { Authorization: `Bearer ${session.token}` }, + }); + } + + await TrackPlayer.seekTo(playerState.position); + await TrackPlayer.setRate(playerState.playbackRate); + + return { + mediaId: playerState.media.id, + duration: parseFloat(playerState.media.duration || "0"), + position: playerState.position, + playbackRate: playerState.playbackRate, + chapters: playerState.media.chapters, + streaming, + }; +} + +async function loadMediaIntoTrackPlayer( + session: Session, + mediaId: string, +): Promise { + const syncedPlayerState = await getSyncedPlayerState(session, mediaId); + const localPlayerState = await getLocalPlayerState(session, mediaId); + + console.debug("[Player] Loading media into player", mediaId); + + if (!syncedPlayerState && !localPlayerState) { + // neither a synced playerState nor a local playerState exists + // create a new local playerState and load it into the player + + console.debug("[Player] No state found; creating new local state", 0); + + const newLocalPlayerState = await createInitialPlayerState( + session, + mediaId, + ); + + return loadPlayerState(session, newLocalPlayerState); + } + + if (syncedPlayerState && !localPlayerState) { + // a synced playerState exists but no local playerState exists + // create a new local playerState by copying the synced playerState + + console.debug( + "[Player] Synced state found; creating new local state", + syncedPlayerState.position, + ); + + const newLocalPlayerState = await createPlayerState( + session, + mediaId, + syncedPlayerState.playbackRate, + syncedPlayerState.position, + syncedPlayerState.status, + ); + + console.debug( + "[Player] Loading new local state into player", + newLocalPlayerState.position, + ); + + return loadPlayerState(session, newLocalPlayerState); + } + + if (!syncedPlayerState && localPlayerState) { + // a local playerState exists but no synced playerState exists + // use it as is (we haven't had a chance to sync it to the server yet) + + console.debug( + "[Player] Local state found (but no synced state); loading into player", + localPlayerState.position, + ); + + return loadPlayerState(session, localPlayerState); + } + + if (!localPlayerState || !syncedPlayerState) throw new Error("Impossible"); + + // both a synced playerState and a local playerState exist + console.debug( + "[Player] Both synced and local states found", + localPlayerState.position, + syncedPlayerState.position, + ); + + if (localPlayerState.updatedAt >= syncedPlayerState.updatedAt) { + // the local playerState is newer + // use it as is (the server is out of date) + + console.debug( + "[Player] Local state is newer; loading into player", + localPlayerState.position, + ); + + return loadPlayerState(session, localPlayerState); + } + + // the synced playerState is newer + // update the local playerState by copying the synced playerState + + console.debug( + "[Player] Synced state is newer; updating local state", + syncedPlayerState.position, + ); + + const updatedLocalPlayerState = await updatePlayerState(session, mediaId, { + playbackRate: syncedPlayerState.playbackRate, + position: syncedPlayerState.position, + status: syncedPlayerState.status, + }); + + console.debug( + "[Player] Loading updated local state into player", + updatedLocalPlayerState.position, + ); + + return loadPlayerState(session, updatedLocalPlayerState); +} + +async function loadMostRecentMediaIntoTrackPlayer( + session: Session, +): Promise { + const track = await TrackPlayer.getTrack(0); + + if (track) { + const streaming = track.url.startsWith("http"); + const mediaId = track.description!; + const progress = await TrackPlayer.getProgress(); + const position = progress.position; + const duration = progress.duration; + const playbackRate = await TrackPlayer.getRate(); + const playerState = await getLocalPlayerState(session, mediaId); + return { + mediaId, + position, + duration, + playbackRate, + streaming, + chapters: playerState?.media.chapters || [], + }; + } + + const mostRecentSyncedMedia = + await getMostRecentInProgressSyncedMedia(session); + const mostRecentLocalMedia = await getMostRecentInProgressLocalMedia(session); + + if (!mostRecentSyncedMedia && !mostRecentLocalMedia) { + return null; + } + + if (mostRecentSyncedMedia && !mostRecentLocalMedia) { + return loadMediaIntoTrackPlayer(session, mostRecentSyncedMedia.mediaId); + } + + if (!mostRecentSyncedMedia && mostRecentLocalMedia) { + return loadMediaIntoTrackPlayer(session, mostRecentLocalMedia.mediaId); + } + + if (!mostRecentSyncedMedia || !mostRecentLocalMedia) + throw new Error("Impossible"); + + if (mostRecentLocalMedia.updatedAt >= mostRecentSyncedMedia.updatedAt) { + return loadMediaIntoTrackPlayer(session, mostRecentLocalMedia.mediaId); + } else { + return loadMediaIntoTrackPlayer(session, mostRecentSyncedMedia.mediaId); + } +} + +function initializeChapterState( + chapters: schema.Chapter[], + position: number, + duration: number, +): ChapterState | null { + const currentChapter = chapters.find( + (chapter) => position < (chapter.endTime || duration), + ); + + if (!currentChapter) return null; + + const previousChapterStartTime = + chapters[chapters.indexOf(currentChapter) - 1]?.startTime || 0; + + return { chapters, currentChapter, previousChapterStartTime }; +} + +function updateChapterState( + chapterState: ChapterState, + position: number, + duration: number, +) { + const { chapters, currentChapter } = chapterState; + + if ( + position < currentChapter.startTime || + (currentChapter.endTime && position >= currentChapter.endTime) + ) { + const nextChapter = chapters.find( + (chapter) => position < (chapter.endTime || duration), + ); + + if (nextChapter) { + const previousChapterStartTime = + chapters[chapters.indexOf(nextChapter) - 1]?.startTime || 0; + return { + chapters, + currentChapter: nextChapter, + previousChapterStartTime, + }; + } + + return chapterState; + } + + return chapterState; +} + +function maybeStartSleepTimer() { + const { sleepTimerEnabled, sleepTimerTriggerTime } = usePlayer.getState(); + + if (!sleepTimerEnabled || sleepTimerTriggerTime !== null) return; + + _startSleepTimer(); +} + +function maybeResetSleepTimer() { + const { sleepTimerEnabled, sleepTimerTriggerTime } = usePlayer.getState(); + + if (!sleepTimerEnabled || sleepTimerTriggerTime === null) return; + + _startSleepTimer(); +} + +function stopSleepTimer() { + usePlayer.setState({ sleepTimerTriggerTime: null }); +} + +function _startSleepTimer() { + const { sleepTimer } = usePlayer.getState(); + const triggerTime = Date.now() + sleepTimer * 1000; + + usePlayer.setState({ sleepTimerTriggerTime: triggerTime }); +} + +function maybeHandleSleepTimer() { + const { sleepTimerTriggerTime } = usePlayer.getState(); + + if (sleepTimerTriggerTime === null) return false; + + const now = Date.now(); + + if (now >= sleepTimerTriggerTime) { + pause(); + return true; + } + + return false; +} diff --git a/src/stores/screen.ts b/src/stores/screen.ts index 4376df9..74b9dc9 100644 --- a/src/stores/screen.ts +++ b/src/stores/screen.ts @@ -3,12 +3,13 @@ import { create } from "zustand"; interface ScreenState { screenHeight: number; screenWidth: number; - setDimensions: (screenHeight: number, screenWidth: number) => void; } -export const useScreenStore = create()((set, get) => ({ +export const useScreen = create()(() => ({ screenHeight: 0, screenWidth: 0, - setDimensions: (screenHeight: number, screenWidth: number) => - set({ screenHeight, screenWidth }), })); + +export function setDimensions(screenHeight: number, screenWidth: number) { + useScreen.setState({ screenHeight, screenWidth }); +} diff --git a/src/stores/session.ts b/src/stores/session.ts index debd4ec..70b385a 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -16,9 +16,6 @@ interface SessionState { isLoading: boolean; error: string | null; session: Session | null; - signIn: (url: string, email: string, password: string) => Promise; - signOut: () => Promise; - clearError: () => void; } // Custom storage interface for Expo SecureStore @@ -36,37 +33,12 @@ const secureStorage: StateStorage = { const storage = createJSONStorage(() => secureStorage); -export const useSessionStore = create()( +export const useSession = create()( persist( (set, get) => ({ isLoading: false, error: null, session: null, - signIn: async (url: string, email: string, password: string) => { - set({ isLoading: true, error: null }); - const result = await signInAsync(url, email, password); - if (result.success) { - set({ - isLoading: false, - session: { token: result.token, email, url }, - }); - } else { - set({ isLoading: false, error: result.error }); - } - }, - signOut: async () => { - set({ isLoading: true, error: null }); - const session = get().session; - if (session) { - await signOutAsync(session.url, session.token); - set({ isLoading: false, session: null }); - } else { - set({ isLoading: false }); - } - }, - clearError: () => { - set({ error: null }); - }, }), { storage, @@ -78,6 +50,36 @@ export const useSessionStore = create()( ), ); +export async function signIn(url: string, email: string, password: string) { + useSession.setState({ isLoading: true, error: null }); + const result = await signInAsync(url, email, password); + + if (result.success) { + useSession.setState({ + isLoading: false, + session: { token: result.token, email, url }, + }); + } else { + useSession.setState({ isLoading: false, error: result.error }); + } +} + +export async function signOut() { + useSession.setState({ isLoading: true, error: null }); + const session = useSession.getState().session; + + if (session) { + await signOutAsync(session.url, session.token); + useSession.setState({ isLoading: false, session: null }); + } else { + useSession.setState({ isLoading: false }); + } +} + +export function clearError() { + useSession.setState({ error: null }); +} + interface SignInSuccess { success: true; token: string; @@ -133,7 +135,7 @@ const signOutAsync = async (url: string, token: string): Promise => { try { const response = await executeAuthenticated(url, token, signOutMutation); - if (!response.deleteSession) { + if (!response?.deleteSession) { return false; } return response.deleteSession.deleted; diff --git a/src/stores/trackPlayer.ts b/src/stores/trackPlayer.ts deleted file mode 100644 index d04e48e..0000000 --- a/src/stores/trackPlayer.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { - LocalPlayerState, - createInitialPlayerState, - createPlayerState, - getLocalPlayerState, - getMostRecentInProgressLocalMedia, - getMostRecentInProgressSyncedMedia, - getSyncedPlayerState, - updatePlayerState, -} from "@/src/db/playerStates"; -import { Platform } from "react-native"; -import TrackPlayer, { - AndroidAudioContentType, - Capability, - IOSCategory, - IOSCategoryMode, - PitchAlgorithm, - State, - TrackType, -} from "react-native-track-player"; -import { create } from "zustand"; -import { Session } from "./session"; - -interface TrackPlayerState { - setup: boolean; - setupError: unknown | null; - mediaId: string | null; - position: number; - duration: number; - playbackRate: number; - lastPlayerExpandRequest: Date | undefined; - setupTrackPlayer: () => Promise; - loadMostRecentMedia: (session: Session) => Promise; - loadMedia: (session: Session, mediaId: string) => Promise; - requestExpandPlayer: () => void; - expandPlayerHandled: () => void; - updateProgress: (position: number, duration: number) => void; - seekRelative: (position: number) => void; -} - -interface TrackLoadResult { - mediaId: string; - duration: number; - position: number; - playbackRate: number; -} - -export const useTrackPlayerStore = create()((set, get) => ({ - setup: false, - setupError: null, - mediaId: null, - position: 0, - duration: 0, - playbackRate: 1, - lastPlayerExpandRequest: undefined, - setupTrackPlayer: async () => { - if (get().setup) { - return; - } - - try { - const response = await setupTrackPlayer(); - - if (response === true) { - set({ setup: true }); - } else { - set({ - setup: true, - mediaId: response.mediaId, - duration: response.duration, - position: response.position, - playbackRate: response.playbackRate, - }); - } - } catch (error) { - set({ setupError: error }); - } - }, - loadMostRecentMedia: async (session: Session) => { - if (!get().setup) return; - - const track = await loadMostRecentMedia(session); - - if (track) { - set({ - mediaId: track.mediaId, - duration: track.duration, - position: track.position, - playbackRate: track.playbackRate, - }); - } - }, - loadMedia: async (session: Session, mediaId: string) => { - const track = await loadMedia(session, mediaId); - - set({ - mediaId: track.mediaId, - duration: track.duration, - position: track.position, - playbackRate: track.playbackRate, - }); - }, - requestExpandPlayer: () => set({ lastPlayerExpandRequest: new Date() }), - expandPlayerHandled: () => set({ lastPlayerExpandRequest: undefined }), - updateProgress: (position, duration) => { - set({ position, duration }); - }, - seekRelative: async (amount) => { - const { state } = await TrackPlayer.getPlaybackState(); - if (!shouldSeek(state)) return; - const { position, duration } = await TrackPlayer.getProgress(); - - let newPosition = position + amount * get().playbackRate; - if (newPosition < 0) newPosition = 0; - if (newPosition > duration) newPosition = duration; - - TrackPlayer.seekTo(newPosition); - set({ position: newPosition }); - }, -})); - -function shouldSeek(state: State): boolean { - switch (state) { - case State.Paused: - case State.Stopped: - case State.Ready: - case State.Playing: - case State.Ended: - return true; - case State.Buffering: - case State.Loading: - case State.None: - case State.Error: - return false; - } -} - -async function setupTrackPlayer(): Promise { - try { - // just checking to see if it's already initialized - const track = await TrackPlayer.getTrack(0); - - if (track) { - const mediaId = track.description!; - const progress = await TrackPlayer.getProgress(); - const position = progress.position; - const duration = progress.duration; - const playbackRate = await TrackPlayer.getRate(); - return { mediaId, position, duration, playbackRate }; - } - } catch (error) { - console.debug("[TrackPlayer] player not yet set up", error); - // it's ok, we'll set it up now - } - - await TrackPlayer.setupPlayer({ - androidAudioContentType: AndroidAudioContentType.Speech, - iosCategory: IOSCategory.Playback, - iosCategoryMode: IOSCategoryMode.SpokenAudio, - autoHandleInterruptions: true, - }); - - await TrackPlayer.updateOptions({ - android: { - alwaysPauseOnInterruption: true, - }, - capabilities: [ - Capability.Play, - Capability.Pause, - Capability.JumpForward, - Capability.JumpBackward, - ], - compactCapabilities: [ - Capability.Play, - Capability.Pause, - Capability.JumpBackward, - Capability.JumpForward, - ], - forwardJumpInterval: 10, - backwardJumpInterval: 10, - progressUpdateEventInterval: 5, - }); - - console.log("[TrackPlayer] setup succeeded"); - return true; -} - -/** - * Loads the given PlayerState into the player. - */ -async function loadPlayerState( - session: Session, - playerState: LocalPlayerState, -): Promise { - console.log("Loading player state into player..."); - - await TrackPlayer.reset(); - if (playerState.media.download?.status === "ready") { - // the media is downloaded, load the local file - await TrackPlayer.add({ - url: playerState.media.download.filePath, - pitchAlgorithm: PitchAlgorithm.Voice, - duration: playerState.media.duration - ? parseFloat(playerState.media.duration) - : undefined, - title: playerState.media.book.title, - artist: playerState.media.book.bookAuthors - .map((bookAuthor) => bookAuthor.author.name) - .join(", "), - artwork: playerState.media.download.thumbnails - ? playerState.media.download.thumbnails.extraLarge - : undefined, - description: playerState.media.id, - }); - } else { - // the media is not downloaded, load the stream - await TrackPlayer.add({ - url: - Platform.OS === "ios" - ? `${session.url}${playerState.media.hlsPath}` - : `${session.url}${playerState.media.mpdPath}`, - type: TrackType.Dash, - pitchAlgorithm: PitchAlgorithm.Voice, - duration: playerState.media.duration - ? parseFloat(playerState.media.duration) - : undefined, - title: playerState.media.book.title, - artist: playerState.media.book.bookAuthors - .map((bookAuthor) => bookAuthor.author.name) - .join(", "), - artwork: playerState.media.thumbnails - ? `${session.url}/${playerState.media.thumbnails.extraLarge}` - : undefined, - description: playerState.media.id, - headers: { Authorization: `Bearer ${session.token}` }, - }); - } - - await TrackPlayer.seekTo(playerState.position); - await TrackPlayer.setRate(playerState.playbackRate); - - return { - mediaId: playerState.media.id, - duration: parseFloat(playerState.media.duration || "0"), - position: playerState.position, - playbackRate: playerState.playbackRate, - }; -} - -async function loadMedia( - session: Session, - mediaId: string, -): Promise { - const syncedPlayerState = await getSyncedPlayerState(session, mediaId); - const localPlayerState = await getLocalPlayerState(session, mediaId); - - if (!syncedPlayerState && !localPlayerState) { - // neither a synced playerState nor a local playerState exists - // create a new local playerState and load it into the player - const newLocalPlayerState = await createInitialPlayerState( - session, - mediaId, - ); - - return loadPlayerState(session, newLocalPlayerState); - } - - if (syncedPlayerState && !localPlayerState) { - // a synced playerState exists but no local playerState exists - // create a new local playerState by copying the synced playerState - const newLocalPlayerState = await createPlayerState( - session, - mediaId, - syncedPlayerState.playbackRate, - syncedPlayerState.position, - syncedPlayerState.status, - ); - - return loadPlayerState(session, newLocalPlayerState); - } - - if (!syncedPlayerState && localPlayerState) { - // a local playerState exists but no synced playerState exists - // use it as is (we haven't had a chance to sync it to the server yet) - return loadPlayerState(session, localPlayerState); - } - - // both a synced playerState and a local playerState exist - if (!localPlayerState || !syncedPlayerState) throw new Error("Impossible"); - - if (localPlayerState.updatedAt >= syncedPlayerState.updatedAt) { - // the local playerState is newer - // use it as is (the server is out of date) - return loadPlayerState(session, localPlayerState); - } - - // the synced playerState is newer - // update the local playerState by copying the synced playerState - const updatedLocalPlayerState = await updatePlayerState(session, mediaId, { - playbackRate: syncedPlayerState.playbackRate, - position: syncedPlayerState.position, - status: syncedPlayerState.status, - }); - - return loadPlayerState(session, updatedLocalPlayerState); -} - -async function loadMostRecentMedia( - session: Session, -): Promise { - const track = await TrackPlayer.getTrack(0); - - if (track) { - const mediaId = track.description!; - const progress = await TrackPlayer.getProgress(); - const position = progress.position; - const duration = progress.duration; - const playbackRate = await TrackPlayer.getRate(); - return { mediaId, position, duration, playbackRate }; - } - - const mostRecentSyncedMedia = - await getMostRecentInProgressSyncedMedia(session); - const mostRecentLocalMedia = await getMostRecentInProgressLocalMedia(session); - - if (!mostRecentSyncedMedia && !mostRecentLocalMedia) { - return null; - } - - if (mostRecentSyncedMedia && !mostRecentLocalMedia) { - return loadMedia(session, mostRecentSyncedMedia.mediaId); - } - - if (!mostRecentSyncedMedia && mostRecentLocalMedia) { - return loadMedia(session, mostRecentLocalMedia.mediaId); - } - - if (!mostRecentSyncedMedia || !mostRecentLocalMedia) - throw new Error("Impossible"); - - if (mostRecentLocalMedia.updatedAt >= mostRecentSyncedMedia.updatedAt) { - return loadMedia(session, mostRecentLocalMedia.mediaId); - } else { - return loadMedia(session, mostRecentSyncedMedia.mediaId); - } -} diff --git a/src/types/router.ts b/src/types/router.ts new file mode 100644 index 0000000..0d2f64b --- /dev/null +++ b/src/types/router.ts @@ -0,0 +1,4 @@ +export type RouterParams = { + id: string; + title: string; +}; diff --git a/src/utils/rate.ts b/src/utils/rate.ts new file mode 100644 index 0000000..ccd2ecd --- /dev/null +++ b/src/utils/rate.ts @@ -0,0 +1,11 @@ +export function formatPlaybackRate(rate: number) { + if (!rate) { + return "1.0"; + } + if (Number.isInteger(rate)) { + return rate + ".0"; + } else { + const out = rate.toFixed(2); + return out.endsWith("0") ? out.slice(0, -1) : out; + } +}