Compare commits
229 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02705dc6ed | ||
|
|
7413313100 | ||
|
|
b11f4ab6b4 | ||
|
|
3e4b1c0484 | ||
|
|
b5bc6ff3de | ||
|
|
8d4bba7964 | ||
|
|
2e5fe54615 | ||
|
|
81265510ef | ||
|
|
4d3c093612 | ||
|
|
937ba052fc | ||
|
|
479d201da9 | ||
|
|
1242cc7eb3 | ||
|
|
0b6dbeb042 | ||
|
|
c06877069f | ||
|
|
261c738fc0 | ||
|
|
5528abe4b0 | ||
|
|
09cdda2a07 | ||
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 | ||
|
|
c7b3495a23 | ||
|
|
83a5910a59 | ||
|
|
0f6639aae7 | ||
|
|
88a25bc33e | ||
|
|
73ad4ece49 | ||
|
|
52f876d8e8 | ||
|
|
72eed89f59 | ||
|
|
12bb0db5f0 | ||
|
|
5ec1773768 | ||
|
|
fb8f1dfe25 | ||
|
|
3a2d113b1b | ||
|
|
0dcfdff65b | ||
|
|
1766011b47 | ||
|
|
a6f800b0d7 | ||
|
|
af9639af05 | ||
|
|
bfc08a2df2 | ||
|
|
dc3bc3ebf2 | ||
|
|
e9d7293d37 | ||
|
|
410af8f236 | ||
|
|
264c00c765 | ||
|
|
e4c72011eb | ||
|
|
6365b14ece | ||
|
|
7da5582075 | ||
|
|
dae841e317 | ||
|
|
16b2bfffa6 | ||
|
|
57be674f44 | ||
|
|
93390fab64 | ||
|
|
072517135f | ||
|
|
fe7c7acbb7 | ||
|
|
d4cce915d9 | ||
|
|
ac24e86f7d | ||
|
|
e9bb387f71 | ||
|
|
d7319b3f7c | ||
|
|
f380c85815 | ||
|
|
9d1b340b83 | ||
|
|
a307ddc9f5 | ||
|
|
004d1b6d9d | ||
|
|
7f20411f50 | ||
|
|
6e6c581904 | ||
|
|
cecedc8687 | ||
|
|
a88e98a436 | ||
|
|
d3ae86d55b | ||
|
|
5ad5c2dbce | ||
|
|
0de91dcc0c | ||
|
|
8e3e9ef31d | ||
|
|
3c5edd5742 | ||
|
|
2142e82fe4 | ||
|
|
88cde88f69 | ||
|
|
ffcc3981f2 | ||
|
|
a7b4694e60 | ||
|
|
8c895c6ba1 | ||
|
|
83059c8a9d | ||
|
|
b54ebf60b5 | ||
|
|
e027afe89d | ||
|
|
9fc2054e36 | ||
|
|
9a43b2190e | ||
|
|
5a7d7ce3b9 | ||
|
|
ce3eef1298 | ||
|
|
5d9b41bcf2 | ||
|
|
47268dea67 | ||
|
|
57591766f2 | ||
|
|
fa8fb96631 | ||
|
|
5ba84f7945 | ||
|
|
2793ad8cfa | ||
|
|
e43699747d | ||
|
|
1e85f1c0bc | ||
|
|
0c2349f259 | ||
|
|
c9252b5953 | ||
|
|
7efeee3fc2 | ||
|
|
9a05708019 | ||
|
|
24cb18e0fe | ||
|
|
71ba882858 | ||
|
|
c35f099f50 | ||
|
|
4df287ace4 | ||
|
|
0df45de2b6 | ||
|
|
825fb04c0d | ||
|
|
fc5cd30c93 | ||
|
|
37bd73651a | ||
|
|
466e289b68 | ||
|
|
bb604019fc | ||
|
|
0745178d9e | ||
|
|
603cd2bb02 | ||
|
|
228d4902bb | ||
|
|
884c82b2c3 | ||
|
|
c6536d5b9f | ||
|
|
460e7553bf | ||
|
|
89f0dfb113 | ||
|
|
88644341d8 | ||
|
|
992eb823f2 | ||
|
|
f51113a2f8 | ||
|
|
1eb70e9b9b | ||
|
|
70dd14e5c8 | ||
|
|
8096827c78 | ||
|
|
669fd765ee | ||
|
|
314af375d5 | ||
|
|
20c45e2676 | ||
|
|
09981a5f4d | ||
|
|
de9e0b4246 | ||
|
|
a72c1f6b52 | ||
|
|
5d3a1a09ef | ||
|
|
39ad0d6c11 | ||
|
|
765b37aea3 | ||
|
|
aff6de9b45 | ||
|
|
ec66e86a18 | ||
|
|
9b7cdad71a | ||
|
|
8f0a2f7e92 | ||
|
|
08d4718245 | ||
|
|
60a9540ef7 | ||
|
|
76d616a308 | ||
|
|
e723459507 | ||
|
|
b3358ac1d2 | ||
|
|
c0d33720e9 | ||
|
|
a5c603e7a6 | ||
|
|
219d4fb214 | ||
|
|
cec0dfe64a | ||
|
|
54616b82d7 | ||
|
|
ce5db37226 | ||
|
|
60bc8e5749 | ||
|
|
b4be0803aa | ||
|
|
12eca865ce | ||
|
|
589f39b49e | ||
|
|
53083429a0 | ||
|
|
70c8db28f9 | ||
|
|
1d00fd4e2e | ||
|
|
a54d8d43aa | ||
|
|
97e7a8dc02 | ||
|
|
fb6b364382 | ||
|
|
7b48707cd9 | ||
|
|
b0547c1b43 | ||
|
|
acbfafb8cd | ||
|
|
c8e0cf2813 | ||
|
|
3899a96576 | ||
|
|
1e7f396b2d | ||
|
|
0eee2eedf3 | ||
|
|
80da1bb3e2 | ||
|
|
9f3e895fa8 | ||
|
|
cf0c0dfaaf | ||
|
|
0402c408e4 | ||
|
|
d14644238f | ||
|
|
8de374cd35 | ||
|
|
82186cfd6d | ||
|
|
b87e758303 | ||
|
|
901b18ee13 | ||
|
|
034e670795 | ||
|
|
0d7b985469 | ||
|
|
53af7515a3 | ||
|
|
11a846d043 | ||
|
|
bf2ffa54db | ||
|
|
fe204598a2 | ||
|
|
9906c7d862 | ||
|
|
06feb91f4f | ||
|
|
5a7751e6d1 | ||
|
|
555973c053 | ||
|
|
c2d6ce1c5b | ||
|
|
8edad54b10 | ||
|
|
48d8fdb6b9 | ||
|
|
1b05b6ebc6 | ||
|
|
cabdd3ffdd | ||
|
|
f80b83309a | ||
|
|
49ba2c27c2 | ||
|
|
353d7397eb | ||
|
|
89ff90629f | ||
|
|
f6febfdb5e | ||
|
|
2c43907e34 | ||
|
|
0e868506ca | ||
|
|
1b234754e8 | ||
|
|
041099598b | ||
|
|
333c8ad868 | ||
|
|
d16ae00537 | ||
|
|
d16313bb6c | ||
|
|
1bab7028c6 | ||
|
|
6520fb9a50 | ||
|
|
7acf04fb9f | ||
|
|
c2bcb2b0a6 | ||
|
|
cfd893d24b | ||
|
|
cff0c78b4f | ||
|
|
d89cefe975 | ||
|
|
a0344b36d7 | ||
|
|
af3c487afb | ||
|
|
b8d4d94b18 | ||
|
|
56bf4dde22 | ||
|
|
2f0857be45 | ||
|
|
bf5774d8d0 | ||
|
|
5131ae0bc4 | ||
|
|
9fa0776258 | ||
|
|
f265d9d020 | ||
|
|
3c26dfe2c0 | ||
|
|
1820fa7303 | ||
|
|
38e400a4c7 | ||
|
|
cb90771248 | ||
|
|
59b1cfab1d | ||
|
|
f95ad3ed29 | ||
|
|
e4c4f8de66 | ||
|
|
4f84bd29c9 | ||
|
|
6bf79ab392 | ||
|
|
4ae6f0ab42 | ||
|
|
33e2a4dc01 | ||
|
|
cb4be0848f | ||
|
|
2f948f2a50 | ||
|
|
baab66823d | ||
|
|
11d2eaa0e5 | ||
|
|
9c115f00c4 | ||
|
|
5ac89da513 | ||
|
|
af86c6f96f | ||
|
|
da4a182f85 | ||
|
|
18e76c9668 | ||
|
|
9add9033b9 | ||
|
|
66d8481637 | ||
|
|
7f92a58fd7 |
50
.env.example
50
.env.example
@@ -1,6 +1,26 @@
|
||||
# libnovel scraper — environment overrides
|
||||
# Copy to .env and adjust values; do NOT commit this file with real secrets.
|
||||
|
||||
# ── Service ports (host-side) ─────────────────────────────────────────────────
|
||||
# Port the scraper HTTP API listens on (default 8080)
|
||||
SCRAPER_PORT=8080
|
||||
|
||||
# Port PocketBase listens on (default 8090)
|
||||
POCKETBASE_PORT=8090
|
||||
|
||||
# Port MinIO S3 API listens on (default 9000)
|
||||
MINIO_PORT=9000
|
||||
|
||||
# Port MinIO web console listens on (default 9001)
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# Port Browserless Chrome listens on (default 3030)
|
||||
BROWSERLESS_PORT=3030
|
||||
|
||||
# Port the SvelteKit UI listens on (default 3000)
|
||||
UI_PORT=3000
|
||||
|
||||
# ── Browserless ───────────────────────────────────────────────────────────────
|
||||
# Browserless API token (leave empty to disable auth)
|
||||
BROWSERLESS_TOKEN=
|
||||
|
||||
@@ -19,10 +39,7 @@ ERROR_ALERT_URL=
|
||||
# Which Browserless strategy the scraper uses: content | scrape | cdp | direct
|
||||
BROWSERLESS_STRATEGY=direct
|
||||
|
||||
# Strategy for URL retrieval (chapter list). Uses browserless content strategy by default.
|
||||
# Set to direct to use plain HTTP, or content/scrape/cdp for browserless.
|
||||
BROWSERLESS_URL_STRATEGY=content
|
||||
|
||||
# ── Scraper ───────────────────────────────────────────────────────────────────
|
||||
# Chapter worker goroutines (0 = NumCPU inside the container)
|
||||
SCRAPER_WORKERS=0
|
||||
|
||||
@@ -39,3 +56,28 @@ KOKORO_URL=http://kokoro:8880
|
||||
# Single voices: af_bella, af_sky, af_heart, am_adam, …
|
||||
# Mixed voices: af_bella+af_sky or af_bella(2)+af_sky(1) (weighted blend)
|
||||
KOKORO_VOICE=af_bella
|
||||
|
||||
# ── MinIO / S3 object storage ─────────────────────────────────────────────────
|
||||
MINIO_ROOT_USER=admin
|
||||
MINIO_ROOT_PASSWORD=changeme123
|
||||
MINIO_BUCKET_CHAPTERS=libnovel-chapters
|
||||
MINIO_BUCKET_AUDIO=libnovel-audio
|
||||
|
||||
# ── PocketBase ────────────────────────────────────────────────────────────────
|
||||
# Admin credentials (used by scraper + UI server-side)
|
||||
POCKETBASE_ADMIN_EMAIL=admin@libnovel.local
|
||||
POCKETBASE_ADMIN_PASSWORD=changeme123
|
||||
|
||||
# ── SvelteKit UI ─────────────────────────────────────────────────────────────
|
||||
# Internal URL the SvelteKit server uses to reach the scraper API.
|
||||
# In docker-compose this is http://scraper:8080 (wired automatically).
|
||||
# Override here only if running the UI outside of docker-compose.
|
||||
SCRAPER_API_URL=http://localhost:8080
|
||||
|
||||
# Internal URL the SvelteKit server uses to reach PocketBase.
|
||||
# In docker-compose this is http://pocketbase:8090 (wired automatically).
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# Public MinIO URL reachable from the browser (for audio/presigned URLs).
|
||||
# In production, point this at your MinIO reverse-proxy or CDN domain.
|
||||
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
|
||||
|
||||
79
.gitea/workflows/ci-scraper.yaml
Normal file
79
.gitea/workflows/ci-scraper.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
name: CI / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Run tests
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: scraper
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ gitea.sha }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
70
.gitea/workflows/ci-ui.yaml
Normal file
70
.gitea/workflows/ci-ui.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: CI / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
@@ -1,106 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/**"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: scraper
|
||||
|
||||
jobs:
|
||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: staticcheck
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
staticcheck ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: go test -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── build binary ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scraper-linux-amd64
|
||||
path: scraper/bin/scraper
|
||||
retention-days: 7
|
||||
|
||||
# ── docker build (& push) ────────────────────────────────────────────────────
|
||||
# Uncomment once the runner has Docker available and a registry is configured.
|
||||
#
|
||||
# docker:
|
||||
# name: Docker
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [lint, test]
|
||||
# # Only push images on commits to the default branch, not on PRs.
|
||||
# # if: github.event_name == 'push'
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Log in to Gitea registry
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# registry: gitea.kalekber.cc
|
||||
# username: ${{ secrets.REGISTRY_USER }}
|
||||
# password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
#
|
||||
# - name: Build and push
|
||||
# uses: docker/build-push-action@v5
|
||||
# with:
|
||||
# context: ./scraper
|
||||
# push: true
|
||||
# tags: |
|
||||
# gitea.kalekber.cc/kamil/libnovel:latest
|
||||
# gitea.kalekber.cc/kamil/libnovel:${{ gitea.sha }}
|
||||
63
.gitea/workflows/ios.yaml
Normal file
63
.gitea/workflows/ios.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: iOS CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["v2", "main"]
|
||||
paths:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
pull_request:
|
||||
branches: ["v2", "main"]
|
||||
paths:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ios-macos-runner
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── build (simulator) ─────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Build (simulator)
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-build
|
||||
|
||||
# ── unit tests ────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: macos-latest
|
||||
needs: build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Run unit tests
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-test
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: ios/LibNovel/test-results.xml
|
||||
retention-days: 7
|
||||
68
.gitea/workflows/release-scraper.yaml
Normal file
68
.gitea/workflows/release-scraper.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Release / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & test ──────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
- name: Run tests
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── docker build & push ──────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: scraper
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
71
.gitea/workflows/release-ui.yaml
Normal file
71
.gitea/workflows/release-ui.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Release / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── docker build & push ──────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
||||
scraper/bin/
|
||||
scraper/scraper
|
||||
|
||||
# ── Scraped output (large, machine-generated) ──────────────────────────────────
|
||||
|
||||
|
||||
156
.opencode/skills/ios-ux/SKILL.md
Normal file
156
.opencode/skills/ios-ux/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: ios-ux
|
||||
description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work.
|
||||
compatibility: opencode
|
||||
---
|
||||
|
||||
# iOS UI/UX Skill — LibNovel
|
||||
|
||||
Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules.
|
||||
|
||||
---
|
||||
|
||||
## Screenshot Review Process
|
||||
|
||||
When the user provides a screenshot of the app:
|
||||
|
||||
1. **Analyze first** — identify specific UI/UX issues across these categories:
|
||||
- Visual hierarchy and spacing
|
||||
- Typography (size, weight, contrast)
|
||||
- Color and material usage
|
||||
- Animation and interactivity gaps
|
||||
- Accessibility problems
|
||||
- Deprecated or non-native patterns
|
||||
2. **Present a numbered list** of suggested improvements with brief rationale for each.
|
||||
3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?"
|
||||
4. Apply only what the user confirms.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
### Colors & Materials
|
||||
- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs.
|
||||
- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`.
|
||||
- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background.
|
||||
- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player).
|
||||
- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors.
|
||||
|
||||
### Typography
|
||||
- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark).
|
||||
- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families.
|
||||
- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier.
|
||||
- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata.
|
||||
|
||||
### Spacing & Layout
|
||||
- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons.
|
||||
- Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements.
|
||||
- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI.
|
||||
- Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art.
|
||||
|
||||
---
|
||||
|
||||
## Animation Rules
|
||||
|
||||
### Spring Animations (default for all interactive transitions)
|
||||
- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions.
|
||||
- Recommended defaults:
|
||||
- Interactive elements: `response: 0.3, dampingFraction: 0.7`
|
||||
- Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7`
|
||||
- Quick snappy feedback: `response: 0.2, dampingFraction: 0.6`
|
||||
- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays).
|
||||
|
||||
### SF Symbol Transitions
|
||||
- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.).
|
||||
- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators).
|
||||
- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing).
|
||||
- Use `.symbolEffect(.pulse)` for error/warning states that need attention.
|
||||
|
||||
### Repeating Animations
|
||||
- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains.
|
||||
- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`.
|
||||
|
||||
---
|
||||
|
||||
## Haptic Feedback
|
||||
|
||||
Add `UIImpactFeedbackGenerator` to every user-initiated interactive control:
|
||||
- `.light` — toggle switches, selection chips, secondary actions, slider drag start.
|
||||
- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations.
|
||||
- `.heavy` — destructive actions (only if no confirmation dialog).
|
||||
|
||||
Pattern:
|
||||
```swift
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
// action
|
||||
} label: { ... }
|
||||
```
|
||||
|
||||
Do **not** add haptics to:
|
||||
- Programmatic state changes not directly triggered by a tap.
|
||||
- Buttons inside `List` rows that already use swipe actions.
|
||||
- Scroll events.
|
||||
|
||||
---
|
||||
|
||||
## iOS 17+ API Usage
|
||||
|
||||
Flag and replace any of the following deprecated patterns:
|
||||
|
||||
| Deprecated | Replace with |
|
||||
|---|---|
|
||||
| `NavigationView` | `NavigationStack` |
|
||||
| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro |
|
||||
| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` |
|
||||
| Manual `@State` animation chains for repeating loops | `phaseAnimator` |
|
||||
| `.animation(_:)` without `value:` | `.animation(_:value:)` |
|
||||
| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` |
|
||||
|
||||
Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
Every view must:
|
||||
- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views.
|
||||
- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment.
|
||||
- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text.
|
||||
- Touch targets ≥ 44pt (see Spacing above).
|
||||
- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`.
|
||||
- Do not rely solely on color to convey state — pair color with icon or label.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds.
|
||||
- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects.
|
||||
- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this.
|
||||
- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache.
|
||||
- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead.
|
||||
|
||||
---
|
||||
|
||||
## Offline & Error States
|
||||
|
||||
Every view that makes network calls must:
|
||||
1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`.
|
||||
2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline.
|
||||
3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`.
|
||||
4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online.
|
||||
|
||||
---
|
||||
|
||||
## Component Checklist (before submitting any view change)
|
||||
|
||||
- [ ] All interactive elements ≥ 44pt touch target
|
||||
- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))`
|
||||
- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)`
|
||||
- [ ] Tappable controls have haptic feedback
|
||||
- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:`
|
||||
- [ ] High-frequency observers are isolated sub-views
|
||||
- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor`
|
||||
- [ ] VoiceOver labels on icon-only buttons
|
||||
- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies
|
||||
171
AGENTS.md
171
AGENTS.md
@@ -1,28 +1,43 @@
|
||||
# libnovel Project
|
||||
|
||||
Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI.
|
||||
Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI. Structured data in PocketBase, binary blobs (chapters, audio, browse snapshots) in MinIO. SvelteKit frontend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scraper/
|
||||
├── cmd/scraper/main.go # Entry point: 'run' (one-shot) and 'serve' (HTTP server)
|
||||
├── cmd/scraper/main.go # Entry point: run | refresh | serve | save-browse
|
||||
├── internal/
|
||||
│ ├── orchestrator/orchestrator.go # Coordinates catalogue walk, metadata extraction, chapter scraping
|
||||
│ ├── browser/ # Browser client (content/scrape/cdp strategies) via Browserless
|
||||
│ ├── novelfire/scraper.go # novelfire.net specific scraping logic
|
||||
│ ├── server/server.go # HTTP API (POST /scrape, POST /scrape/book)
|
||||
│ ├── writer/writer.go # File writer (metadata.yaml, chapter .md files)
|
||||
│ └── scraper/interfaces.go # NovelScraper interface definition
|
||||
└── static/books/ # Output directory for scraped content
|
||||
│ ├── orchestrator/orchestrator.go # Catalogue walk → per-book metadata goroutines → chapter worker pool
|
||||
│ ├── browser/ # BrowserClient interface + direct HTTP (production) + Browserless variants
|
||||
│ ├── novelfire/scraper.go # novelfire.net scraping (catalogue, metadata, chapters, ranking)
|
||||
│ ├── server/ # HTTP API server (server.go + 6 handler files)
|
||||
│ │ ├── server.go # Server struct, route registration, ListenAndServe
|
||||
│ │ ├── handlers_scrape.go # POST /scrape, /scrape/book, /scrape/book/range; job status/tasks
|
||||
│ │ ├── handlers_browse.go # GET /api/browse, /api/search, /api/cover — MinIO-cached browse pages
|
||||
│ │ ├── handlers_preview.go # GET /api/book-preview, /api/chapter-text-preview — live scrape, no store writes
|
||||
│ │ ├── handlers_audio.go # POST /api/audio, GET /api/audio-proxy, voice samples, presign
|
||||
│ │ ├── handlers_progress.go # GET/POST/DELETE /api/progress
|
||||
│ │ ├── handlers_ranking.go # GET /api/ranking, /api/cover
|
||||
│ │ └── helpers.go # stripMarkdown, hardcoded voice list fallback
|
||||
│ ├── storage/ # Persistence layer (PocketBase + MinIO)
|
||||
│ │ ├── store.go # Store interface — single abstraction for server + orchestrator
|
||||
│ │ ├── hybrid.go # HybridStore: routes structured data → PocketBase, blobs → MinIO
|
||||
│ │ ├── pocketbase.go # PocketBase REST admin client (7 collections, auth, schema bootstrap)
|
||||
│ │ ├── minio.go # MinIO client (3 buckets: chapters, audio, browse)
|
||||
│ │ └── coverutil.go # Best-effort cover image downloader → browse bucket
|
||||
│ └── scraper/
|
||||
│ ├── interfaces.go # NovelScraper interface + domain types (BookMeta, ChapterRef, etc.)
|
||||
│ └── htmlutil/htmlutil.go # HTML parsing helpers (NodeToMarkdown, ResolveURL, etc.)
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Orchestrator**: Manages concurrency - catalogue streaming → per-book metadata goroutines → chapter worker pool
|
||||
- **Browser Client**: 3 strategies (content/scrape/cdp) via Browserless Chrome container
|
||||
- **Writer**: Writes metadata.yaml and chapter markdown files to `static/books/{slug}/vol-0/1-50/`
|
||||
- **Server**: HTTP API with async scrape jobs, UI for browsing books/chapters, chapter-text endpoint for TTS
|
||||
- **Orchestrator**: Catalogue stream → per-book goroutines (metadata + chapter list) → shared chapter work channel → N worker goroutines (chapter text). Scrape jobs tracked in PocketBase `scraping_tasks`.
|
||||
- **Storage**: `HybridStore` implements the `Store` interface. PocketBase holds structured records (`books`, `chapters_idx`, `ranking`, `progress`, `audio_cache`, `app_users`, `scraping_tasks`). MinIO holds blobs (chapter markdown, audio MP3s, browse HTML snapshots, cover images).
|
||||
- **Browser Client**: Production uses `NewDirectHTTPClient` (plain HTTP, no Browserless). Browserless variants (content/scrape/cdp) exist in `browser/` but are only wired for the `save-browse` subcommand.
|
||||
- **Preview**: `GET /api/book-preview/{slug}` scrapes metadata + chapter list live without persisting anything — used when a book is not yet in the library. On first visit, metadata and chapter index are auto-saved to PocketBase in the background.
|
||||
- **Server**: 24 HTTP endpoints. Async scrape jobs (mutex, 409 on concurrent), in-flight dedup for audio generation, MinIO-backed browse page cache with mem-cache fallback.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -30,60 +45,138 @@ scraper/
|
||||
# Build
|
||||
cd scraper && go build -o bin/scraper ./cmd/scraper
|
||||
|
||||
# One-shot scrape (full catalogue)
|
||||
# Full catalogue scrape (one-shot)
|
||||
./bin/scraper run
|
||||
|
||||
# Single book
|
||||
./bin/scraper run --url https://novelfire.net/book/xxx
|
||||
|
||||
# Re-scrape a book already in the DB (uses stored source_url)
|
||||
./bin/scraper refresh <slug>
|
||||
|
||||
# HTTP server
|
||||
./bin/scraper serve
|
||||
|
||||
# Tests
|
||||
# Capture browse pages to MinIO via SingleFile CLI (requires SINGLEFILE_PATH + BROWSERLESS_URL)
|
||||
./bin/scraper save-browse
|
||||
|
||||
# Tests (unit only — integration tests require live services)
|
||||
cd scraper && go test ./... -short
|
||||
|
||||
# All tests (requires MinIO + PocketBase + Browserless)
|
||||
cd scraper && go test ./...
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Scraper (Go)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| BROWSERLESS_URL | Browserless Chrome endpoint | http://localhost:3030 |
|
||||
| BROWSERLESS_STRATEGY | content \| scrape \| cdp | content |
|
||||
| SCRAPER_WORKERS | Chapter goroutines | NumCPU |
|
||||
| SCRAPER_STATIC_ROOT | Output directory | ./static/books |
|
||||
| SCRAPER_HTTP_ADDR | HTTP listen address | :8080 |
|
||||
| KOKORO_URL | Kokoro TTS endpoint | http://localhost:8880 |
|
||||
| KOKORO_VOICE | Default TTS voice | af_bella |
|
||||
| LOG_LEVEL | debug \| info \| warn \| error | info |
|
||||
| `LOG_LEVEL` | `debug\|info\|warn\|error` | `info` |
|
||||
| `SCRAPER_HTTP_ADDR` | HTTP listen address | `:8080` |
|
||||
| `SCRAPER_WORKERS` | Chapter goroutines | `NumCPU` |
|
||||
| `SCRAPER_TIMEOUT` | Per-request HTTP timeout (seconds) | `90` |
|
||||
| `KOKORO_URL` | Kokoro-FastAPI TTS base URL | `https://kokoro.kalekber.cc` |
|
||||
| `KOKORO_VOICE` | Default TTS voice | `af_bella` |
|
||||
| `MINIO_ENDPOINT` | MinIO S3 API host:port | `localhost:9000` |
|
||||
| `MINIO_PUBLIC_ENDPOINT` | Public MinIO endpoint for presigned URLs | `""` |
|
||||
| `MINIO_ACCESS_KEY` | MinIO access key | `admin` |
|
||||
| `MINIO_SECRET_KEY` | MinIO secret key | `changeme123` |
|
||||
| `MINIO_USE_SSL` | TLS for internal MinIO connection | `false` |
|
||||
| `MINIO_PUBLIC_USE_SSL` | TLS for public presigned URL endpoint | `true` |
|
||||
| `MINIO_BUCKET_CHAPTERS` | Chapter markdown bucket | `libnovel-chapters` |
|
||||
| `MINIO_BUCKET_AUDIO` | Audio MP3 bucket | `libnovel-audio` |
|
||||
| `MINIO_BUCKET_BROWSE` | Browse HTML + cover image bucket | `libnovel-browse` |
|
||||
| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` |
|
||||
| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` |
|
||||
| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` |
|
||||
| `BROWSERLESS_URL` | Browserless WS endpoint (save-browse only) | `http://localhost:3030` |
|
||||
| `SINGLEFILE_PATH` | SingleFile CLI binary path (save-browse only) | `single-file` |
|
||||
|
||||
### UI (SvelteKit)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `AUTH_SECRET` | HMAC signing secret for auth tokens | `dev_secret_change_in_production` |
|
||||
| `SCRAPER_API_URL` | Internal URL of the Go scraper | `http://localhost:8080` |
|
||||
| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` |
|
||||
| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` |
|
||||
| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` |
|
||||
| `PUBLIC_MINIO_PUBLIC_URL` | Browser-visible MinIO URL (presigned links) | `http://localhost:9000` |
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker-compose up -d # Starts browserless, kokoro, scraper
|
||||
docker-compose up -d # Starts: minio, minio-init, pocketbase, pb-init, scraper, ui
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
| Service | Port(s) | Role |
|
||||
|---------|---------|------|
|
||||
| `minio` | `9000` (S3 API), `9001` (console) | Object storage |
|
||||
| `minio-init` | — | One-shot bucket creation then exits |
|
||||
| `pocketbase` | `8090` | Structured data store |
|
||||
| `pb-init` | — | One-shot PocketBase collection bootstrap then exits |
|
||||
| `scraper` | `8080` | Go scraper HTTP API |
|
||||
| `ui` | `5252` → internal `3000` | SvelteKit frontend |
|
||||
|
||||
Kokoro and Browserless are **external services** — not in docker-compose.
|
||||
|
||||
## HTTP API Endpoints (Go scraper)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/health` | Liveness probe |
|
||||
| `POST` | `/scrape` | Enqueue full catalogue scrape |
|
||||
| `POST` | `/scrape/book` | Enqueue single-book scrape `{url}` |
|
||||
| `POST` | `/scrape/book/range` | Enqueue range scrape `{url, from, to?}` |
|
||||
| `GET` | `/api/scrape/status` | Current scrape job status |
|
||||
| `GET` | `/api/scrape/tasks` | All scrape task records |
|
||||
| `GET` | `/api/browse` | Browse novelfire catalogue (MinIO-cached) |
|
||||
| `GET` | `/api/search` | Search local + remote `?q=` |
|
||||
| `GET` | `/api/ranking` | Ranking list |
|
||||
| `GET` | `/api/cover/{domain}/{slug}` | Proxy cover image from MinIO |
|
||||
| `GET` | `/api/book-preview/{slug}` | Live metadata + chapter list (no store write) |
|
||||
| `GET` | `/api/chapter-text-preview/{slug}/{n}` | Live chapter text (no store write) |
|
||||
| `POST` | `/api/reindex/{slug}` | Rebuild chapters_idx from MinIO |
|
||||
| `GET` | `/api/chapter-text/{slug}/{n}` | Chapter text (markdown stripped) |
|
||||
| `POST` | `/api/audio/{slug}/{n}` | Trigger Kokoro TTS generation |
|
||||
| `GET` | `/api/audio-proxy/{slug}/{n}` | Proxy generated audio |
|
||||
| `POST` | `/api/audio/voice-samples` | Pre-generate voice samples |
|
||||
| `GET` | `/api/voices` | List available Kokoro voices |
|
||||
| `GET` | `/api/presign/chapter/{slug}/{n}` | Presigned MinIO URL for chapter |
|
||||
| `GET` | `/api/presign/audio/{slug}/{n}` | Presigned MinIO URL for audio |
|
||||
| `GET` | `/api/presign/voice-sample/{voice}` | Presigned MinIO URL for voice sample |
|
||||
| `GET` | `/api/progress` | Get reading progress (session-scoped) |
|
||||
| `POST` | `/api/progress/{slug}` | Set reading progress |
|
||||
| `DELETE` | `/api/progress/{slug}` | Delete reading progress |
|
||||
|
||||
## Code Patterns
|
||||
|
||||
- Uses `log/slog` for structured logging
|
||||
- Context-based cancellation throughout
|
||||
- Worker pool pattern in orchestrator (channel + goroutines)
|
||||
- Mutex for single async job (409 on concurrent scrape requests)
|
||||
- `log/slog` for structured logging throughout
|
||||
- Context-based cancellation on all network calls and goroutines
|
||||
- Worker pool pattern in orchestrator (buffered channel + WaitGroup)
|
||||
- Single async scrape job enforced by mutex; 409 on concurrent requests; job state persisted to `scraping_tasks` in PocketBase
|
||||
- `Store` interface decouples all persistence — pass it around, never touch MinIO/PocketBase clients directly outside `storage/`
|
||||
- Auth: custom HMAC-signed token (`userId:username:role.<sig>`) in `libnovel_auth` cookie; signed with `AUTH_SECRET`
|
||||
|
||||
## AI Context Tips
|
||||
|
||||
- Primary files to modify: `orchestrator.go`, `server.go`, `scraper.go`, `browser/*.go`
|
||||
- To add new source: implement `NovelScraper` interface from `internal/scraper/interfaces.go`
|
||||
- Skip `static/` directory - generated content, not source
|
||||
- **Primary files to modify**: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go`
|
||||
- **To add a new scrape source**: implement `NovelScraper` from `internal/scraper/interfaces.go`
|
||||
- **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()`
|
||||
- **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go`
|
||||
- **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes
|
||||
|
||||
## Speed Up AI Sessions (Optional)
|
||||
## iOS App
|
||||
|
||||
For faster AI context loading, use **Context7** (free, local indexing):
|
||||
See `ios/AGENTS.md` for full iOS/SwiftUI conventions.
|
||||
|
||||
```bash
|
||||
# Install and index once
|
||||
npx @context7/cli@latest index --path . --ignore .aiignore
|
||||
## Documentation Tools
|
||||
|
||||
# After first run, AI tools will query the index instead of re-scanning files
|
||||
```
|
||||
This project has two MCP-backed documentation tools available. Use them proactively:
|
||||
|
||||
VSCode extension: https://marketplace.visualstudio.com/items?itemName=context7.context7
|
||||
- **`context7`** — Live Apple SwiftUI/Swift docs, Go stdlib, SvelteKit, and any other library docs. Use before implementing anything non-trivial in Swift/SwiftUI. Example: `use context7 to look up NavigationStack`.
|
||||
- **`gh_grep`** — Search real-world code on GitHub for implementation patterns. Example: `use gh_grep to find examples of background URLSession in Swift`.
|
||||
|
||||
@@ -1,82 +1,165 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
# ─── Browserless ────────────────────────────────────────────────────────────
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
container_name: libnovel-browserless
|
||||
# ─── MinIO (object storage for chapter .md files + audio cache) ─────────────
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
#container_name: libnovel-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
# Set a token to lock down the endpoint; the scraper reads it via
|
||||
# BROWSERLESS_TOKEN below.
|
||||
TOKEN: "${BROWSERLESS_TOKEN:-}"
|
||||
# Allow up to 10 concurrent browser sessions.
|
||||
CONCURRENT: "${BROWSERLESS_CONCURRENT:-10}"
|
||||
# Queue up to 100 requests before returning 429.
|
||||
QUEUED: "${BROWSERLESS_QUEUED:-100}"
|
||||
# Per-session timeout in ms.
|
||||
TIMEOUT: "${BROWSERLESS_TIMEOUT:-60000}"
|
||||
# Optional webhook URL for Browserless error alerts.
|
||||
ERROR_ALERT_URL: "${ERROR_ALERT_URL:-}"
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "3030:3000"
|
||||
# Shared memory is required for Chrome.
|
||||
shm_size: "2gb"
|
||||
- "${MINIO_PORT:-9000}:9000" # S3 API
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/json/version"]
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Kokoro-FastAPI (TTS) ────────────────────────────────────────────────────
|
||||
# CPU image; swap for ghcr.io/remsky/kokoro-fastapi-gpu:latest on NVIDIA hosts.
|
||||
# Models are baked in — no volume mount required for the default voice set.
|
||||
kokoro:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
container_name: libnovel-kokoro
|
||||
# ─── MinIO bucket initialisation ─────────────────────────────────────────────
|
||||
# Runs once to create the default buckets and then exits.
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
#container_name: libnovel-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123};
|
||||
mc mb --ignore-existing local/libnovel-chapters;
|
||||
mc mb --ignore-existing local/libnovel-audio;
|
||||
mc mb --ignore-existing local/libnovel-browse;
|
||||
mc mb --ignore-existing local/libnovel-avatars;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
|
||||
# ─── PocketBase (auth + structured data: books, chapters index, ranking, progress) ──
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
#container_name: libnovel-pocketbase
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Auto-create superuser on first boot (used by entrypoint.sh)
|
||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "8880:8880"
|
||||
- "${POCKETBASE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8880/health"]
|
||||
interval: 15s
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── PocketBase collection bootstrap ────────────────────────────────────────
|
||||
# One-shot init container: creates all required collections via the admin API
|
||||
# and exits. Idempotent — safe to run on every `docker compose up`.
|
||||
pb-init:
|
||||
image: alpine:3.19
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
volumes:
|
||||
- ./scripts/pb-init.sh:/pb-init.sh:ro
|
||||
entrypoint: ["sh", "/pb-init.sh"]
|
||||
|
||||
# ─── Scraper ─────────────────────────────────────────────────────────────────
|
||||
scraper:
|
||||
build:
|
||||
context: ./scraper
|
||||
dockerfile: Dockerfile
|
||||
container_name: libnovel-scraper
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
kokoro:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BROWSERLESS_URL: "http://browserless:3000"
|
||||
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN:-}"
|
||||
# content | scrape | cdp | direct — swap to test different strategies.
|
||||
BROWSERLESS_STRATEGY: "${BROWSERLESS_STRATEGY:-direct}"
|
||||
# Strategy for URL retrieval (chapter list). Default: content (browserless)
|
||||
BROWSERLESS_URL_STRATEGY: "${BROWSERLESS_URL_STRATEGY:-content}"
|
||||
# 0 → defaults to NumCPU inside the container.
|
||||
SCRAPER_WORKERS: "${SCRAPER_WORKERS:-0}"
|
||||
SCRAPER_STATIC_ROOT: "/app/static/books"
|
||||
SCRAPER_HTTP_ADDR: ":8080"
|
||||
LOG_LEVEL: "debug"
|
||||
# Kokoro-FastAPI TTS endpoint.
|
||||
KOKORO_URL: "${KOKORO_URL:-http://localhost:8880}"
|
||||
KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
|
||||
# MinIO / S3 object storage
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
MINIO_USE_SSL: "false"
|
||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
||||
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
|
||||
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}"
|
||||
# SingleFile CLI path for save-browse subcommand
|
||||
SINGLEFILE_PATH: "${SINGLEFILE_PATH:-single-file}"
|
||||
# PocketBase
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- static_books:/app/static/books
|
||||
- "${SCRAPER_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ─── SvelteKit UI ────────────────────────────────────────────────────────────
|
||||
ui:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
# container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
scraper:
|
||||
condition: service_healthy
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SCRAPER_API_URL: "http://scraper:8080"
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
||||
ports:
|
||||
- "${UI_PORT:-5252}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
static_books:
|
||||
minio_data:
|
||||
pb_data:
|
||||
|
||||
14
ios/.gitignore
vendored
Normal file
14
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Xcode build artifacts — regenerate with: xcodegen generate --spec project.yml
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
*.xcworkspace/xcuserdata/
|
||||
DerivedData/
|
||||
build/
|
||||
|
||||
# Swift Package Manager — resolved by Xcode on first open
|
||||
LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
|
||||
.build/
|
||||
# Package.resolved is committed so SPM builds are reproducible
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
87
ios/AGENTS.md
Normal file
87
ios/AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# LibNovel iOS App
|
||||
|
||||
SwiftUI app targeting iOS 17+. Consumes the Go scraper HTTP API for books, chapters, and audio. Uses MinIO presigned URLs for media playback and downloads.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ios/LibNovel/LibNovel/
|
||||
├── App/ # LibNovelApp.swift, ContentView.swift, RootTabView.swift
|
||||
├── Models/ # Models.swift (all domain types)
|
||||
├── Networking/ # APIClient.swift (URLSession-based HTTP client)
|
||||
├── Services/ # AudioPlayerService, AudioDownloadService, AuthStore,
|
||||
│ # BookVoicePreferences, NetworkMonitor
|
||||
├── ViewModels/ # One per view/feature (HomeViewModel, BrowseViewModel, etc.)
|
||||
├── Views/
|
||||
│ ├── Auth/ # AuthView
|
||||
│ ├── BookDetail/ # BookDetailView, CommentsView
|
||||
│ ├── Browse/ # BrowseView (infinite scroll shelves)
|
||||
│ ├── ChapterReader/ # ChapterReaderView, DownloadAudioButton
|
||||
│ ├── Common/ # CommonViews (shared reusable components)
|
||||
│ ├── Components/ # OfflineBanner
|
||||
│ ├── Downloads/ # DownloadsView, DownloadQueueButton
|
||||
│ ├── Home/ # HomeView
|
||||
│ ├── Library/ # LibraryView (2-col grid, filters)
|
||||
│ ├── Player/ # PlayerViews (floating FAB, compact, full-screen)
|
||||
│ ├── Profile/ # ProfileView, VoiceSelectionView, UserProfileView, etc.
|
||||
│ └── Search/ # SearchView
|
||||
└── Extensions/ # NavDestination.swift, String+App.swift, Color+App.swift
|
||||
```
|
||||
|
||||
## iOS / Swift Conventions
|
||||
|
||||
- **Deployment target**: iOS 17.0 — use iOS 17+ APIs freely.
|
||||
- **Observable pattern**: The codebase currently uses `@StateObject` / `ObservableObject` / `@Published`. When adding new types, prefer the **`@Observable` macro** (iOS 17+) over `ObservableObject`. Do not refactor existing types unless explicitly asked.
|
||||
- **Navigation**: Use `NavigationStack` (not `NavigationView`). Use `.navigationDestination(for:)` for type-safe routing.
|
||||
- **Concurrency**: Use `async/await` and structured concurrency. Avoid callback-based APIs and `DispatchQueue.main.async` — prefer `@MainActor` or `await MainActor.run`.
|
||||
- **State management**: Prefer `@State` + `@Binding` for local UI state. Use environment objects for app-wide services (authStore, audioPlayer, downloadService, networkMonitor).
|
||||
- **SwiftData**: Not currently used. Do not introduce SwiftData without discussion.
|
||||
- **SF Symbols**: Use `Image(systemName:)` for icons. No emoji in UI unless already present.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Download keys**: Use `::` as separator (e.g., `"slug::chapter-1::voice"`), never `-`. Slugs contain hyphens.
|
||||
- **Voice fallback chain**: book override → global default → `"af_bella"`. See `BookVoicePreferences.voiceWithFallback()`.
|
||||
- **Offline handling**: Wrap view bodies in `VStack` with `OfflineBanner` at top. Use `NetworkMonitor` (environment object) to gate network calls. Suppress network errors silently when offline via `ErrorAlertModifier`.
|
||||
- **Audio playback priority**: local file → MinIO presigned URL → trigger TTS generation.
|
||||
- **Progress display**: Show decimal % when < 10% (e.g., "3.4%"), rounded when >= 10% (e.g., "47%").
|
||||
- **Cover images**: Always proxy via `/api/cover/{domain}/{slug}` — never link directly to source.
|
||||
|
||||
## Networking
|
||||
|
||||
`APIClient.swift` wraps all Go scraper API calls. When adding new endpoints:
|
||||
|
||||
1. Add a method to `APIClient`.
|
||||
2. Keep error handling consistent — throw typed errors, let ViewModels catch and set `errorMessage`.
|
||||
3. All requests are relative to `SCRAPER_API_URL` (configured at build time via xcconfig or environment).
|
||||
|
||||
## Using Documentation Tools
|
||||
|
||||
When writing or reviewing SwiftUI/Swift code:
|
||||
|
||||
- Use `context7` to look up current Apple SwiftUI/Swift documentation before implementing anything non-trivial. Apple's APIs evolve fast — do not rely on training data alone.
|
||||
- Use `gh_grep` to find real-world Swift patterns when unsure how something is typically implemented.
|
||||
|
||||
Example prompts:
|
||||
- "How does `.searchable` work in iOS 17? use context7"
|
||||
- "Show me examples of `@Observable` with async tasks. use context7"
|
||||
- "How do other apps implement background URLSession downloads in Swift? use gh_grep"
|
||||
|
||||
## UI/UX Skill
|
||||
|
||||
For any iOS view work, always load the `ios-ux` skill at the start of the task:
|
||||
|
||||
```
|
||||
skill({ name: "ios-ux" })
|
||||
```
|
||||
|
||||
This skill defines the full design system, animation rules, haptic feedback policy, accessibility checklist, performance guidelines, and offline handling requirements. It also governs how to handle screenshot-based reviews (analyze → suggest → confirm before applying).
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- `NavigationView` — deprecated, use `NavigationStack`
|
||||
- `ObservableObject` / `@Published` for new types — prefer `@Observable`
|
||||
- `DispatchQueue.main.async` — prefer `@MainActor`
|
||||
- Force unwrapping optionals
|
||||
- Hardcoded color literals — use `Color+App.swift` extensions or semantic colors
|
||||
- Adding new dependencies (SPM packages) without discussion
|
||||
10
ios/LibNovel/.gitignore
vendored
Normal file
10
ios/LibNovel/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/README.md
|
||||
|
||||
# Bundler
|
||||
.bundle
|
||||
vendor/bundle
|
||||
21
ios/LibNovel/ExportOptions.plist
Normal file
21
ios/LibNovel/ExportOptions.plist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>GHZXC6FVMU</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.kalekber.LibNovel</key>
|
||||
<string>LibNovel Distribution</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
3
ios/LibNovel/Gemfile
Normal file
3
ios/LibNovel/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
772
ios/LibNovel/LibNovel.xcodeproj/project.pbxproj
Normal file
772
ios/LibNovel/LibNovel.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,772 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; };
|
||||
07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */; };
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; };
|
||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; };
|
||||
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */; };
|
||||
192F82518CB8763775E33B38 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79133D9FA697D1909C8D3973 /* SearchView.swift */; };
|
||||
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */; };
|
||||
1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */; };
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; };
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; };
|
||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */; };
|
||||
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */; };
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
|
||||
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */; };
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
|
||||
774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175390266E8C6CF1437A229 /* DownloadsView.swift */; };
|
||||
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||
880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */; };
|
||||
8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */; };
|
||||
9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */; };
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; };
|
||||
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */; };
|
||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
||||
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; };
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
||||
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B593F179EC3E9112126B540B /* APIClient.swift */; };
|
||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D039EDECDE3998D8534BB680;
|
||||
remoteInfo = LibNovel;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
16B9AFE90719BDBC718F0621 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
||||
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueButton.swift; sourceTree = "<group>"; };
|
||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; };
|
||||
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAudioButton.swift; sourceTree = "<group>"; };
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
|
||||
5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
||||
79133D9FA697D1909C8D3973 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
|
||||
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
8175390266E8C6CF1437A229 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
|
||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
|
||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
|
||||
8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; };
|
||||
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.swift; sourceTree = "<group>"; };
|
||||
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
|
||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.swift; sourceTree = "<group>"; };
|
||||
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = "<group>"; };
|
||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||
F247DE25991F4DB98DF717AA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
EFE3211B202EDF04EB141EFB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2C0FB0EDFF9B3E24B97F4214 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A776719B77EDDB5E44743B0 /* Assets.xcassets */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2C57B93EAF19A3B18E7B7E87 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2F18D1275D6022B9847E310E /* Auth */,
|
||||
FB5C0D4925633786D28C6DE3 /* BookDetail */,
|
||||
8E8AAA58A33084ADB8AEA80C /* Browse */,
|
||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */,
|
||||
5D5809803A3D74FAE19DB218 /* Common */,
|
||||
9180FAFE96724B8AACFA9859 /* Components */,
|
||||
3881CBFE9730C6422BE6F03D /* Downloads */,
|
||||
811FC0F6B9C209D6EC8543BD /* Home */,
|
||||
FA994FD601E79EC811D822A4 /* Library */,
|
||||
89F2CB14192E7D7565A588E0 /* Player */,
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
||||
474BE4FC0353C2DD8D8425D1 /* Search */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2F18D1275D6022B9847E310E /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3881CBFE9730C6422BE6F03D /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */,
|
||||
8175390266E8C6CF1437A229 /* DownloadsView.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */,
|
||||
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */,
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||
F247DE25991F4DB98DF717AA /* UserProfileView.swift */,
|
||||
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
426F7C5465758645B93A1AB1 /* Networking */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */,
|
||||
);
|
||||
path = Networking;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
474BE4FC0353C2DD8D8425D1 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
79133D9FA697D1909C8D3973 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
|
||||
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */,
|
||||
);
|
||||
path = ChapterReader;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D5809803A3D74FAE19DB218 /* Common */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8E89FD8F46747CA653C5203D /* CommonViews.swift */,
|
||||
);
|
||||
path = Common;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6318D3C6F0DC6C8E2C377103 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */,
|
||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
646952B9CE927F8038FF0A13 /* LibNovelTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */,
|
||||
);
|
||||
path = LibNovelTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80148B5E27BD0A3DEDB3ADAA /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
762E378B9BC2161A7AA2CC36 /* Models.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
811FC0F6B9C209D6EC8543BD /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6268D60803940CBD38FB921 /* HomeView.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
89F2CB14192E7D7565A588E0 /* Player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */,
|
||||
);
|
||||
path = Player;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8E8AAA58A33084ADB8AEA80C /* Browse */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */,
|
||||
);
|
||||
path = Browse;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9180FAFE96724B8AACFA9859 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9AF55E5D62F980C72431782A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A28A184E73B15138A4D13F31 /* LibNovel */,
|
||||
646952B9CE927F8038FF0A13 /* LibNovelTests */,
|
||||
6318D3C6F0DC6C8E2C377103 /* Products */,
|
||||
);
|
||||
indentWidth = 4;
|
||||
sourceTree = "<group>";
|
||||
tabWidth = 4;
|
||||
usesTabs = 0;
|
||||
};
|
||||
A28A184E73B15138A4D13F31 /* LibNovel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE92158CC5DA9AD446062724 /* App */,
|
||||
FD5EDEE9747643D45CA6423E /* Extensions */,
|
||||
80148B5E27BD0A3DEDB3ADAA /* Models */,
|
||||
426F7C5465758645B93A1AB1 /* Networking */,
|
||||
2C0FB0EDFF9B3E24B97F4214 /* Resources */,
|
||||
DA6F6F625578875F3E74F1D3 /* Services */,
|
||||
B6916C5C762A37AB1279DF44 /* ViewModels */,
|
||||
2C57B93EAF19A3B18E7B7E87 /* Views */,
|
||||
);
|
||||
path = LibNovel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6916C5C762A37AB1279DF44 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */,
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */,
|
||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */,
|
||||
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */,
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */,
|
||||
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DA6F6F625578875F3E74F1D3 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */,
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
|
||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
|
||||
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */,
|
||||
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FA994FD601E79EC811D822A4 /* Library */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */,
|
||||
);
|
||||
path = Library;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FB5C0D4925633786D28C6DE3 /* BookDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||
16B9AFE90719BDBC718F0621 /* CommentsView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD5EDEE9747643D45CA6423E /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
|
||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FE92158CC5DA9AD446062724 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4B820081FA4817765A39939A /* ContentView.swift */,
|
||||
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */,
|
||||
2D5C115992F1CE2326236765 /* RootTabView.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */;
|
||||
buildPhases = (
|
||||
247D45B3DB26CAC41FA78A0B /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */,
|
||||
);
|
||||
name = LibNovelTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = LibNovelTests;
|
||||
productReference = 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
D039EDECDE3998D8534BB680 /* LibNovel */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */;
|
||||
buildPhases = (
|
||||
48661ADCA15B54E048CF694C /* Sources */,
|
||||
27446CA4728C022832398376 /* Resources */,
|
||||
EFE3211B202EDF04EB141EFB /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = LibNovel;
|
||||
packageProductDependencies = (
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */,
|
||||
);
|
||||
productName = LibNovel;
|
||||
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A10A669C0C8B43078C0FEE9F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
};
|
||||
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 9AF55E5D62F980C72431782A;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
D039EDECDE3998D8534BB680 /* LibNovel */,
|
||||
5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
27446CA4728C022832398376 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
247D45B3DB26CAC41FA78A0B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
48661ADCA15B54E048CF694C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */,
|
||||
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */,
|
||||
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */,
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */,
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */,
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */,
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
||||
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */,
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
||||
07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */,
|
||||
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */,
|
||||
880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */,
|
||||
774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */,
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */,
|
||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */,
|
||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */,
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */,
|
||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */,
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */,
|
||||
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */,
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||
192F82518CB8763775E33B38 /* SearchView.swift in Sources */,
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */,
|
||||
9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */,
|
||||
8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */,
|
||||
1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D039EDECDE3998D8534BB680 /* LibNovel */;
|
||||
targetProxy = 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
428871329DC9E7B31FA1664B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
49CBF0D367E562629E002A4B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
8098D4A97F989064EC71E5A1 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
9C182367114E72FF84D54A2F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.10;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D9977A0FA70F052FD0C126D3 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F9ED141CFB1E2EC6F5E9F089 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.10;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
8098D4A97F989064EC71E5A1 /* Debug */,
|
||||
D9977A0FA70F052FD0C126D3 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
49CBF0D367E562629E002A4B /* Debug */,
|
||||
428871329DC9E7B31FA1664B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
9C182367114E72FF84D54A2F /* Debug */,
|
||||
F9ED141CFB1E2EC6F5E9F089 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/onevcat/Kingfisher";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 8.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
}
|
||||
7
ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher",
|
||||
"state" : {
|
||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E6D3E8266BFCF0AAF5EC79D"
|
||||
BuildableName = "LibNovelTests.xctest"
|
||||
BlueprintName = "LibNovelTests"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "LIBNOVEL_BASE_URL"
|
||||
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
16
ios/LibNovel/LibNovel/App/ContentView.swift
Normal file
16
ios/LibNovel/LibNovel/App/ContentView.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authStore.isAuthenticated {
|
||||
RootTabView()
|
||||
} else {
|
||||
AuthView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
ios/LibNovel/LibNovel/App/LibNovelApp.swift
Normal file
19
ios/LibNovel/LibNovel/App/LibNovelApp.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct LibNovelApp: App {
|
||||
@StateObject private var authStore = AuthStore()
|
||||
@StateObject private var audioPlayer = AudioPlayerService()
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@StateObject private var networkMonitor = NetworkMonitor()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(authStore)
|
||||
.environmentObject(audioPlayer)
|
||||
.environmentObject(downloadService)
|
||||
.environmentObject(networkMonitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
ios/LibNovel/LibNovel/App/RootTabView.swift
Normal file
90
ios/LibNovel/LibNovel/App/RootTabView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Root tab container with persistent mini-player overlay
|
||||
|
||||
struct RootTabView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var showFullPlayer: Bool = false
|
||||
@State private var readerIsActive: Bool = false
|
||||
|
||||
/// Live drag offset while the user is dragging the full player down.
|
||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, library, browse, search
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
.tag(Tab.home)
|
||||
|
||||
LibraryView()
|
||||
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
SearchView()
|
||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||
.tag(Tab.search)
|
||||
}
|
||||
|
||||
// Mini player bar — sits above the tab bar, hidden while full player is open
|
||||
// or while the chapter reader is active (it has its own audio chrome).
|
||||
if audioPlayer.isActive && !showFullPlayer && !readerIsActive {
|
||||
MiniPlayerBar(showFullPlayer: $showFullPlayer)
|
||||
// Lift above the tab bar (approx 49 pt on all devices)
|
||||
.padding(.bottom, 49)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
}
|
||||
|
||||
// Full player — slides up from the bottom as a custom overlay.
|
||||
if showFullPlayer {
|
||||
FullPlayerView(onDismiss: {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
showFullPlayer = false
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
})
|
||||
.offset(y: max(fullPlayerDragOffset, 0))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
if value.translation.height > 0 {
|
||||
fullPlayerDragOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let velocity = value.predictedEndTranslation.height - value.translation.height
|
||||
if value.translation.height > 120 || velocity > 400 {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
showFullPlayer = false
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
.onPreferenceChange(HideMiniPlayerKey.self) { hide in
|
||||
readerIsActive = hide
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ios/LibNovel/LibNovel/Extensions/Color+App.swift
Normal file
10
ios/LibNovel/LibNovel/Extensions/Color+App.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
||||
extension Color {
|
||||
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
static var amber: Color { .amber }
|
||||
}
|
||||
168
ios/LibNovel/LibNovel/Extensions/NavDestination.swift
Normal file
168
ios/LibNovel/LibNovel/Extensions/NavDestination.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Navigation destination enum used across all tabs
|
||||
|
||||
enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
case userProfile(String) // username
|
||||
case browseCategory(sort: String, genre: String, status: String, title: String) // Browse with filters
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
|
||||
extension View {
|
||||
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||
func appNavigationDestination() -> some View {
|
||||
modifier(AppNavigationDestinationModifier())
|
||||
}
|
||||
|
||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||
/// Dismissing the alert sets the binding back to nil.
|
||||
/// Silently suppresses network errors when offline (banner shows instead).
|
||||
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||
self.modifier(ErrorAlertModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Alert Modifier
|
||||
|
||||
private struct ErrorAlertModifier: ViewModifier {
|
||||
@Binding var error: String?
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
private var shouldShowAlert: Bool {
|
||||
guard let errorMessage = error else { return false }
|
||||
|
||||
// If offline, suppress common network error messages
|
||||
if !networkMonitor.isConnected {
|
||||
let networkKeywords = [
|
||||
"internet",
|
||||
"offline",
|
||||
"network",
|
||||
"connection",
|
||||
"unreachable",
|
||||
"timed out",
|
||||
"no data"
|
||||
]
|
||||
|
||||
let lowercased = errorMessage.lowercased()
|
||||
let isNetworkError = networkKeywords.contains { lowercased.contains($0) }
|
||||
|
||||
if isNetworkError {
|
||||
// Clear the error silently
|
||||
DispatchQueue.main.async {
|
||||
self.error = nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { shouldShowAlert },
|
||||
set: { if !$0 { error = nil } }
|
||||
)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation destination modifier
|
||||
|
||||
private struct AppNavigationDestinationModifier: ViewModifier {
|
||||
@Namespace private var zoomNamespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug):
|
||||
BookDetailView(slug: slug)
|
||||
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||
case .chapter(let slug, let n):
|
||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username):
|
||||
UserProfileView(username: username)
|
||||
case .browseCategory(let sort, let genre, let status, let title):
|
||||
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||
}
|
||||
}
|
||||
// Expose namespace to child views via environment
|
||||
.environment(\.bookZoomNamespace, zoomNamespace)
|
||||
} else {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username): UserProfileView(username: username)
|
||||
case .browseCategory(let sort, let genre, let status, let title):
|
||||
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment key for zoom namespace
|
||||
|
||||
struct BookZoomNamespaceKey: EnvironmentKey {
|
||||
static var defaultValue: Namespace.ID? { nil }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var bookZoomNamespace: Namespace.ID? {
|
||||
get { self[BookZoomNamespaceKey.self] }
|
||||
set { self[BookZoomNamespaceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preference key: suppress mini player overlay (used by ChapterReaderView)
|
||||
|
||||
struct HideMiniPlayerKey: PreferenceKey {
|
||||
static var defaultValue = false
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||
value = value || nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Signal to the root overlay that the mini player should be hidden.
|
||||
func hideMiniPlayer() -> some View {
|
||||
preference(key: HideMiniPlayerKey.self, value: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover card zoom source modifier
|
||||
|
||||
/// Apply this to any cover image that should be a zoom source for book navigation.
|
||||
/// Falls back to a no-op on iOS 17 or when no namespace is available.
|
||||
struct BookCoverZoomSource: ViewModifier {
|
||||
let slug: String
|
||||
@Environment(\.bookZoomNamespace) private var namespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *), let ns = namespace {
|
||||
content.matchedTransitionSource(id: slug, in: ns)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Marks a cover image as the zoom source for a book's navigation transition.
|
||||
func bookCoverZoomSource(slug: String) -> some View {
|
||||
modifier(BookCoverZoomSource(slug: slug))
|
||||
}
|
||||
}
|
||||
|
||||
41
ios/LibNovel/LibNovel/Extensions/String+App.swift
Normal file
41
ios/LibNovel/LibNovel/Extensions/String+App.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - String helpers for display purposes
|
||||
|
||||
extension String {
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
|
||||
/// or "(One)4 years ago" where the number is attached without a preceding space).
|
||||
func strippingTrailingDate() -> String {
|
||||
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
||||
let lower = self.lowercased()
|
||||
for unit in units {
|
||||
for suffix in [unit + "s ago", unit + " ago"] {
|
||||
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
||||
// Everything before the suffix
|
||||
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
||||
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
||||
// Strip trailing digits (the numeric count, which may be attached without a space)
|
||||
var result = trimmed
|
||||
while let last = result.last, last.isNumber {
|
||||
result.removeLast()
|
||||
}
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
if result != trimmed {
|
||||
// We actually stripped some digits — return cleaned result
|
||||
return result
|
||||
}
|
||||
// Fallback: number preceded by space
|
||||
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
||||
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
||||
if Int(potentialNum) != nil {
|
||||
return String(trimmed[trimmed.startIndex ..< spaceIdx])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
} else if Int(trimmed) != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
395
ios/LibNovel/LibNovel/Models/Models.swift
Normal file
395
ios/LibNovel/LibNovel/Models/Models.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Book
|
||||
|
||||
struct Book: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String
|
||||
let cover: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
let summary: String
|
||||
let totalChapters: Int
|
||||
let sourceURL: String
|
||||
let ranking: Int
|
||||
let metaUpdated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, title, author, cover, status, genres, summary
|
||||
case totalChapters = "total_chapters"
|
||||
case sourceURL = "source_url"
|
||||
case ranking
|
||||
case metaUpdated = "meta_updated"
|
||||
}
|
||||
|
||||
// PocketBase returns genres as either a JSON string array or a real array
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
slug = try container.decode(String.self, forKey: .slug)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
author = try container.decode(String.self, forKey: .author)
|
||||
cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||
totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
|
||||
sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
|
||||
ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
|
||||
metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
|
||||
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
|
||||
|
||||
// genres is sometimes a JSON-encoded string, sometimes a real array
|
||||
if let arr = try? container.decode([String].self, forKey: .genres) {
|
||||
genres = arr
|
||||
} else if let str = try? container.decode(String.self, forKey: .genres),
|
||||
let data = str.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
genres = arr
|
||||
} else {
|
||||
genres = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChapterIndex
|
||||
|
||||
struct ChapterIndex: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let number: Int
|
||||
let title: String
|
||||
let dateLabel: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, number, title
|
||||
case dateLabel = "date_label"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterIndexBrief: Codable, Hashable {
|
||||
let number: Int
|
||||
let title: String
|
||||
}
|
||||
|
||||
// MARK: - User Settings
|
||||
|
||||
struct UserSettings: Codable {
|
||||
var id: String?
|
||||
var autoNext: Bool
|
||||
var voice: String
|
||||
var speed: Double
|
||||
|
||||
// Server sends/expects camelCase: { autoNext, voice, speed }
|
||||
// (No CodingKeys needed — Swift synthesises the same names by default)
|
||||
|
||||
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Reading Display Settings (local only — stored in UserDefaults)
|
||||
|
||||
enum ReaderTheme: String, CaseIterable, Codable {
|
||||
case white, sepia, night
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
||||
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
||||
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 0.1, opacity: 1)
|
||||
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
||||
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .white: return nil // follows system
|
||||
case .sepia: return .light
|
||||
case .night: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderFont: String, CaseIterable, Codable {
|
||||
case system = "System"
|
||||
case georgia = "Georgia"
|
||||
case newYork = "New York"
|
||||
|
||||
var fontName: String? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .georgia: return "Georgia"
|
||||
case .newYork: return "NewYorkMedium-Regular"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderSettings: Codable, Equatable {
|
||||
var fontSize: CGFloat
|
||||
var lineSpacing: CGFloat
|
||||
var font: ReaderFont
|
||||
var theme: ReaderTheme
|
||||
var scrollMode: Bool
|
||||
|
||||
static let `default` = ReaderSettings(
|
||||
fontSize: 17,
|
||||
lineSpacing: 1.7,
|
||||
font: .system,
|
||||
theme: .white,
|
||||
scrollMode: false
|
||||
)
|
||||
|
||||
static let userDefaultsKey = "readerSettings"
|
||||
|
||||
static func load() -> ReaderSettings {
|
||||
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||
else { return .default }
|
||||
return decoded
|
||||
}
|
||||
|
||||
func save() {
|
||||
if let data = try? JSONEncoder().encode(self) {
|
||||
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User
|
||||
|
||||
struct AppUser: Codable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let role: String
|
||||
let created: String
|
||||
let avatarURL: String?
|
||||
|
||||
var isAdmin: Bool { role == "admin" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, role, created
|
||||
case avatarURL = "avatar_url"
|
||||
}
|
||||
|
||||
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.role = role
|
||||
self.created = created
|
||||
self.avatarURL = avatarURL
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ranking
|
||||
|
||||
struct RankingItem: Codable, Identifiable {
|
||||
var id: String { slug }
|
||||
let rank: Int
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String
|
||||
let cover: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
let sourceURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case rank, slug, title, author, cover, status, genres
|
||||
case sourceURL = "source_url"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
struct ContinueReadingItem: Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let chapter: Int
|
||||
}
|
||||
|
||||
struct HomeStats: Codable {
|
||||
let totalBooks: Int
|
||||
let totalChapters: Int
|
||||
let booksInProgress: Int
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
struct UserSession: Codable, Identifiable {
|
||||
let id: String
|
||||
let userAgent: String
|
||||
let ip: String
|
||||
let createdAt: String
|
||||
let lastSeen: String
|
||||
var isCurrent: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userAgent = "user_agent"
|
||||
case ip
|
||||
case createdAt = "created_at"
|
||||
case lastSeen = "last_seen"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
}
|
||||
|
||||
struct PreviewChapter: Codable, Identifiable {
|
||||
var id: Int { number }
|
||||
let number: Int
|
||||
let title: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
struct BookBrief: Codable {
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
struct BookComment: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let userId: String
|
||||
let username: String
|
||||
let body: String
|
||||
var upvotes: Int
|
||||
var downvotes: Int
|
||||
let created: String
|
||||
let parentId: String // empty = top-level; non-empty = reply
|
||||
var replies: [BookComment]? // populated client-side from the API response
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, username, body, upvotes, downvotes, created, replies
|
||||
case userId = "user_id"
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
|
||||
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
|
||||
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
|
||||
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsResponse: Decodable {
|
||||
let comments: [BookComment]
|
||||
let myVotes: [String: String]
|
||||
let avatarUrls: [String: String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case comments
|
||||
case myVotes = "myVotes"
|
||||
case avatarUrls = "avatarUrls"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
comments = try c.decode([BookComment].self, forKey: .comments)
|
||||
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
|
||||
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Profile (public)
|
||||
|
||||
struct PublicUserProfile: Decodable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let avatarUrl: String?
|
||||
let created: String
|
||||
let followerCount: Int
|
||||
let followingCount: Int
|
||||
let isSubscribed: Bool
|
||||
let isSelf: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, created
|
||||
case avatarUrl = "avatarUrl"
|
||||
case followerCount = "followerCount"
|
||||
case followingCount = "followingCount"
|
||||
case isSubscribed = "isSubscribed"
|
||||
case isSelf = "isSelf"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
|
||||
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
|
||||
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Feed
|
||||
|
||||
struct SubscriptionFeedItem: Identifiable, Decodable {
|
||||
var id: String { book.id + readerUsername }
|
||||
let book: Book
|
||||
let readerUsername: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case readerUsername = "readerUsername"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public User Library
|
||||
|
||||
struct PublicLibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let lastChapter: Int?
|
||||
let saved: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case lastChapter = "last_chapter"
|
||||
case saved
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicUserLibraryResponse: Decodable {
|
||||
let currentlyReading: [PublicLibraryItem]
|
||||
let library: [PublicLibraryItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentlyReading = "currently_reading"
|
||||
case library
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
enum NextPrefetchStatus {
|
||||
case none, prefetching, prefetched, failed
|
||||
}
|
||||
580
ios/LibNovel/LibNovel/Networking/APIClient.swift
Normal file
580
ios/LibNovel/LibNovel/Networking/APIClient.swift
Normal file
@@ -0,0 +1,580 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Client
|
||||
// Communicates with the SvelteKit UI server (not directly with the Go scraper).
|
||||
// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning.
|
||||
// For the iOS app we talk to the same /api/* endpoints the web UI uses,
|
||||
// so we reuse the exact same HMAC-cookie auth flow.
|
||||
|
||||
actor APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
var baseURL: URL
|
||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||
|
||||
// URLSession with persistent cookie storage
|
||||
private let session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
private init() {
|
||||
// Default: point at the UI server. Override via Settings bundle or compile flag.
|
||||
let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
baseURL = URL(string: urlString)!
|
||||
}
|
||||
|
||||
// MARK: - Auth cookie management
|
||||
|
||||
func setAuthCookie(_ value: String?) {
|
||||
authCookie = value
|
||||
if let value {
|
||||
// Also inject into shared cookie storage so redirects carry the cookie
|
||||
let cookieProps: [HTTPCookiePropertyKey: Any] = [
|
||||
.name: "libnovel_auth",
|
||||
.value: value,
|
||||
.domain: baseURL.host ?? "localhost",
|
||||
.path: "/"
|
||||
]
|
||||
if let cookie = HTTPCookie(properties: cookieProps) {
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
} else {
|
||||
// Clear
|
||||
let cookieStorage = HTTPCookieStorage.shared
|
||||
cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Low-level request builder
|
||||
|
||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||
// Build URL by appending the path string directly to the base URL string.
|
||||
// appendingPathComponent() percent-encodes slashes, which breaks multi-segment
|
||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// MARK: - Generic fetch
|
||||
|
||||
func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `fetch` but discards the response body — use for endpoints that return 204 No Content.
|
||||
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let token: String
|
||||
let user: AppUser
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> LoginResponse {
|
||||
try await fetch("/api/auth/login", method: "POST",
|
||||
body: LoginRequest(username: username, password: password))
|
||||
}
|
||||
|
||||
func register(username: String, password: String) async throws -> LoginResponse {
|
||||
try await fetch("/api/auth/register", method: "POST",
|
||||
body: LoginRequest(username: username, password: password))
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||
setAuthCookie(nil)
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
func homeData() async throws -> HomeDataResponse {
|
||||
try await fetch("/api/home")
|
||||
}
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
func library() async throws -> [LibraryItem] {
|
||||
try await fetch("/api/library")
|
||||
}
|
||||
|
||||
func saveBook(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST")
|
||||
}
|
||||
|
||||
func unsaveBook(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Book Detail
|
||||
|
||||
func bookDetail(slug: String) async throws -> BookDetailResponse {
|
||||
try await fetch("/api/book/\(slug)")
|
||||
}
|
||||
|
||||
// MARK: - Chapter
|
||||
|
||||
func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse {
|
||||
try await fetch("/api/chapter/\(slug)/\(chapter)")
|
||||
}
|
||||
|
||||
// MARK: - Browse
|
||||
|
||||
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
||||
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||
return try await fetch("/api/browse-page\(query)")
|
||||
}
|
||||
|
||||
func search(query: String) async throws -> SearchResponse {
|
||||
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
||||
return try await fetch("/api/search?q=\(encoded)")
|
||||
}
|
||||
|
||||
func ranking() async throws -> [RankingItem] {
|
||||
try await fetch("/api/ranking")
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
func progress() async throws -> [ProgressEntry] {
|
||||
try await fetch("/api/progress")
|
||||
}
|
||||
|
||||
func setProgress(slug: String, chapter: Int) async throws {
|
||||
struct Body: Encodable { let chapter: Int }
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
||||
}
|
||||
|
||||
func deleteProgress(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
|
||||
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||
return r.audioTime
|
||||
}
|
||||
|
||||
func setAudioTime(slug: String, chapter: Int, time: Double) async throws {
|
||||
struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double; enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } }
|
||||
let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time))
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse {
|
||||
struct Body: Encodable { let voice: String; let speed: Double }
|
||||
return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed))
|
||||
}
|
||||
|
||||
/// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed.
|
||||
/// Returns the presigned/proxy URL on success, throws on failure or cancellation.
|
||||
func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||
let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)"
|
||||
struct StatusResponse: Decodable {
|
||||
let status: String
|
||||
let url: String?
|
||||
let error: String?
|
||||
}
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let r: StatusResponse = try await fetch(path)
|
||||
switch r.status {
|
||||
case "done":
|
||||
guard let url = r.url, !url.isEmpty else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
return url
|
||||
case "failed":
|
||||
throw NSError(
|
||||
domain: "AudioGeneration",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]
|
||||
)
|
||||
default:
|
||||
// pending / generating / idle — keep polling
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||
struct Response: Decodable { let url: String }
|
||||
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)")
|
||||
return r.url
|
||||
}
|
||||
|
||||
func presignVoiceSample(voice: String) async throws -> String {
|
||||
struct Response: Decodable { let url: String }
|
||||
let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)")
|
||||
return r.url
|
||||
}
|
||||
|
||||
func voices() async throws -> [String] {
|
||||
struct Response: Decodable { let voices: [String] }
|
||||
let r: Response = try await fetch("/api/voices")
|
||||
return r.voices
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func settings() async throws -> UserSettings {
|
||||
try await fetch("/api/settings")
|
||||
}
|
||||
|
||||
func updateSettings(_ settings: UserSettings) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings)
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func sessions() async throws -> [UserSession] {
|
||||
struct Response: Decodable { let sessions: [UserSession] }
|
||||
let r: Response = try await fetch("/api/sessions")
|
||||
return r.sessions
|
||||
}
|
||||
|
||||
func revokeSession(id: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
struct AvatarPresignResponse: Decodable {
|
||||
let uploadURL: String
|
||||
let key: String
|
||||
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||
}
|
||||
|
||||
struct AvatarResponse: Decodable {
|
||||
let avatarURL: String?
|
||||
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||
}
|
||||
|
||||
/// Upload a profile avatar using a two-step presigned PUT flow:
|
||||
/// 1. POST /api/profile/avatar → get a presigned PUT URL + object key
|
||||
/// 2. PUT image bytes directly to MinIO via the presigned URL
|
||||
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
|
||||
/// Returns the presigned GET URL for the uploaded avatar.
|
||||
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||
// Step 1: request a presigned PUT URL from the SvelteKit server
|
||||
let presign: AvatarPresignResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "POST",
|
||||
body: ["mime_type": mimeType]
|
||||
)
|
||||
|
||||
// Step 2: PUT the image bytes directly to MinIO
|
||||
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||
var putReq = URLRequest(url: putURL)
|
||||
putReq.httpMethod = "PUT"
|
||||
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
putReq.httpBody = imageData
|
||||
|
||||
let (_, putResp) = try await session.data(for: putReq)
|
||||
guard let putHttp = putResp as? HTTPURLResponse,
|
||||
(200..<300).contains(putHttp.statusCode) else {
|
||||
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
throw APIError.httpError(code, "MinIO PUT failed")
|
||||
}
|
||||
|
||||
// Step 3: record the key in PocketBase and get back a presigned GET URL
|
||||
let result: AvatarResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "PATCH",
|
||||
body: ["key": presign.key]
|
||||
)
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
/// Fetches a fresh presigned GET URL for the current user's avatar.
|
||||
/// Returns nil if the user has no avatar set.
|
||||
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
|
||||
func fetchAvatarPresignedURL() async throws -> String? {
|
||||
let result: AvatarResponse = try await fetch("/api/profile/avatar")
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
// MARK: - User Profiles & Subscriptions
|
||||
|
||||
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
|
||||
try await fetch("/api/users/\(username)")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func unsubscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
|
||||
try await fetch("/api/users/\(username)/library")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||
try await fetch("/api/comments/\(slug)?sort=\(sort)")
|
||||
}
|
||||
|
||||
struct PostCommentBody: Encodable {
|
||||
let body: String
|
||||
let parent_id: String?
|
||||
}
|
||||
|
||||
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
|
||||
}
|
||||
|
||||
struct VoteBody: Encodable { let vote: String }
|
||||
|
||||
/// Cast, change, or toggle-off a vote on a comment.
|
||||
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
}
|
||||
|
||||
/// Delete a comment (and its replies) by ID. Only the owner can delete.
|
||||
func deleteComment(commentId: String) async throws {
|
||||
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response types
|
||||
|
||||
struct HomeDataResponse: Decodable {
|
||||
struct ContinueItem: Decodable {
|
||||
let book: Book
|
||||
let chapter: Int
|
||||
}
|
||||
let continueReading: [ContinueItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
let subscriptionFeed: [SubscriptionFeedItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case continueReading = "continue_reading"
|
||||
case recentlyUpdated = "recently_updated"
|
||||
case stats
|
||||
case subscriptionFeed = "subscription_feed"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
|
||||
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
|
||||
stats = try c.decode(HomeStats.self, forKey: .stats)
|
||||
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let savedAt: String
|
||||
let lastChapter: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case savedAt = "saved_at"
|
||||
case lastChapter = "last_chapter"
|
||||
}
|
||||
}
|
||||
|
||||
struct BookDetailResponse: Decodable {
|
||||
let book: Book
|
||||
let chapters: [ChapterIndex]
|
||||
let previewChapters: [PreviewChapter]?
|
||||
let inLib: Bool
|
||||
let saved: Bool
|
||||
let lastChapter: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book, chapters
|
||||
case previewChapters = "preview_chapters"
|
||||
case inLib = "in_lib"
|
||||
case saved
|
||||
case lastChapter = "last_chapter"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterResponse: Decodable {
|
||||
let book: BookBrief
|
||||
let chapter: ChapterIndex
|
||||
let html: String
|
||||
let voices: [String]
|
||||
let prev: Int?
|
||||
let next: Int?
|
||||
let chapters: [ChapterIndexBrief]
|
||||
let isPreview: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book, chapter, html, voices, prev, next, chapters
|
||||
case isPreview = "is_preview"
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowseResponse: Decodable {
|
||||
let novels: [BrowseNovel]
|
||||
let page: Int
|
||||
let hasNext: Bool
|
||||
}
|
||||
|
||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||
var id: String { slug.isEmpty ? url : slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
let rank: String
|
||||
let rating: String
|
||||
let chapters: String
|
||||
let url: String
|
||||
let author: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, cover, rank, rating, chapters, url, author, status, genres
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||
rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? ""
|
||||
rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? ""
|
||||
chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? ""
|
||||
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||
genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResponse: Decodable {
|
||||
let results: [BrowseNovel]
|
||||
let localCount: Int
|
||||
let remoteCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case results
|
||||
case localCount = "local_count"
|
||||
case remoteCount = "remote_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned by POST /api/audio/{slug}/{n}.
|
||||
/// - 202 Accepted: job enqueued → poll via pollAudioStatus()
|
||||
/// - 200 OK: audio already cached → url is ready to play
|
||||
struct AudioTriggerResponse: Decodable {
|
||||
let jobId: String? // present on 202
|
||||
let status: String? // present on 202: "pending" | "generating"
|
||||
let url: String? // present on 200: proxy URL ready to play
|
||||
let filename: String? // present on 200
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobId = "job_id"
|
||||
case status, url, filename
|
||||
}
|
||||
|
||||
/// True when the server accepted the request and created an async job.
|
||||
var isAsync: Bool { jobId != nil }
|
||||
}
|
||||
|
||||
struct ProgressEntry: Decodable, Identifiable {
|
||||
var id: String { slug }
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let audioTime: Double?
|
||||
let updated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, chapter, updated
|
||||
case audioTime = "audio_time"
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
|
||||
// MARK: - API Error
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(Int, String)
|
||||
case decodingError(Error)
|
||||
case unauthorized
|
||||
case networkError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "Invalid server response"
|
||||
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
|
||||
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
|
||||
case .unauthorized: return "Not authenticated"
|
||||
case .networkError(let e): return e.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSONDecoder helper
|
||||
|
||||
extension JSONDecoder {
|
||||
static let iso8601: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
45
ios/LibNovel/LibNovel/Resources/Info.plist
Normal file
45
ios/LibNovel/LibNovel/Resources/Info.plist
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1000</string>
|
||||
<key>LIBNOVEL_BASE_URL</key>
|
||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
318
ios/LibNovel/LibNovel/Services/AudioDownloadService.swift
Normal file
318
ios/LibNovel/LibNovel/Services/AudioDownloadService.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - AudioDownloadService
|
||||
// Manages offline TTS audio downloads with progress tracking and persistent storage.
|
||||
// Downloads are saved to the app's Documents directory, organized by slug/chapter/voice.
|
||||
|
||||
@MainActor
|
||||
final class AudioDownloadService: NSObject, ObservableObject {
|
||||
static let shared = AudioDownloadService()
|
||||
|
||||
// MARK: - Published State
|
||||
|
||||
@Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice"
|
||||
@Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice"
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var session: URLSession!
|
||||
private var activeTasks: [String: URLSessionDownloadTask] = [:]
|
||||
private let fileManager = FileManager.default
|
||||
private let metadataKey = "downloadedChaptersMetadata"
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "cc.kalekber.libnovel.audio-downloads")
|
||||
config.isDiscretionary = false
|
||||
config.sessionSendsLaunchEvents = true
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
loadMetadata()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Check if a chapter's audio is downloaded offline
|
||||
func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
return downloadedChapters.contains(key)
|
||||
}
|
||||
|
||||
/// Get the local file URL for a downloaded chapter (nil if not downloaded)
|
||||
func localURL(slug: String, chapter: Int, voice: String) -> URL? {
|
||||
guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil }
|
||||
return audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
/// Start downloading a chapter's audio
|
||||
func download(slug: String, chapter: Int, voice: String) async throws {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
print("📥 AudioDownload: Starting download - slug: \(slug), chapter: \(chapter), voice: \(voice)")
|
||||
|
||||
// Already downloaded or in progress
|
||||
if downloadedChapters.contains(key) {
|
||||
print("⚠️ AudioDownload: Already downloaded - key: \(key)")
|
||||
return
|
||||
}
|
||||
if activeTasks[key] != nil {
|
||||
print("⚠️ AudioDownload: Already in progress - key: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
// Get presigned URL from API
|
||||
print("🔗 AudioDownload: Fetching presigned URL...")
|
||||
let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice)
|
||||
guard let url = URL(string: urlString) else {
|
||||
print("❌ AudioDownload: Invalid URL - \(urlString)")
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
print("🔗 AudioDownload: Presigned URL obtained: \(url.absoluteString)")
|
||||
|
||||
// Create download task
|
||||
let task = session.downloadTask(with: url)
|
||||
task.taskDescription = key // Use taskDescription to identify the download
|
||||
activeTasks[key] = task
|
||||
|
||||
// Initialize progress tracking
|
||||
downloads[key] = DownloadProgress(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
voice: voice,
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
downloadedBytes: 0,
|
||||
status: .downloading
|
||||
)
|
||||
|
||||
print("🚀 AudioDownload: Starting download task - key: \(key)")
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/// Cancel an ongoing download
|
||||
func cancelDownload(slug: String, chapter: Int, voice: String) {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
activeTasks[key]?.cancel()
|
||||
activeTasks.removeValue(forKey: key)
|
||||
downloads.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
/// Delete a downloaded chapter
|
||||
func deleteDownload(slug: String, chapter: Int, voice: String) throws {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
downloadedChapters.remove(key)
|
||||
downloads.removeValue(forKey: key)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
/// Get total storage used by downloads (in bytes)
|
||||
func getTotalStorageUsed() -> Int64 {
|
||||
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let audioDir = documentsURL.appendingPathComponent("audio")
|
||||
guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
var totalSize: Int64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
|
||||
/// Delete all downloads
|
||||
func deleteAllDownloads() throws {
|
||||
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
return
|
||||
}
|
||||
|
||||
let audioDir = documentsURL.appendingPathComponent("audio")
|
||||
if fileManager.fileExists(atPath: audioDir.path) {
|
||||
try fileManager.removeItem(at: audioDir)
|
||||
}
|
||||
|
||||
downloadedChapters.removeAll()
|
||||
downloads.removeAll()
|
||||
activeTasks.values.forEach { $0.cancel() }
|
||||
activeTasks.removeAll()
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
/// Get list of all book slugs that have offline downloads
|
||||
func getOfflineBookSlugs() -> [String] {
|
||||
let slugs = downloadedChapters.compactMap { key -> String? in
|
||||
let components = key.split(separator: "::")
|
||||
guard components.count == 3 else { return nil }
|
||||
return String(components[0])
|
||||
}
|
||||
return Array(Set(slugs)).sorted()
|
||||
}
|
||||
|
||||
/// Get count of downloaded chapters for a specific book
|
||||
func getDownloadedChapterCount(for slug: String) -> Int {
|
||||
return downloadedChapters.filter { key in
|
||||
let components = key.split(separator: "::")
|
||||
guard components.count == 3 else { return false }
|
||||
return String(components[0]) == slug
|
||||
}.count
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Build the canonical download key used for both in-memory tracking and UserDefaults.
|
||||
/// Uses `::` as separator so slugs that contain `-` are unambiguous.
|
||||
func makeKey(slug: String, chapter: Int, voice: String) -> String {
|
||||
"\(slug)::\(chapter)::\(voice)"
|
||||
}
|
||||
|
||||
nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL {
|
||||
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
fatalError("Could not access documents directory")
|
||||
}
|
||||
|
||||
return documentsURL
|
||||
.appendingPathComponent("audio")
|
||||
.appendingPathComponent(slug)
|
||||
.appendingPathComponent("\(chapter)-\(voice).mp3")
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
if let data = UserDefaults.standard.data(forKey: metadataKey),
|
||||
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||
downloadedChapters = decoded
|
||||
}
|
||||
}
|
||||
|
||||
private func saveMetadata() {
|
||||
if let encoded = try? JSONEncoder().encode(downloadedChapters) {
|
||||
UserDefaults.standard.set(encoded, forKey: metadataKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDownloadDelegate
|
||||
|
||||
extension AudioDownloadService: URLSessionDownloadDelegate {
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
guard let key = downloadTask.taskDescription else {
|
||||
print("⚠️ AudioDownload: No task description")
|
||||
return
|
||||
}
|
||||
|
||||
print("✅ AudioDownload: Finished downloading - key: \(key)")
|
||||
|
||||
let components = key.split(separator: "::")
|
||||
guard components.count == 3,
|
||||
let chapter = Int(components[1]) else {
|
||||
print("⚠️ AudioDownload: Invalid key format: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
let slug = String(components[0])
|
||||
let voice = String(components[2])
|
||||
|
||||
let destinationURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
print("📁 AudioDownload: Moving from \(location.path) to \(destinationURL.path)")
|
||||
|
||||
do {
|
||||
// Create directory if needed
|
||||
let directory = destinationURL.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
// Move file from temp location to permanent storage
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
print("📁 AudioDownload: Removing existing file at destination")
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
try fileManager.moveItem(at: location, to: destinationURL)
|
||||
|
||||
print("✅ AudioDownload: File moved successfully")
|
||||
|
||||
Task { @MainActor in
|
||||
print("✅ AudioDownload: Marking as completed - key: \(key)")
|
||||
self.downloadedChapters.insert(key)
|
||||
self.downloads.removeValue(forKey: key) // Remove from active downloads
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
self.saveMetadata()
|
||||
print("✅ AudioDownload: Metadata saved, downloadedChapters count: \(self.downloadedChapters.count)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ AudioDownload: Failed to move file - \(error.localizedDescription)")
|
||||
Task { @MainActor in
|
||||
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
|
||||
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||||
|
||||
if Int(progress * 100) % 10 == 0 { // Log every 10%
|
||||
print("📊 AudioDownload: Progress for \(key): \(Int(progress * 100))% (\(totalBytesWritten)/\(totalBytesExpectedToWrite) bytes)")
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
if var progressData = self.downloads[key] {
|
||||
progressData.downloadedBytes = totalBytesWritten
|
||||
progressData.totalBytes = totalBytesExpectedToWrite
|
||||
progressData.progress = progress
|
||||
self.downloads[key] = progressData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let key = task.taskDescription else { return }
|
||||
|
||||
if let error = error {
|
||||
let nsError = error as NSError
|
||||
if nsError.code != NSURLErrorCancelled {
|
||||
print("❌ AudioDownload: Task completed with error - key: \(key), error: \(error.localizedDescription)")
|
||||
Task { @MainActor in
|
||||
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
}
|
||||
} else {
|
||||
print("⚠️ AudioDownload: Task cancelled - key: \(key)")
|
||||
}
|
||||
} else {
|
||||
print("✅ AudioDownload: Task completed without error - key: \(key)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
struct DownloadProgress: Equatable {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let voice: String
|
||||
var progress: Double
|
||||
var totalBytes: Int64
|
||||
var downloadedBytes: Int64
|
||||
var status: DownloadStatus
|
||||
}
|
||||
|
||||
enum DownloadStatus: Equatable {
|
||||
case downloading
|
||||
case completed
|
||||
case failed(String)
|
||||
}
|
||||
627
ios/LibNovel/LibNovel/Services/AudioPlayerService.swift
Normal file
627
ios/LibNovel/LibNovel/Services/AudioPlayerService.swift
Normal file
@@ -0,0 +1,627 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - PlaybackProgress
|
||||
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||
// duration, isPlaying). Keeping these separate from AudioPlayerService means
|
||||
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||
// observe PlaybackProgress — menus and other stable UI are unaffected.
|
||||
|
||||
@MainActor
|
||||
final class PlaybackProgress: ObservableObject {
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - AudioPlayerService
|
||||
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
||||
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
|
||||
// next chapter audio.
|
||||
|
||||
@MainActor
|
||||
final class AudioPlayerService: ObservableObject {
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
@Published var slug: String = ""
|
||||
@Published var chapter: Int = 0
|
||||
@Published var chapterTitle: String = ""
|
||||
@Published var bookTitle: String = ""
|
||||
@Published var coverURL: String = ""
|
||||
@Published var voice: String = "af_bella"
|
||||
@Published var speed: Double = 1.0
|
||||
@Published var chapters: [ChapterIndexBrief] = []
|
||||
|
||||
@Published var status: AudioPlayerStatus = .idle
|
||||
@Published var audioURL: String = ""
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var generationProgress: Double = 0
|
||||
|
||||
/// High-frequency playback state (currentTime / duration / isPlaying).
|
||||
/// Views that only need the seek bar or play-pause button should observe
|
||||
/// this directly so they don't trigger re-renders of menu-bearing parents.
|
||||
let progress = PlaybackProgress()
|
||||
|
||||
// Convenience forwarders so non-view call sites keep compiling unchanged.
|
||||
var currentTime: Double {
|
||||
get { progress.currentTime }
|
||||
set { progress.currentTime = newValue }
|
||||
}
|
||||
var duration: Double {
|
||||
get { progress.duration }
|
||||
set { progress.duration = newValue }
|
||||
}
|
||||
var isPlaying: Bool {
|
||||
get { progress.isPlaying }
|
||||
set { progress.isPlaying = newValue }
|
||||
}
|
||||
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
/// Human-readable countdown string shown in the full player near the moon button.
|
||||
/// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off.
|
||||
@Published var sleepTimerRemainingText: String = ""
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@Published var nextPrefetchedChapter: Int? = nil
|
||||
|
||||
var isActive: Bool {
|
||||
switch status {
|
||||
case .idle: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var playerItem: AVPlayerItem?
|
||||
private var timeObserver: Any?
|
||||
private var statusObserver: AnyCancellable?
|
||||
private var durationObserver: AnyCancellable?
|
||||
private var finishObserver: AnyCancellable?
|
||||
private var generationTask: Task<Void, Never>?
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
// Cached cover image — downloaded once per chapter load, reused on every
|
||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
private var cachedCoverURL: String = ""
|
||||
|
||||
// Sleep timer tracking
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
|
||||
private var sleepTimerDeadline: Date? = nil
|
||||
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
|
||||
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
configureAudioSession()
|
||||
setupRemoteCommandCenter()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
|
||||
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||
generationTask?.cancel()
|
||||
prefetchTask?.cancel()
|
||||
stop()
|
||||
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.bookTitle = bookTitle
|
||||
self.coverURL = coverURL
|
||||
self.voice = voice
|
||||
self.speed = speed
|
||||
self.chapters = chapters
|
||||
self.nextChapter = nextChapter
|
||||
self.prevChapter = prevChapter
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
|
||||
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = chapter
|
||||
}
|
||||
|
||||
status = .generating
|
||||
generationProgress = 0
|
||||
|
||||
// Invalidate cover cache if the book changed.
|
||||
if coverURL != cachedCoverURL {
|
||||
cachedCoverArtwork = nil
|
||||
cachedCoverURL = coverURL
|
||||
prefetchCoverArtwork(from: coverURL)
|
||||
}
|
||||
|
||||
generationTask = Task { await generateAudio() }
|
||||
}
|
||||
|
||||
func play() {
|
||||
player?.play()
|
||||
player?.rate = Float(speed)
|
||||
isPlaying = true
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
let time = CMTime(seconds: seconds, preferredTimescale: 600)
|
||||
currentTime = seconds // optimistic UI update
|
||||
player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.updateNowPlaying() }
|
||||
}
|
||||
}
|
||||
|
||||
func skip(by seconds: Double) {
|
||||
seek(to: max(0, min(currentTime + seconds, duration)))
|
||||
}
|
||||
|
||||
func setSpeed(_ newSpeed: Double) {
|
||||
speed = newSpeed
|
||||
if isPlaying { player?.rate = Float(newSpeed) }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
// Cancel existing timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else {
|
||||
sleepTimerRemainingText = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Start timer based on option
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
// Update display immediately; chapter changes are tracked in handlePlaybackFinished.
|
||||
updateChapterTimerLabel(chaptersRemaining: count)
|
||||
|
||||
case .minutes(let minutes):
|
||||
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||
sleepTimerDeadline = deadline
|
||||
// Stop playback when the deadline is reached.
|
||||
sleepTimerTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.stop()
|
||||
self.sleepTimer = nil
|
||||
self.sleepTimerRemainingText = ""
|
||||
}
|
||||
}
|
||||
// 1-second tick to keep the countdown label fresh.
|
||||
sleepTimerCountdownTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let deadline = self.sleepTimerDeadline else { return }
|
||||
let remaining = max(0, deadline.timeIntervalSinceNow)
|
||||
self.sleepTimerRemainingText = Self.formatCountdown(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set initial label without waiting for the first tick.
|
||||
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChapterTimerLabel(chaptersRemaining: Int) {
|
||||
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
|
||||
}
|
||||
|
||||
private static func formatCountdown(_ seconds: Double) -> String {
|
||||
let s = Int(max(0, seconds))
|
||||
let m = s / 60
|
||||
let sec = s % 60
|
||||
return "\(m):\(String(format: "%02d", sec))"
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
teardownPlayer()
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
|
||||
// Cancel sleep timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
sleepTimer = nil
|
||||
sleepTimerRemainingText = ""
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
|
||||
private func generateAudio() async {
|
||||
guard !slug.isEmpty, chapter > 0 else { return }
|
||||
|
||||
// Check if audio is downloaded locally first
|
||||
if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) {
|
||||
audioURL = localURL.absoluteString
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(localURL.absoluteString)
|
||||
await prefetchNext()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Fast path: audio already in MinIO — get a presigned URL and play immediately.
|
||||
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
|
||||
audioURL = presignedURL
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(presignedURL)
|
||||
await prefetchNext()
|
||||
return
|
||||
}
|
||||
|
||||
// Slow path: trigger TTS generation (async — returns 202 immediately).
|
||||
status = .generating
|
||||
generationProgress = 10
|
||||
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
|
||||
|
||||
let playableURL: String
|
||||
if trigger.isAsync {
|
||||
// 202 Accepted: poll until done.
|
||||
generationProgress = 30
|
||||
playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice)
|
||||
} else {
|
||||
// 200: already cached URL returned inline.
|
||||
guard let url = trigger.url, !url.isEmpty else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
playableURL = url
|
||||
}
|
||||
|
||||
audioURL = playableURL
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(playableURL)
|
||||
await prefetchNext()
|
||||
} catch is CancellationError {
|
||||
// Cancelled — no-op
|
||||
} catch {
|
||||
status = .error(error.localizedDescription)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prefetch next chapter
|
||||
// Always prefetch regardless of autoNext — faster playback when the user
|
||||
// manually navigates forward. autoNext only controls whether we auto-navigate.
|
||||
|
||||
private func prefetchNext() async {
|
||||
guard let next = nextChapter, !Task.isCancelled else { return }
|
||||
nextPrefetchStatus = .prefetching
|
||||
nextPrefetchedChapter = next
|
||||
do {
|
||||
// Fast path: already in MinIO.
|
||||
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) {
|
||||
nextAudioURL = presignedURL
|
||||
nextPrefetchStatus = .prefetched
|
||||
return
|
||||
}
|
||||
// Slow path: trigger generation; poll until done (background — won't block playback).
|
||||
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
|
||||
let url: String
|
||||
if trigger.isAsync {
|
||||
url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice)
|
||||
} else {
|
||||
guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) }
|
||||
url = u
|
||||
}
|
||||
nextAudioURL = url
|
||||
nextPrefetchStatus = .prefetched
|
||||
} catch {
|
||||
nextPrefetchStatus = .failed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayer management
|
||||
|
||||
private func playURL(_ urlString: String) async {
|
||||
// Resolve relative paths (e.g. "/api/audio/...") to absolute URLs.
|
||||
let resolved: URL?
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||
resolved = URL(string: urlString)
|
||||
} else {
|
||||
resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL
|
||||
}
|
||||
guard let url = resolved else { return }
|
||||
teardownPlayer()
|
||||
let item = AVPlayerItem(url: url)
|
||||
playerItem = item
|
||||
player = AVPlayer(playerItem: item)
|
||||
|
||||
// KVO: update duration as soon as asset metadata is loaded.
|
||||
durationObserver = item.publisher(for: \.duration)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] dur in
|
||||
guard let self else { return }
|
||||
let secs = dur.seconds
|
||||
if secs.isFinite && secs > 0 {
|
||||
self.duration = secs
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// KVO: set playback rate once the item is ready.
|
||||
// Do NOT call player?.play() unconditionally — let readyToPlay trigger it
|
||||
// so we don't race between AVPlayer's internal buffering and our call.
|
||||
statusObserver = item.publisher(for: \.status)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] itemStatus in
|
||||
guard let self else { return }
|
||||
if itemStatus == .readyToPlay {
|
||||
self.player?.rate = Float(self.speed)
|
||||
self.isPlaying = true
|
||||
self.updateNowPlaying()
|
||||
} else if itemStatus == .failed {
|
||||
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
|
||||
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic time observer for seek bar position.
|
||||
timeObserver = player?.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
|
||||
queue: .main
|
||||
) { [weak self] time in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let secs = time.seconds
|
||||
if secs.isFinite && secs >= 0 {
|
||||
self.currentTime = secs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe when playback ends.
|
||||
finishObserver = NotificationCenter.default
|
||||
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.handlePlaybackFinished()
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off buffering — actual playback starts via statusObserver above.
|
||||
player?.play()
|
||||
}
|
||||
|
||||
private func teardownPlayer() {
|
||||
if let observer = timeObserver { player?.removeTimeObserver(observer) }
|
||||
timeObserver = nil
|
||||
statusObserver = nil
|
||||
durationObserver = nil
|
||||
finishObserver = nil
|
||||
player = nil
|
||||
playerItem = nil
|
||||
}
|
||||
|
||||
private func handlePlaybackFinished() {
|
||||
isPlaying = false
|
||||
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Check chapter-based sleep timer
|
||||
if case .chapters(let count) = sleepTimer {
|
||||
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||
if chaptersPlayed >= count {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
// Update the remaining chapters label.
|
||||
let remaining = count - chaptersPlayed
|
||||
updateChapterTimerLabel(chaptersRemaining: remaining)
|
||||
}
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
NotificationCenter.default.post(
|
||||
name: .audioDidFinishChapter,
|
||||
object: nil,
|
||||
userInfo: ["next": next, "autoNext": autoNext]
|
||||
)
|
||||
|
||||
// If autoNext is on, load the next chapter internally right away.
|
||||
// We already have the metadata in `chapters`, so we can reconstruct
|
||||
// everything without waiting for the view to navigate.
|
||||
guard autoNext else { return }
|
||||
|
||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
|
||||
|
||||
// If we already prefetched a URL for the next chapter, skip straight to
|
||||
// playback and kick off generation in the background for the one after.
|
||||
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
|
||||
let url = nextAudioURL
|
||||
|
||||
// Advance state before tearing down the current player.
|
||||
chapter = next
|
||||
chapterTitle = nextTitle
|
||||
nextChapter = nextNextChapter
|
||||
prevChapter = nextPrevChapter
|
||||
nextPrefetchStatus = .none
|
||||
nextAudioURL = ""
|
||||
nextPrefetchedChapter = nil
|
||||
audioURL = url
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
|
||||
// Update sleep timer start chapter if using chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = next
|
||||
}
|
||||
|
||||
generationTask = Task {
|
||||
await playURL(url)
|
||||
await prefetchNext()
|
||||
}
|
||||
} else {
|
||||
// No prefetch available — do a full load.
|
||||
load(
|
||||
slug: slug,
|
||||
chapter: next,
|
||||
chapterTitle: nextTitle,
|
||||
bookTitle: bookTitle,
|
||||
coverURL: coverURL,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: nextNextChapter,
|
||||
prevChapter: nextPrevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover art prefetch
|
||||
|
||||
private func prefetchCoverArtwork(from urlString: String) {
|
||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
|
||||
guard let self else { return }
|
||||
if case .success(let value) = result {
|
||||
let image = value.image
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
Task { @MainActor in
|
||||
self.cachedCoverArtwork = artwork
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Session
|
||||
|
||||
private func configureAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen / Control Center
|
||||
|
||||
private func setupRemoteCommandCenter() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.addTarget { [weak self] _ in
|
||||
self?.play()
|
||||
return .success
|
||||
}
|
||||
center.pauseCommand.addTarget { [weak self] _ in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
center.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
self?.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.skipForwardCommand.preferredIntervals = [15]
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.skip(by: 15)
|
||||
return .success
|
||||
}
|
||||
center.skipBackwardCommand.preferredIntervals = [15]
|
||||
center.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
self?.skip(by: -15)
|
||||
return .success
|
||||
}
|
||||
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
if let e = event as? MPChangePlaybackPositionCommandEvent {
|
||||
self?.seek(to: e.positionTime)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle,
|
||||
MPMediaItemPropertyArtist: bookTitle,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
||||
MPMediaItemPropertyPlaybackDuration: duration,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0
|
||||
]
|
||||
// Use cached artwork — downloaded once in prefetchCoverArtwork().
|
||||
if let artwork = cachedCoverArtwork {
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting types
|
||||
|
||||
enum AudioPlayerStatus: Equatable {
|
||||
case idle
|
||||
case generating // covers both "loading" and "generating TTS" phases
|
||||
case ready
|
||||
case error(String)
|
||||
|
||||
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle), (.generating, .generating), (.ready, .ready):
|
||||
return true
|
||||
case (.error(let a), .error(let b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case chapters(Int) // Stop after N chapters
|
||||
case minutes(Int) // Stop after N minutes
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
|
||||
}
|
||||
159
ios/LibNovel/LibNovel/Services/AuthStore.swift
Normal file
159
ios/LibNovel/LibNovel/Services/AuthStore.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - AuthStore
|
||||
// Owns the authenticated user, the HMAC auth token, and user settings.
|
||||
// Persists the token to Keychain so the user stays logged in across launches.
|
||||
|
||||
@MainActor
|
||||
final class AuthStore: ObservableObject {
|
||||
@Published var user: AppUser?
|
||||
@Published var settings: UserSettings = .default
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: String?
|
||||
|
||||
var isAuthenticated: Bool { user != nil }
|
||||
|
||||
private let keychainKey = "libnovel_auth_token"
|
||||
|
||||
init() {
|
||||
// Restore token from Keychain and validate it on launch
|
||||
if let token = loadToken() {
|
||||
Task { await validateToken(token) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login / Register
|
||||
|
||||
func login(username: String, password: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.login(username: username, password: password)
|
||||
await APIClient.shared.setAuthCookie(response.token)
|
||||
saveToken(response.token)
|
||||
user = response.user
|
||||
await loadSettings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func register(username: String, password: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.register(username: username, password: password)
|
||||
await APIClient.shared.setAuthCookie(response.token)
|
||||
saveToken(response.token)
|
||||
user = response.user
|
||||
await loadSettings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
do {
|
||||
try await APIClient.shared.logout()
|
||||
} catch {
|
||||
// Best-effort; clear local state regardless
|
||||
}
|
||||
clearToken()
|
||||
user = nil
|
||||
settings = .default
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func loadSettings() async {
|
||||
do {
|
||||
settings = try await APIClient.shared.settings()
|
||||
} catch {
|
||||
// Use defaults if settings endpoint fails
|
||||
}
|
||||
}
|
||||
|
||||
func saveSettings(_ updated: UserSettings) async {
|
||||
do {
|
||||
try await APIClient.shared.updateSettings(updated)
|
||||
settings = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token validation
|
||||
|
||||
/// Re-validates the current session and refreshes `user` + `settings`.
|
||||
/// Call this after any operation that may change the user record (e.g. avatar upload).
|
||||
func validateToken() async {
|
||||
guard let token = loadToken() else { return }
|
||||
await validateToken(token)
|
||||
}
|
||||
|
||||
private func validateToken(_ token: String) async {
|
||||
await APIClient.shared.setAuthCookie(token)
|
||||
// Use /api/auth/me to restore the user record and confirm the token is still valid
|
||||
do {
|
||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||
async let s: UserSettings = APIClient.shared.settings()
|
||||
var (restoredUser, restoredSettings) = try await (me, s)
|
||||
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
|
||||
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
|
||||
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
||||
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
||||
restoredUser = AppUser(
|
||||
id: restoredUser.id,
|
||||
username: restoredUser.username,
|
||||
role: restoredUser.role,
|
||||
created: restoredUser.created,
|
||||
avatarURL: presignedURL
|
||||
)
|
||||
}
|
||||
}
|
||||
user = restoredUser
|
||||
settings = restoredSettings
|
||||
} catch let e as APIError {
|
||||
if case .httpError(let code, _) = e, code == 401 {
|
||||
clearToken()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// MARK: - Keychain helpers
|
||||
|
||||
private func saveToken(_ token: String) {
|
||||
let data = Data(token.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func clearToken() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
73
ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift
Normal file
73
ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Book Voice Preferences Service
|
||||
// Manages per-book voice overrides with global fallback
|
||||
|
||||
@MainActor
|
||||
final class BookVoicePreferences: ObservableObject {
|
||||
static let shared = BookVoicePreferences()
|
||||
|
||||
@Published private(set) var bookVoices: [String: String] = [:] // slug -> voice
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let storageKey = "bookVoicePreferences"
|
||||
|
||||
private init() {
|
||||
loadPreferences()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Get the voice for a specific book (returns nil if no override set)
|
||||
func voice(for slug: String) -> String? {
|
||||
return bookVoices[slug]
|
||||
}
|
||||
|
||||
/// Get the voice for a book with fallback to global user voice
|
||||
func voiceWithFallback(for slug: String, globalVoice: String) -> String {
|
||||
return bookVoices[slug] ?? globalVoice
|
||||
}
|
||||
|
||||
/// Set a voice override for a specific book
|
||||
func setVoice(_ voice: String, for slug: String) {
|
||||
print("📚 BookVoicePreferences: Setting voice '\(voice)' for book '\(slug)'")
|
||||
bookVoices[slug] = voice
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
/// Remove voice override for a book (will use global voice)
|
||||
func removeVoice(for slug: String) {
|
||||
print("📚 BookVoicePreferences: Removing voice override for book '\(slug)'")
|
||||
bookVoices.removeValue(forKey: slug)
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
/// Check if a book has a voice override
|
||||
func hasOverride(for slug: String) -> Bool {
|
||||
return bookVoices[slug] != nil
|
||||
}
|
||||
|
||||
/// Clear all book voice overrides
|
||||
func clearAll() {
|
||||
print("📚 BookVoicePreferences: Clearing all book voice overrides")
|
||||
bookVoices.removeAll()
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func loadPreferences() {
|
||||
if let data = userDefaults.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||
bookVoices = decoded
|
||||
print("📚 BookVoicePreferences: Loaded \(bookVoices.count) book voice overrides")
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
if let encoded = try? JSONEncoder().encode(bookVoices) {
|
||||
userDefaults.set(encoded, forKey: storageKey)
|
||||
print("📚 BookVoicePreferences: Saved \(bookVoices.count) book voice overrides")
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios/LibNovel/LibNovel/Services/NetworkMonitor.swift
Normal file
54
ios/LibNovel/LibNovel/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
// MARK: - Network Monitor
|
||||
// Monitors network connectivity and provides offline state across the app
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
|
||||
@Published var isConnected: Bool = true
|
||||
@Published var connectionType: NWInterface.InterfaceType?
|
||||
|
||||
private let monitor: NWPathMonitor
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
|
||||
init() {
|
||||
monitor = NWPathMonitor()
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isConnected = path.status == .satisfied
|
||||
self?.connectionType = path.availableInterfaces.first?.type
|
||||
|
||||
if path.status == .satisfied {
|
||||
print("🌐 Network: Connected (\(path.availableInterfaces.first?.type.debugDescription ?? "unknown"))")
|
||||
} else {
|
||||
print("📴 Network: Offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension NWInterface.InterfaceType {
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .wifi: return "Wi-Fi"
|
||||
case .cellular: return "Cellular"
|
||||
case .wiredEthernet: return "Ethernet"
|
||||
case .loopback: return "Loopback"
|
||||
case .other: return "Other"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift
Normal file
47
ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class BookDetailViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var book: Book?
|
||||
@Published var chapters: [ChapterIndex] = []
|
||||
@Published var saved: Bool = false
|
||||
@Published var lastChapter: Int?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let detail = try await APIClient.shared.bookDetail(slug: slug)
|
||||
book = detail.book
|
||||
chapters = detail.chapters
|
||||
saved = detail.saved
|
||||
lastChapter = detail.lastChapter
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSaved() async {
|
||||
do {
|
||||
if saved {
|
||||
try await APIClient.shared.unsaveBook(slug: slug)
|
||||
} else {
|
||||
try await APIClient.shared.saveBook(slug: slug)
|
||||
}
|
||||
saved.toggle()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
73
ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift
Normal file
73
ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class BrowseViewModel: ObservableObject {
|
||||
@Published var novels: [BrowseNovel] = []
|
||||
@Published var sort: String = "popular"
|
||||
@Published var genre: String = "all"
|
||||
@Published var status: String = "all"
|
||||
@Published var searchQuery: String = ""
|
||||
@Published var isLoading = false
|
||||
@Published var hasNext = false
|
||||
@Published var error: String?
|
||||
|
||||
private var currentPage = 1
|
||||
private var isSearchMode = false
|
||||
|
||||
func loadFirstPage() async {
|
||||
currentPage = 1
|
||||
novels = []
|
||||
isSearchMode = false
|
||||
await loadPage(1)
|
||||
}
|
||||
|
||||
func loadNextPage() async {
|
||||
guard hasNext, !isLoading else { return }
|
||||
await loadPage(currentPage + 1)
|
||||
}
|
||||
|
||||
func search() async {
|
||||
guard !searchQuery.isEmpty else { await loadFirstPage(); return }
|
||||
isLoading = true
|
||||
isSearchMode = true
|
||||
novels = []
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.search(query: searchQuery)
|
||||
novels = result.results
|
||||
hasNext = false
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchQuery = ""
|
||||
Task { await loadFirstPage() }
|
||||
}
|
||||
|
||||
private func loadPage(_ page: Int) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(
|
||||
page: page, genre: genre, sort: sort, status: status
|
||||
)
|
||||
if page == 1 {
|
||||
novels = result.novels
|
||||
} else {
|
||||
novels.append(contentsOf: result.novels)
|
||||
}
|
||||
hasNext = result.hasNext
|
||||
currentPage = page
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ChapterReaderViewModel: ObservableObject {
|
||||
let slug: String
|
||||
private(set) var chapter: Int
|
||||
|
||||
@Published var content: ChapterResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
init(slug: String, chapter: Int) {
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
/// Switch to a different chapter in-place: resets state and updates `chapter`
|
||||
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
|
||||
func switchChapter(to newChapter: Int) {
|
||||
guard newChapter != chapter else { return }
|
||||
chapter = newChapter
|
||||
content = nil
|
||||
error = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||
// Record reading progress
|
||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
|
||||
guard let content else { return }
|
||||
|
||||
// Only treat as "current" if the player is active (not idle/stopped).
|
||||
// If the user stopped playback, isActive is false — we must re-load.
|
||||
let isCurrent = audioPlayer.isActive &&
|
||||
audioPlayer.slug == slug &&
|
||||
audioPlayer.chapter == chapter
|
||||
|
||||
if isCurrent {
|
||||
audioPlayer.togglePlayPause()
|
||||
} else {
|
||||
let nextChapter: Int? = content.next
|
||||
let prevChapter: Int? = content.prev
|
||||
|
||||
// Use per-book voice override, fallback to global voice
|
||||
let voice = BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: settings.voice)
|
||||
|
||||
audioPlayer.load(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
chapterTitle: content.chapter.title,
|
||||
bookTitle: content.book.title,
|
||||
coverURL: content.book.cover,
|
||||
voice: voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
nextChapter: nextChapter,
|
||||
prevChapter: prevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift
Normal file
78
ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class DiscoverViewModel: ObservableObject {
|
||||
@Published var trending: [BrowseNovel] = []
|
||||
@Published var topRated: [BrowseNovel] = []
|
||||
@Published var recentlyUpdated: [BrowseNovel] = []
|
||||
@Published var newReleases: [BrowseNovel] = []
|
||||
@Published var genreShelves: [GenreShelf] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
struct GenreShelf: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let genre: String
|
||||
var novels: [BrowseNovel] = []
|
||||
}
|
||||
|
||||
// Popular genres to show as shelves
|
||||
private let featuredGenres = [
|
||||
("fantasy", "Fantasy"),
|
||||
("romance", "Romance"),
|
||||
("action", "Action"),
|
||||
("sci-fi", "Sci-Fi"),
|
||||
("mystery", "Mystery")
|
||||
]
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
async let trendingTask = loadShelf(sort: "popular", limit: 20)
|
||||
async let topRatedTask = loadShelf(sort: "rating", limit: 20)
|
||||
async let recentlyUpdatedTask = loadShelf(sort: "updated", limit: 20)
|
||||
async let newReleasesTask = loadShelf(sort: "new", limit: 20)
|
||||
|
||||
do {
|
||||
trending = try await trendingTask
|
||||
topRated = try await topRatedTask
|
||||
recentlyUpdated = try await recentlyUpdatedTask
|
||||
newReleases = try await newReleasesTask
|
||||
|
||||
// Load genre shelves
|
||||
await loadGenreShelves()
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadShelf(sort: String, genre: String = "all", status: String = "all", limit: Int = 20) async throws -> [BrowseNovel] {
|
||||
let result = try await APIClient.shared.browse(page: 1, genre: genre, sort: sort, status: status)
|
||||
return Array(result.novels.prefix(limit))
|
||||
}
|
||||
|
||||
private func loadGenreShelves() async {
|
||||
var shelves: [GenreShelf] = []
|
||||
|
||||
for (genre, name) in featuredGenres {
|
||||
do {
|
||||
let novels = try await loadShelf(sort: "popular", genre: genre, limit: 15)
|
||||
if !novels.isEmpty {
|
||||
shelves.append(GenreShelf(id: genre, name: name, genre: genre, novels: novels))
|
||||
}
|
||||
} catch {
|
||||
// Skip failed genres silently
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
genreShelves = shelves
|
||||
}
|
||||
}
|
||||
30
ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift
Normal file
30
ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class HomeViewModel: ObservableObject {
|
||||
@Published var continueReading: [ContinueReadingItem] = []
|
||||
@Published var recentlyUpdated: [Book] = []
|
||||
@Published var stats: HomeStats?
|
||||
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let data = try await APIClient.shared.homeData()
|
||||
continueReading = data.continueReading.map {
|
||||
ContinueReadingItem(book: $0.book, chapter: $0.chapter)
|
||||
}
|
||||
recentlyUpdated = data.recentlyUpdated
|
||||
stats = data.stats
|
||||
subscriptionFeed = data.subscriptionFeed
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
21
ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift
Normal file
21
ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LibraryViewModel: ObservableObject {
|
||||
@Published var items: [LibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
items = try await APIClient.shared.library()
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
40
ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift
Normal file
40
ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ProfileViewModel: ObservableObject {
|
||||
@Published var sessions: [UserSession] = []
|
||||
@Published var voices: [String] = []
|
||||
@Published var sessionsLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
func loadSessions() async {
|
||||
sessionsLoading = true
|
||||
do {
|
||||
sessions = try await APIClient.shared.sessions()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
sessionsLoading = false
|
||||
}
|
||||
|
||||
func loadVoices() async {
|
||||
guard voices.isEmpty else { return }
|
||||
do {
|
||||
voices = try await APIClient.shared.voices()
|
||||
} catch {
|
||||
// Use hardcoded fallback — same as Go server helpers.go
|
||||
voices = ["af_bella", "af_sky", "af_sarah", "af_nicole",
|
||||
"am_adam", "am_michael", "bf_emma", "bf_isabella",
|
||||
"bm_george", "bm_lewis"]
|
||||
}
|
||||
}
|
||||
|
||||
func revokeSession(id: String) async {
|
||||
do {
|
||||
try await APIClient.shared.revokeSession(id: id)
|
||||
sessions.removeAll { $0.id == id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class UserProfileViewModel: ObservableObject {
|
||||
let username: String
|
||||
|
||||
@Published var profile: PublicUserProfile?
|
||||
@Published var currentlyReading: [PublicLibraryItem] = []
|
||||
@Published var library: [PublicLibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isTogglingSubscribe = false
|
||||
@Published var error: String?
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
|
||||
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
|
||||
let (p, lib) = try await (profileFetch, libraryFetch)
|
||||
profile = p
|
||||
currentlyReading = lib.currentlyReading
|
||||
library = lib.library
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(404, _): error = "User not found."
|
||||
default: error = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSubscribe() async {
|
||||
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
|
||||
isTogglingSubscribe = true
|
||||
defer { isTogglingSubscribe = false }
|
||||
do {
|
||||
if p.isSubscribed {
|
||||
try await APIClient.shared.unsubscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: max(0, p.followerCount - 1),
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: false, isSelf: p.isSelf
|
||||
)
|
||||
} else {
|
||||
try await APIClient.shared.subscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: p.followerCount + 1,
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: true, isSelf: p.isSelf
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
|
||||
|
||||
private extension PublicUserProfile {
|
||||
init(id: String, username: String, avatarUrl: String?, created: String,
|
||||
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
|
||||
// Encode then decode to go through the standard Decodable path without duplicating code
|
||||
var dict: [String: Any] = [
|
||||
"id": id, "username": username, "created": created,
|
||||
"followerCount": followerCount, "followingCount": followingCount,
|
||||
"isSubscribed": isSubscribed, "isSelf": isSelf
|
||||
]
|
||||
if let url = avatarUrl { dict["avatarUrl"] = url }
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
|
||||
}
|
||||
}
|
||||
127
ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift
Normal file
127
ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
@MainActor
|
||||
class VoiceSelectionViewModel: ObservableObject {
|
||||
@Published var voices: [String] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
@Published var playingVoice: String?
|
||||
|
||||
private var audioPlayer: AVPlayer?
|
||||
// Store the opaque token returned by the block-based addObserver so we can
|
||||
// actually remove it later. removeObserver(self, ...) does nothing when the
|
||||
// block-based API was used — the token is the observer, not `self`.
|
||||
private var endObserverToken: NSObjectProtocol?
|
||||
|
||||
// Voice label formatting (matches web UI logic)
|
||||
func voiceLabel(_ voice: String) -> String {
|
||||
let parts = voice.split(separator: "_")
|
||||
guard parts.count >= 2 else { return voice }
|
||||
|
||||
let prefix = String(parts[0])
|
||||
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||
|
||||
var info = ""
|
||||
switch prefix {
|
||||
case "af": info = "US F"
|
||||
case "am": info = "US M"
|
||||
case "bf": info = "UK F"
|
||||
case "bm": info = "UK M"
|
||||
default: info = prefix.uppercased()
|
||||
}
|
||||
|
||||
return "\(name) (\(info))"
|
||||
}
|
||||
|
||||
func voiceId(_ voice: String) -> String { voice }
|
||||
|
||||
// Load available voices from API
|
||||
func loadVoices() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let fetchedVoices = try await APIClient.shared.voices()
|
||||
voices = fetchedVoices.isEmpty ? fallbackVoices() : fetchedVoices
|
||||
} catch {
|
||||
self.error = "Failed to load voices: \(error.localizedDescription)"
|
||||
voices = fallbackVoices()
|
||||
}
|
||||
}
|
||||
|
||||
// Play voice sample
|
||||
func playSample(_ voice: String) async {
|
||||
if playingVoice == voice {
|
||||
stopSample()
|
||||
return
|
||||
}
|
||||
|
||||
stopSample()
|
||||
playingVoice = voice
|
||||
|
||||
do {
|
||||
let presignedURL = try await APIClient.shared.presignVoiceSample(voice: voice)
|
||||
guard let url = URL(string: presignedURL) else {
|
||||
throw NSError(domain: "VoiceSelection", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
|
||||
}
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
audioPlayer = AVPlayer(playerItem: playerItem)
|
||||
|
||||
// Block-based addObserver returns a token — store it so we can remove it.
|
||||
endObserverToken = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.stopSample()
|
||||
}
|
||||
}
|
||||
|
||||
audioPlayer?.play()
|
||||
} catch {
|
||||
// Sample might not be generated yet — silently ignore.
|
||||
print("Voice sample not available for \(voice): \(error)")
|
||||
playingVoice = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stop currently playing sample
|
||||
func stopSample() {
|
||||
audioPlayer?.pause()
|
||||
audioPlayer = nil
|
||||
playingVoice = nil
|
||||
if let token = endObserverToken {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
endObserverToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackVoices() -> [String] {
|
||||
["af_bella", "af_sarah", "af_nicole",
|
||||
"am_adam", "am_michael",
|
||||
"bf_emma", "bf_isabella",
|
||||
"bm_george", "bm_lewis",
|
||||
"af_sky"]
|
||||
}
|
||||
|
||||
// deinit: must NOT dispatch a Task capturing self.
|
||||
// A Task strongly retains self, which causes "deallocated with non-zero retain
|
||||
// count 2" → SIGABRT. Instead capture just the two values we need (player and
|
||||
// token) and clean up without touching self at all.
|
||||
nonisolated deinit {
|
||||
// Capture locals — self is going away, do not reference it after this point.
|
||||
// audioPlayer and endObserverToken are actor-isolated, but we can read their
|
||||
// stored value directly in deinit because deinit is the last exclusive owner.
|
||||
// Suppress the "actor-isolated" warning with an unowned reference pattern:
|
||||
// Swift SE-0371 allows nonisolated deinit to access stored properties directly.
|
||||
audioPlayer?.pause()
|
||||
if let token = endObserverToken {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ios/LibNovel/LibNovel/Views/Auth/AuthView.swift
Normal file
123
ios/LibNovel/LibNovel/Views/Auth/AuthView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AuthView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@State private var mode: Mode = .login
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var confirmPassword: String = ""
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Mode { case login, register }
|
||||
enum Field { case username, password, confirmPassword }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Logo / header
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.amber)
|
||||
Text("LibNovel")
|
||||
.font(.largeTitle.bold())
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.bottom, 40)
|
||||
|
||||
// Tab switcher
|
||||
Picker("Mode", selection: $mode) {
|
||||
Text("Sign In").tag(Mode.login)
|
||||
Text("Create Account").tag(Mode.register)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
|
||||
// Form
|
||||
VStack(spacing: 16) {
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($focusedField, equals: .username)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .password }
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focusedField, equals: .password)
|
||||
.submitLabel(mode == .register ? .next : .go)
|
||||
.onSubmit {
|
||||
if mode == .register { focusedField = .confirmPassword }
|
||||
else { submit() }
|
||||
}
|
||||
|
||||
if mode == .register {
|
||||
SecureField("Confirm Password", text: $confirmPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { submit() }
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.easeInOut(duration: 0.2), value: mode)
|
||||
|
||||
if let error = authStore.error {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Button(action: submit) {
|
||||
Group {
|
||||
if authStore.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text(mode == .login ? "Sign In" : "Create Account")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
.disabled(authStore.isLoading || !formIsValid)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in
|
||||
authStore.error = nil
|
||||
confirmPassword = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var formIsValid: Bool {
|
||||
let base = !username.isEmpty && password.count >= 4
|
||||
if mode == .register { return base && password == confirmPassword }
|
||||
return base
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
focusedField = nil
|
||||
Task {
|
||||
if mode == .login {
|
||||
await authStore.login(username: username, password: password)
|
||||
} else {
|
||||
await authStore.register(username: username, password: password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
708
ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift
Normal file
708
ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift
Normal file
@@ -0,0 +1,708 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct BookDetailView: View {
|
||||
let slug: String
|
||||
@StateObject private var vm: BookDetailViewModel
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@State private var summaryExpanded = false
|
||||
@State private var showChapters = false
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
_vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
||||
} else if let book = vm.book {
|
||||
heroSection(book: book)
|
||||
metaSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
chaptersRow(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
CommentsView(slug: slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appNavigationDestination()
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showChapters) {
|
||||
BookChaptersSheet(
|
||||
slug: slug,
|
||||
chapters: vm.chapters,
|
||||
lastChapter: vm.lastChapter,
|
||||
totalChapters: vm.book?.totalChapters ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
|
||||
@ViewBuilder
|
||||
private func heroSection(book: Book) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Full-bleed blurred background
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 320)
|
||||
.blur(radius: 24)
|
||||
.clipped()
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.15), .black.opacity(0.68)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray5))
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(book.title)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Text(book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
|
||||
if !book.genres.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||
TagChip(label: genre).colorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !book.status.isEmpty {
|
||||
StatusBadge(status: book.status)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.frame(minHeight: 320)
|
||||
}
|
||||
|
||||
// MARK: - Meta section (stats + summary + CTAs)
|
||||
|
||||
@ViewBuilder
|
||||
private func metaSection(book: Book) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Quick stats row
|
||||
HStack(spacing: 0) {
|
||||
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(
|
||||
value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status", icon: "flag"
|
||||
)
|
||||
if book.ranking > 0 {
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Summary
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
|
||||
|
||||
if book.summary.count > 200 {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// CTA buttons
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Start Reading", systemImage: "book.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact chapters row (tap → sheet)
|
||||
|
||||
@ViewBuilder
|
||||
private func chaptersRow(book: Book) -> some View {
|
||||
Button {
|
||||
showChapters = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "list.number")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapters")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !vm.chapters.isEmpty {
|
||||
let last = vm.lastChapter
|
||||
let total = vm.chapters.count
|
||||
Text(last != nil && last! > 0
|
||||
? "Reading Ch.\(last!) of \(total)"
|
||||
: "\(total) chapter\(total == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if vm.isLoading {
|
||||
Text("Loading…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Bookmark toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var bookmarkButton: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await vm.toggleSaved() }
|
||||
} label: {
|
||||
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
|
||||
.foregroundStyle(vm.saved ? .amber : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapters list sheet
|
||||
// Apple Books-style: chapters grouped into blocks of 100 with a right-edge jump bar.
|
||||
// A .searchable bar filters by number or title; an "offline only" toggle shows downloaded chapters.
|
||||
// Per-row download status (arc ring, labels, swipe actions) mirrors ChaptersListSheet in PlayerViews.
|
||||
|
||||
struct BookChaptersSheet: View {
|
||||
let slug: String
|
||||
let chapters: [ChapterIndex]
|
||||
let lastChapter: Int?
|
||||
let totalChapters: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var downloadService: AudioDownloadService
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
@State private var searchText: String = ""
|
||||
@State private var filterOfflineOnly = false
|
||||
@State private var showingDownloadAll = false
|
||||
/// The block label the jump bar is currently scrolling to (e.g. "1–100").
|
||||
@State private var activeBlock: String? = nil
|
||||
|
||||
// MARK: Derived data
|
||||
|
||||
private var downloadedCount: Int {
|
||||
chapters.filter { ch in
|
||||
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||
}.count
|
||||
}
|
||||
|
||||
private var downloadingCount: Int {
|
||||
downloadService.downloads.filter { key, _ in
|
||||
key.hasPrefix("\(slug)::")
|
||||
}.count
|
||||
}
|
||||
|
||||
private var defaultVoice: String {
|
||||
BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: audioPlayer.voice)
|
||||
}
|
||||
|
||||
private var filtered: [ChapterIndex] {
|
||||
var result = chapters
|
||||
|
||||
if filterOfflineOnly {
|
||||
result = result.filter { ch in
|
||||
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||
}
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
let q = searchText.lowercased()
|
||||
result = result.filter {
|
||||
"\($0.number)".contains(q) ||
|
||||
$0.title.lowercased().contains(q) ||
|
||||
"chapter \($0.number)".contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Chapters grouped into blocks of 100 with range labels "1–100", "101–200", etc.
|
||||
/// When searching or filtering the jump bar is hidden and a flat "Results" group is used.
|
||||
private var groups: [(label: String, chapters: [ChapterIndex])] {
|
||||
guard searchText.isEmpty && !filterOfflineOnly else {
|
||||
return filtered.isEmpty ? [] : [("Results", filtered)]
|
||||
}
|
||||
guard !filtered.isEmpty else { return [] }
|
||||
let blockSize = 100
|
||||
let minN = filtered.map(\.number).min() ?? 1
|
||||
let maxN = filtered.map(\.number).max() ?? 1
|
||||
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
|
||||
var result: [(label: String, chapters: [ChapterIndex])] = []
|
||||
var blockStart = firstBlock
|
||||
while blockStart <= maxN {
|
||||
let blockEnd = blockStart + blockSize - 1
|
||||
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
|
||||
if !slice.isEmpty {
|
||||
result.append(("\(blockStart)–\(blockEnd)", slice))
|
||||
}
|
||||
blockStart += blockSize
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private var jumpLabels: [String] { groups.map(\.label) }
|
||||
|
||||
// MARK: Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .trailing) {
|
||||
// ── Main chapter list ──────────────────────────────────────
|
||||
List {
|
||||
// Offline downloads summary (shown when at least one chapter is downloaded)
|
||||
if downloadedCount > 0 || downloadingCount > 0 {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Offline Downloads")
|
||||
.font(.headline)
|
||||
Text("\(downloadedCount) of \(chapters.count) chapters")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingDownloadAll = true
|
||||
} label: {
|
||||
Label("Manage", systemImage: "arrow.down.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
if downloadingCount > 0 {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Show offline only", isOn: $filterOfflineOnly)
|
||||
.font(.subheadline)
|
||||
.tint(.amber)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(groups, id: \.label) { group in
|
||||
Section {
|
||||
ForEach(group.chapters, id: \.number) { ch in
|
||||
BookChapterRow(
|
||||
chapter: ch,
|
||||
slug: slug,
|
||||
isCurrent: ch.number == lastChapter,
|
||||
voice: defaultVoice
|
||||
)
|
||||
.id(group.label)
|
||||
}
|
||||
} header: {
|
||||
if searchText.isEmpty && !filterOfflineOnly {
|
||||
Text(group.label)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.id("header_\(group.label)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chapters.isEmpty {
|
||||
Section {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Chapter number or title"
|
||||
)
|
||||
.scrollPosition(id: $activeBlock, anchor: .top)
|
||||
.appNavigationDestination()
|
||||
|
||||
// ── Right-edge jump bar ────────────────────────────────────
|
||||
if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 {
|
||||
BookChaptersJumpBar(
|
||||
labels: jumpLabels,
|
||||
currentChapter: lastChapter ?? 0,
|
||||
groups: groups
|
||||
) { label in
|
||||
withAnimation { activeBlock = label }
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapters (\(filtered.count))")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
// Sheet to manage bulk downloads for this book
|
||||
.sheet(isPresented: $showingDownloadAll) {
|
||||
DownloadManagementSheet(
|
||||
chapters: chapters.map { ChapterIndexBrief(number: $0.number, title: $0.title) },
|
||||
slug: slug,
|
||||
voice: Binding(
|
||||
get: { defaultVoice },
|
||||
set: { _ in } // voice changes handled inside DownloadManagementSheet
|
||||
)
|
||||
)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
// Scroll to the current chapter's block on first appear
|
||||
.onAppear {
|
||||
if let block = groups.first(where: { g in
|
||||
g.chapters.contains(where: { $0.number == (lastChapter ?? 0) })
|
||||
}) {
|
||||
activeBlock = block.label
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Individual chapter row with download status + NavigationLink
|
||||
|
||||
private struct BookChapterRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let slug: String
|
||||
let isCurrent: Bool
|
||||
let voice: String
|
||||
|
||||
@EnvironmentObject var downloadService: AudioDownloadService
|
||||
|
||||
private var isDownloaded: Bool {
|
||||
downloadService.isDownloaded(slug: slug, chapter: chapter.number, voice: voice)
|
||||
}
|
||||
|
||||
private var downloadProgress: DownloadProgress? {
|
||||
let key = downloadService.makeKey(slug: slug, chapter: chapter.number, voice: voice)
|
||||
return downloadService.downloads[key]
|
||||
}
|
||||
|
||||
private var isDownloading: Bool { downloadProgress != nil }
|
||||
|
||||
private var displayTitle: String {
|
||||
let stripped = chapter.title.strippingTrailingDate()
|
||||
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||
return "Chapter \(chapter.number)"
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
|
||||
HStack(spacing: 14) {
|
||||
// Number badge with optional download-progress arc ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isCurrent ? Color.amber : Color(.systemGray5))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Text("\(chapter.number)")
|
||||
.font(.caption.bold().monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .white : .secondary)
|
||||
.minimumScaleFactor(0.6)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
// In-progress download arc
|
||||
if isDownloading, let progress = downloadProgress {
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.progress)
|
||||
.stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 44, height: 44)
|
||||
.animation(.easeInOut(duration: 0.3), value: progress.progress)
|
||||
}
|
||||
}
|
||||
|
||||
// Title + status subtitle
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(displayTitle)
|
||||
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
|
||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if isCurrent {
|
||||
Label("Reading", systemImage: "bookmark.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
|
||||
if isDownloading, let progress = downloadProgress {
|
||||
Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
} else if isDownloaded {
|
||||
Label("Downloaded", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
} else if !chapter.dateLabel.isEmpty {
|
||||
Text(chapter.dateLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||||
// Trailing swipe: Download / Cancel / Delete
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if isDownloaded {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
try? downloadService.deleteDownload(
|
||||
slug: slug, chapter: chapter.number, voice: voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} else if isDownloading {
|
||||
Button(role: .destructive) {
|
||||
downloadService.cancelDownload(
|
||||
slug: slug, chapter: chapter.number, voice: voice
|
||||
)
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
try? await downloadService.download(
|
||||
slug: slug, chapter: chapter.number, voice: voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Download", systemImage: "arrow.down.circle")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Right-edge jump bar for BookChaptersSheet
|
||||
// Mirrors the JumpBar in PlayerViews.swift but operates on ChapterIndex groups.
|
||||
|
||||
private struct BookChaptersJumpBar: View {
|
||||
let labels: [String]
|
||||
let currentChapter: Int
|
||||
let groups: [(label: String, chapters: [ChapterIndex])]
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
@State private var isDragging = false
|
||||
|
||||
private func shortLabel(_ full: String) -> String {
|
||||
full.components(separatedBy: "–").first ?? full
|
||||
}
|
||||
|
||||
private var currentBlock: String? {
|
||||
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(labels, id: \.self) { label in
|
||||
let isCurrent = label == currentBlock
|
||||
Text(shortLabel(label))
|
||||
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
|
||||
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(label) }
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.shadow(color: .black.opacity(0.15), radius: 4)
|
||||
)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
let itemHeight: CGFloat = 28
|
||||
let index = Int(value.location.y / itemHeight)
|
||||
let clamped = max(0, min(labels.count - 1, index))
|
||||
onSelect(labels[clamped])
|
||||
}
|
||||
.onEnded { _ in isDragging = false }
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting components
|
||||
|
||||
private struct MetaStat: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusBadge: View {
|
||||
let status: String
|
||||
|
||||
private var color: Color {
|
||||
switch status.lowercased() {
|
||||
case "ongoing", "active": return .green
|
||||
case "completed": return .blue
|
||||
case "hiatus": return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(status.capitalized)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(color.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
@@ -0,0 +1,643 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
class CommentsViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var comments: [BookComment] = []
|
||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||
@Published var avatarUrls: [String: String] = [:] // userId → presigned URL
|
||||
@Published var isLoading = true
|
||||
@Published var error: String?
|
||||
|
||||
@Published var newBody = ""
|
||||
@Published var isPosting = false
|
||||
@Published var postError: String?
|
||||
|
||||
@Published var sort: CommentSortOrder = .top
|
||||
|
||||
// Reply state
|
||||
@Published var replyingToId: String? = nil
|
||||
@Published var replyBody = ""
|
||||
@Published var isPostingReply = false
|
||||
@Published var replyError: String?
|
||||
|
||||
private var votingIds: Set<String> = []
|
||||
private var deletingIds: Set<String> = []
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue)
|
||||
comments = response.comments
|
||||
myVotes = response.myVotes
|
||||
avatarUrls = response.avatarUrls
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func postComment() async {
|
||||
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPosting else { return }
|
||||
if text.count > 2000 {
|
||||
postError = "Comment too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPosting = true
|
||||
postError = nil
|
||||
do {
|
||||
var created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
created.replies = []
|
||||
comments.insert(created, at: 0)
|
||||
newBody = ""
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): postError = "You must be logged in to comment."
|
||||
default: postError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
postError = error.localizedDescription
|
||||
}
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
func postReply(parentId: String) async {
|
||||
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPostingReply else { return }
|
||||
if text.count > 2000 {
|
||||
replyError = "Reply too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPostingReply = true
|
||||
replyError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(created)
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
}
|
||||
replyBody = ""
|
||||
replyingToId = nil
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): replyError = "You must be logged in to reply."
|
||||
default: replyError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
replyError = error.localizedDescription
|
||||
}
|
||||
isPostingReply = false
|
||||
}
|
||||
|
||||
func deleteComment(commentId: String, parentId: String? = nil) async {
|
||||
guard !deletingIds.contains(commentId) else { return }
|
||||
deletingIds.insert(commentId)
|
||||
|
||||
// Optimistic removal — update the UI immediately before the network call
|
||||
var removedComment: BookComment?
|
||||
var removedAtIndex: Int?
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
removedComment = parent.replies?.first(where: { $0.id == commentId })
|
||||
removedAtIndex = idx
|
||||
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
removedAtIndex = comments.firstIndex(where: { $0.id == commentId })
|
||||
removedComment = removedAtIndex.map { comments[$0] }
|
||||
comments.removeAll { $0.id == commentId }
|
||||
}
|
||||
|
||||
do {
|
||||
try await APIClient.shared.deleteComment(commentId: commentId)
|
||||
} catch {
|
||||
// Revert the optimistic removal on failure
|
||||
if let removed = removedComment {
|
||||
if let parentId, let idx = removedAtIndex {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(removed)
|
||||
replies.sort { $0.created < $1.created }
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
} else if let idx = removedAtIndex {
|
||||
comments.insert(removed, at: min(idx, comments.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deletingIds.remove(commentId)
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String, parentId: String? = nil) async {
|
||||
guard !votingIds.contains(commentId) else { return }
|
||||
votingIds.insert(commentId)
|
||||
defer { votingIds.remove(commentId) }
|
||||
do {
|
||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
|
||||
parent.replies![rIdx] = updated
|
||||
}
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
var c = updated
|
||||
c.replies = comments[idx].replies
|
||||
comments[idx] = c
|
||||
}
|
||||
}
|
||||
let prev = myVotes[commentId]
|
||||
if prev == vote {
|
||||
myVotes.removeValue(forKey: commentId)
|
||||
} else {
|
||||
myVotes[commentId] = vote
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore vote errors
|
||||
}
|
||||
}
|
||||
|
||||
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
|
||||
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
|
||||
|
||||
func setSort(_ newSort: CommentSortOrder) {
|
||||
guard newSort != sort else { return }
|
||||
sort = newSort
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
enum CommentSortOrder: String, CaseIterable {
|
||||
case top = "top"
|
||||
case new = "new"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .top: return "Top"
|
||||
case .new: return "New"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentsView
|
||||
|
||||
struct CommentsView: View {
|
||||
@StateObject private var vm: CommentsViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(slug: String) {
|
||||
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header + sort picker
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
|
||||
if !vm.isLoading && total > 0 {
|
||||
Text("(\(total))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// Sort picker
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Picker("Sort", selection: Binding(
|
||||
get: { vm.sort },
|
||||
set: { vm.setSort($0) }
|
||||
)) {
|
||||
ForEach(CommentSortOrder.allCases, id: \.self) { s in
|
||||
Text(s.label).tag(s)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Post form
|
||||
postForm
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Comment list
|
||||
if vm.isLoading {
|
||||
loadingPlaceholder
|
||||
} else if let err = vm.error {
|
||||
Text(err)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
} else if vm.comments.isEmpty {
|
||||
Text("No comments yet. Be the first!")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(vm.comments) { comment in
|
||||
commentThread(comment: comment)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 16)
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Comment thread (top-level + replies)
|
||||
|
||||
@ViewBuilder
|
||||
private func commentThread(comment: BookComment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id),
|
||||
isDeleting: vm.isDeleting(comment.id),
|
||||
isOwner: authStore.user?.id == comment.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: vm.replyingToId == comment.id,
|
||||
avatarUrl: vm.avatarUrls[comment.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
|
||||
onReply: {
|
||||
if vm.replyingToId == comment.id {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
} else {
|
||||
vm.replyingToId = comment.id
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Inline reply form
|
||||
if vm.replyingToId == comment.id {
|
||||
replyForm(parentId: comment.id)
|
||||
.padding(.leading, 32)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Replies
|
||||
if let replies = comment.replies, !replies.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(replies) { reply in
|
||||
CommentRow(
|
||||
comment: reply,
|
||||
myVote: vm.myVotes[reply.id],
|
||||
isVoting: vm.isVoting(reply.id),
|
||||
isDeleting: vm.isDeleting(reply.id),
|
||||
isOwner: authStore.user?.id == reply.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: false,
|
||||
isReply: true,
|
||||
avatarUrl: vm.avatarUrls[reply.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
|
||||
onReply: nil
|
||||
)
|
||||
if reply.id != replies.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: 2)
|
||||
.padding(.leading, 16)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reply form
|
||||
|
||||
@ViewBuilder
|
||||
private func replyForm(parentId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.replyBody.isEmpty {
|
||||
Text("Write a reply…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.replyBody)
|
||||
.font(.caption)
|
||||
.frame(minHeight: 56, maxHeight: 120)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
HStack {
|
||||
let count = vm.replyBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.replyError {
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
|
||||
}
|
||||
|
||||
Button("Cancel") {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
Task { await vm.postReply(parentId: parentId) }
|
||||
} label: {
|
||||
if vm.isPostingReply {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text("Reply").fontWeight(.semibold).font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.mini)
|
||||
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
if authStore.isAuthenticated {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Log in to leave a comment.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading skeleton
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingPlaceholder: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 100, height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 200, height: 12)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentRow
|
||||
|
||||
private struct CommentRow: View {
|
||||
let comment: BookComment
|
||||
let myVote: String?
|
||||
let isVoting: Bool
|
||||
let isDeleting: Bool
|
||||
let isOwner: Bool
|
||||
let isLoggedIn: Bool
|
||||
let isReplyingTo: Bool
|
||||
var isReply: Bool = false
|
||||
var avatarUrl: String? = nil
|
||||
let onVote: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReply: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Avatar + Username + date
|
||||
HStack(spacing: 8) {
|
||||
avatarView
|
||||
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(comment.username.isEmpty)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formattedDate(comment.created))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Body
|
||||
Text(comment.body)
|
||||
.font(isReply ? .caption : .subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Actions
|
||||
HStack(spacing: 14) {
|
||||
// Upvote
|
||||
Button { onVote("up") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||
.font(.caption)
|
||||
Text("\(comment.upvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Downvote
|
||||
Button { onVote("down") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||
.font(.caption)
|
||||
Text("\(comment.downvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "down" ? .red : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Reply button (top-level only, logged in)
|
||||
if let onReply, isLoggedIn {
|
||||
Button { onReply() } label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
.font(.caption)
|
||||
Text("Reply")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete (owner only)
|
||||
if isOwner {
|
||||
Button(role: .destructive) { onDelete() } label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.opacity(isDeleting ? 0.5 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDeleting)
|
||||
}
|
||||
|
||||
private var avatarSize: CGFloat { isReply ? 20 : 24 }
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
if let url = avatarUrl, let imageUrl = URL(string: url) {
|
||||
AsyncImage(url: imageUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
|
||||
private var initialsView: some View {
|
||||
let name = comment.username.isEmpty ? "?" : comment.username
|
||||
let letters = String(name.prefix(2)).uppercased()
|
||||
return ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
Text(letters)
|
||||
.font(.system(size: avatarSize * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ iso: String) -> String {
|
||||
// PocketBase returns "2006-01-02 15:04:05.999Z" format
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
// Fallback: try space-separated format
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = df.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
567
ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift
Normal file
567
ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift
Normal file
@@ -0,0 +1,567 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Discover View (Browse)
|
||||
// Serendipity-focused browsing with curated shelves.
|
||||
// No search bar — use the dedicated Search tab for that.
|
||||
|
||||
struct BrowseView: View {
|
||||
@StateObject private var vm = DiscoverViewModel()
|
||||
@State private var showGenreSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.trending.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMsg = vm.error, vm.trending.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(errorMsg)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") { Task { await vm.load() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 32) {
|
||||
// Trending shelf
|
||||
if !vm.trending.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Trending Now",
|
||||
novels: vm.trending,
|
||||
destination: .browseCategory(
|
||||
sort: "popular",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Trending Now"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Top Rated shelf
|
||||
if !vm.topRated.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Top Rated",
|
||||
novels: vm.topRated,
|
||||
destination: .browseCategory(
|
||||
sort: "rating",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Top Rated"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Recently Updated shelf
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Recently Updated",
|
||||
novels: vm.recentlyUpdated,
|
||||
destination: .browseCategory(
|
||||
sort: "updated",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Recently Updated"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// New Releases shelf
|
||||
if !vm.newReleases.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "New Releases",
|
||||
novels: vm.newReleases,
|
||||
destination: .browseCategory(
|
||||
sort: "new",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "New Releases"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Categories button — replaces individual genre shelves
|
||||
CategoriesRow(onTap: { showGenreSheet = true })
|
||||
.padding(.horizontal)
|
||||
|
||||
Color.clear.frame(height: 100)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showGenreSheet) {
|
||||
GenrePickerSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Categories row (Apple Books–style single button)
|
||||
|
||||
private struct CategoriesRow: View {
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.amber.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Browse by Genre")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Action, Fantasy, Romance & more")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Genre picker sheet
|
||||
|
||||
private struct GenrePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let genres: [(label: String, genre: String, icon: String)] = [
|
||||
("Action", "action", "bolt.fill"),
|
||||
("Fantasy", "fantasy", "wand.and.stars"),
|
||||
("Romance", "romance", "heart.fill"),
|
||||
("Sci-Fi", "sci-fi", "sparkles"),
|
||||
("Mystery", "mystery", "magnifyingglass"),
|
||||
("Horror", "horror", "moon.fill"),
|
||||
("Comedy", "comedy", "face.smiling"),
|
||||
("Adventure", "adventure", "map.fill"),
|
||||
("Martial Arts", "martial arts", "figure.martial.arts"),
|
||||
("Cultivation", "cultivation", "leaf.fill"),
|
||||
("Historical", "historical", "building.columns.fill"),
|
||||
("Slice of Life", "slice of life", "sun.max.fill"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 12
|
||||
) {
|
||||
// "All" tile
|
||||
NavigationLink(value: NavDestination.browseCategory(
|
||||
sort: "popular", genre: "all", status: "all", title: "All Novels"
|
||||
)) {
|
||||
GenreTile(label: "All Novels", icon: "books.vertical.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||
|
||||
ForEach(genres, id: \.genre) { item in
|
||||
NavigationLink(value: NavDestination.browseCategory(
|
||||
sort: "popular",
|
||||
genre: item.genre,
|
||||
status: "all",
|
||||
title: item.label
|
||||
)) {
|
||||
GenreTile(label: item.label, icon: item.icon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Genres")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationCornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenreTile: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 24)
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Discover Shelf (horizontal scrolling)
|
||||
|
||||
private struct DiscoverShelf: View {
|
||||
let title: String
|
||||
let novels: [BrowseNovel]
|
||||
let destination: NavDestination
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Header with "See All" button
|
||||
HStack(spacing: 10) {
|
||||
// Amber accent bar — matches ShelfHeader style used on Home and UserProfile
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.amber)
|
||||
.frame(width: 3, height: 18)
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
NavigationLink(value: destination) {
|
||||
HStack(spacing: 4) {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.bold())
|
||||
}
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Horizontal scroll — leading padding aligns cards with header
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
DiscoverShelfCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 4) // let shadows breathe
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf card (card-style)
|
||||
|
||||
private struct DiscoverShelfCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(width: 120, height: 173) // 2:3 ratio
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(width: 136)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse Category View (full grid for "See All")
|
||||
|
||||
struct BrowseCategoryView: View {
|
||||
let sort: String
|
||||
let genre: String
|
||||
let status: String
|
||||
let title: String
|
||||
|
||||
@StateObject private var vm: BrowseViewModel
|
||||
@State private var showFilters = false
|
||||
|
||||
init(sort: String, genre: String, status: String, title: String) {
|
||||
self.sort = sort
|
||||
self.genre = genre
|
||||
self.status = status
|
||||
self.title = title
|
||||
|
||||
let viewModel = BrowseViewModel()
|
||||
viewModel.sort = sort
|
||||
viewModel.genre = genre
|
||||
viewModel.status = status
|
||||
_vm = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if vm.isLoading && vm.novels.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMsg = vm.error, vm.novels.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(errorMsg)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") { Task { await vm.loadFirstPage() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)
|
||||
],
|
||||
spacing: 14
|
||||
) {
|
||||
ForEach(vm.novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
BrowseCategoryCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
// Infinite scroll
|
||||
if novel.id == vm.novels.last?.id {
|
||||
Task { await vm.loadNextPage() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 100)
|
||||
|
||||
if vm.isLoading && !vm.novels.isEmpty {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.refreshable { await vm.loadFirstPage() }
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showFilters = true
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersView(vm: vm)
|
||||
}
|
||||
.task {
|
||||
if vm.novels.isEmpty {
|
||||
await vm.loadFirstPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowseCategoryCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !novel.author.isEmpty {
|
||||
Text(novel.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filters sheet (kept for future "See All" views)
|
||||
|
||||
struct BrowseFiltersView: View {
|
||||
@ObservedObject var vm: BrowseViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let sortOptions = ["popular", "new", "updated", "rating", "rank"]
|
||||
let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery",
|
||||
"horror", "comedy", "drama", "adventure", "martial arts",
|
||||
"cultivation", "magic", "supernatural", "historical", "slice of life"]
|
||||
let statusOptions = ["all", "ongoing", "completed"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Sort") {
|
||||
ForEach(sortOptions, id: \.self) { opt in
|
||||
HStack {
|
||||
Text(opt.capitalized)
|
||||
Spacer()
|
||||
if vm.sort == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vm.sort = opt; dismiss() }
|
||||
}
|
||||
}
|
||||
Section("Genre") {
|
||||
ForEach(genreOptions, id: \.self) { opt in
|
||||
HStack {
|
||||
Text(opt == "all" ? "All Genres" : opt.capitalized)
|
||||
Spacer()
|
||||
if vm.genre == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vm.genre = opt; dismiss() }
|
||||
}
|
||||
}
|
||||
Section("Status") {
|
||||
ForEach(statusOptions, id: \.self) { opt in
|
||||
HStack {
|
||||
Text(opt.capitalized)
|
||||
Spacer()
|
||||
if vm.status == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vm.status = opt; dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
1240
ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift
Normal file
1240
ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Download Audio Button
|
||||
// Shows download status and allows users to download/delete offline audio.
|
||||
// Uses symbolEffect + spring animations for a modern, tactile feel.
|
||||
|
||||
struct DownloadAudioButton: View {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let voice: String
|
||||
let theme: ReaderTheme
|
||||
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@State private var showDownloadMenu = false
|
||||
@State private var bounceDownload = false
|
||||
|
||||
private var downloadKey: String {
|
||||
AudioDownloadService.shared.makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
private var isDownloaded: Bool {
|
||||
downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
private var downloadProgress: DownloadProgress? {
|
||||
downloadService.downloads[downloadKey]
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showDownloadMenu = true
|
||||
} label: {
|
||||
ZStack {
|
||||
// Background pill
|
||||
Circle()
|
||||
.fill(backgroundFillColor)
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
stateIcon
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDownloaded)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: downloadProgress?.status.isDownloading)
|
||||
.confirmationDialog("Audio Download", isPresented: $showDownloadMenu) {
|
||||
if isDownloaded {
|
||||
Button("Delete Download", role: .destructive) {
|
||||
Task {
|
||||
try? await downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
}
|
||||
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||
Button("Cancel Download", role: .destructive) {
|
||||
downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
} else {
|
||||
Button("Download for Offline") {
|
||||
Task {
|
||||
try? await downloadService.download(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { bounceDownload.toggle() }
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
if isDownloaded {
|
||||
Text("This chapter's audio is downloaded for offline listening.")
|
||||
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||
Text("Downloading… \(Int(progress.progress * 100))%")
|
||||
} else {
|
||||
Text("Download this chapter's audio to listen offline without internet connection.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
private var backgroundFillColor: Color {
|
||||
if isDownloaded {
|
||||
return Color.green.opacity(0.15)
|
||||
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||
return accentColor.opacity(0.1)
|
||||
} else if let progress = downloadProgress, case .failed = progress.status {
|
||||
return Color.red.opacity(0.12)
|
||||
} else {
|
||||
return theme.textColor.opacity(0.07)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Icon
|
||||
|
||||
@ViewBuilder
|
||||
private var stateIcon: some View {
|
||||
if isDownloaded {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.green)
|
||||
.symbolEffect(.bounce, value: isDownloaded)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
|
||||
} else if let progress = downloadProgress {
|
||||
switch progress.status {
|
||||
case .downloading:
|
||||
ZStack {
|
||||
// Track ring
|
||||
Circle()
|
||||
.stroke(accentColor.opacity(0.18), lineWidth: 2.5)
|
||||
// Progress arc
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.progress)
|
||||
.stroke(
|
||||
accentColor,
|
||||
style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.2), value: progress.progress)
|
||||
// Down arrow
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.red)
|
||||
.symbolEffect(.pulse)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
|
||||
case .completed:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
} else {
|
||||
// Idle — not yet downloaded
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(theme.textColor.opacity(0.55))
|
||||
.symbolEffect(.bounce, value: bounceDownload)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadStatus {
|
||||
var isDownloading: Bool {
|
||||
if case .downloading = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
164
ios/LibNovel/LibNovel/Views/Common/CommonViews.swift
Normal file
164
ios/LibNovel/LibNovel/Views/Common/CommonViews.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - Empty state placeholder used across all screens
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover image card reused across screens
|
||||
|
||||
struct BookCard: View {
|
||||
let book: Book
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
Text(book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
Text(book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Async cover image with disk/memory caching via Kingfisher
|
||||
|
||||
struct AsyncCoverImage: View {
|
||||
let url: String
|
||||
/// When true the placeholder is a plain colour fill — used for blurred hero backgrounds
|
||||
/// so the rounded-rect loading indicator doesn't bleed through.
|
||||
var isBackground: Bool = false
|
||||
|
||||
var body: some View {
|
||||
KFImage(URL(string: url))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
if isBackground {
|
||||
Color(.systemGray6)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
}
|
||||
}
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tag chip
|
||||
|
||||
struct TagChip: View {
|
||||
let label: String
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(.systemGray5), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified chip button (filter/sort chips across all screens)
|
||||
//
|
||||
// .filled → amber background when selected (genre filter chips in Library)
|
||||
// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips)
|
||||
|
||||
enum ChipButtonStyle { case filled, outlined }
|
||||
|
||||
struct ChipButton: View {
|
||||
let label: String
|
||||
let isSelected: Bool
|
||||
var style: ChipButtonStyle = .filled
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(chipFont)
|
||||
.padding(.horizontal, chipHPad)
|
||||
.padding(.vertical, 6)
|
||||
.background(background)
|
||||
.foregroundStyle(foregroundColor)
|
||||
.overlay(border)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var chipFont: Font {
|
||||
switch style {
|
||||
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
|
||||
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
switch style {
|
||||
case .filled:
|
||||
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
|
||||
case .outlined:
|
||||
Capsule()
|
||||
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
|
||||
.overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
|
||||
private var foregroundColor: Color {
|
||||
switch style {
|
||||
case .filled: return isSelected ? .white : .primary
|
||||
case .outlined: return isSelected ? .amber : .primary
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var border: some View {
|
||||
// outlined style already has its border baked into `background`
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf header (amber accent bar + title)
|
||||
// Used by HomeView, UserProfileView, BrowseView's DiscoverShelf, and any future shelf screen.
|
||||
// Call sites that need trailing content (e.g. a "See All" NavigationLink) wrap this in an HStack.
|
||||
|
||||
struct ShelfHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
// 3-pt amber accent bar — the brand visual anchor for all shelf titles
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.amber)
|
||||
.frame(width: 3, height: 18)
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
32
ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift
Normal file
32
ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Offline Banner
|
||||
// Subtle banner shown at top of screen when network is unavailable
|
||||
|
||||
struct OfflineBanner: View {
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
var body: some View {
|
||||
if !networkMonitor.isConnected {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.caption)
|
||||
Text("You're offline")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text("Showing cached content")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.3))
|
||||
.frame(height: 1)
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
340
ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift
Normal file
340
ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Download Queue Toolbar Button
|
||||
// Compact toolbar button that shows active download status and opens queue management sheet.
|
||||
// Shows:
|
||||
// - Download icon with badge count when downloads are active
|
||||
// - Progress ring around icon
|
||||
// - Taps opens DownloadQueueSheet for management
|
||||
|
||||
struct DownloadQueueButton: View {
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@State private var showQueue = false
|
||||
|
||||
private var activeDownloads: [DownloadProgress] {
|
||||
downloadService.downloads.values.filter { $0.status == .downloading }
|
||||
}
|
||||
|
||||
private var hasActiveDownloads: Bool {
|
||||
!activeDownloads.isEmpty
|
||||
}
|
||||
|
||||
private var averageProgress: Double {
|
||||
guard !activeDownloads.isEmpty else { return 0 }
|
||||
let total = activeDownloads.reduce(0.0) { $0 + $1.progress }
|
||||
return total / Double(activeDownloads.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showQueue = true
|
||||
} label: {
|
||||
ZStack {
|
||||
// Progress ring (only shown when downloading)
|
||||
if hasActiveDownloads {
|
||||
Circle()
|
||||
.stroke(Color.amber.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: averageProgress)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.frame(width: 30, height: 30)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.3), value: averageProgress)
|
||||
}
|
||||
|
||||
// Download icon
|
||||
Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(hasActiveDownloads ? .amber : .secondary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
||||
// Badge count (top-right corner)
|
||||
if activeDownloads.count > 0 {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(activeDownloads.count)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.frame(minWidth: 16)
|
||||
.background(Circle().fill(Color.red))
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6)
|
||||
.sheet(isPresented: $showQueue) {
|
||||
DownloadQueueSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Queue Management Sheet
|
||||
// Bottom sheet showing active downloads and quick management options
|
||||
|
||||
struct DownloadQueueSheet: View {
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var activeDownloads: [(key: String, value: DownloadProgress)] {
|
||||
downloadService.downloads
|
||||
.filter { $0.value.status == .downloading }
|
||||
.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private var failedDownloads: [(key: String, value: DownloadProgress)] {
|
||||
downloadService.downloads.compactMap { key, value in
|
||||
if case .failed = value.status {
|
||||
return (key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private var totalDownloaded: Int {
|
||||
downloadService.downloadedChapters.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 {
|
||||
emptyState
|
||||
} else {
|
||||
List {
|
||||
// Active downloads section
|
||||
if !activeDownloads.isEmpty {
|
||||
Section {
|
||||
ForEach(activeDownloads, id: \.key) { key, progress in
|
||||
ActiveDownloadRow(progress: progress)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Downloading")
|
||||
Spacer()
|
||||
Text("\(activeDownloads.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Failed downloads section
|
||||
if !failedDownloads.isEmpty {
|
||||
Section("Failed") {
|
||||
ForEach(failedDownloads, id: \.key) { key, progress in
|
||||
FailedDownloadRow(progress: progress, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick stats section
|
||||
Section {
|
||||
NavigationLink {
|
||||
DownloadsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Downloaded Chapters")
|
||||
Spacer()
|
||||
Text("\(totalDownloaded)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "internaldrive")
|
||||
.foregroundStyle(.amber)
|
||||
Text("Storage Used")
|
||||
Spacer()
|
||||
Text(storageUsedFormatted)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel all option (only show if there are active downloads)
|
||||
if !activeDownloads.isEmpty {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
activeDownloads.forEach { key, progress in
|
||||
downloadService.cancelDownload(
|
||||
slug: progress.slug,
|
||||
chapter: progress.chapter,
|
||||
voice: progress.voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Cancel All Downloads")
|
||||
.font(.subheadline.bold())
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Download Queue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
Text("No Active Downloads")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("Audio chapters you download will appear here")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var storageUsedFormatted: String {
|
||||
let bytes = downloadService.getTotalStorageUsed()
|
||||
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active Download Row
|
||||
|
||||
private struct ActiveDownloadRow: View {
|
||||
let progress: DownloadProgress
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Book/Chapter info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formatSlug(progress.slug))
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
Text("Chapter \(progress.chapter)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Progress indicator
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(progress.progress * 100))%")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
.monospacedDigit()
|
||||
|
||||
ProgressView(value: progress.progress)
|
||||
.frame(width: 60)
|
||||
.tint(.amber)
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
Button {
|
||||
downloadService.cancelDownload(
|
||||
slug: progress.slug,
|
||||
chapter: progress.chapter,
|
||||
voice: progress.voice
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatSlug(_ slug: String) -> String {
|
||||
// Convert slug to readable title (e.g., "my-book-title" -> "My Book Title")
|
||||
slug.split(separator: "-")
|
||||
.map { $0.capitalized }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Failed Download Row
|
||||
|
||||
private struct FailedDownloadRow: View {
|
||||
let progress: DownloadProgress
|
||||
let key: String
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formatSlug(progress.slug))
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
Text("Chapter \(progress.chapter)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Retry button
|
||||
Button {
|
||||
Task {
|
||||
// Remove failed status
|
||||
downloadService.downloads.removeValue(forKey: key)
|
||||
// Retry download
|
||||
try? await downloadService.download(
|
||||
slug: progress.slug,
|
||||
chapter: progress.chapter,
|
||||
voice: progress.voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Text("Retry")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.amber.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatSlug(_ slug: String) -> String {
|
||||
slug.split(separator: "-")
|
||||
.map { $0.capitalized }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
216
ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift
Normal file
216
ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Downloads Management View
|
||||
// Shows all downloaded audio chapters and allows deletion
|
||||
|
||||
struct DownloadsView: View {
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var sortedDownloads: [(key: String, value: DownloadProgress)] {
|
||||
downloadService.downloads.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private var totalStorageFormatted: String {
|
||||
let bytes = downloadService.getTotalStorageUsed()
|
||||
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if downloadService.downloadedChapters.isEmpty && downloadService.downloads.isEmpty {
|
||||
// Empty state
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
Text("No Downloads")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("Downloaded audio chapters will appear here for offline listening")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List {
|
||||
// Storage info section
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "internaldrive")
|
||||
.foregroundStyle(.amber)
|
||||
Text("Total Storage Used")
|
||||
Spacer()
|
||||
Text(totalStorageFormatted)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Active downloads
|
||||
if !downloadService.downloads.isEmpty {
|
||||
Section("Active Downloads") {
|
||||
ForEach(sortedDownloads, id: \.key) { key, progress in
|
||||
DownloadRow(progress: progress, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Downloaded chapters
|
||||
if !downloadService.downloadedChapters.isEmpty {
|
||||
Section("Downloaded (\(downloadService.downloadedChapters.count))") {
|
||||
ForEach(Array(downloadService.downloadedChapters.sorted()), id: \.self) { key in
|
||||
DownloadedChapterRow(key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if !downloadService.downloadedChapters.isEmpty {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
try? downloadService.deleteAllDownloads()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Delete All Downloads")
|
||||
.font(.subheadline.bold())
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Downloads")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Row (in progress)
|
||||
|
||||
private struct DownloadRow: View {
|
||||
let progress: DownloadProgress
|
||||
let key: String
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Chapter \(progress.chapter)")
|
||||
.font(.subheadline.bold())
|
||||
Text(progress.slug)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if progress.status == .downloading {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(progress.progress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: progress.progress)
|
||||
.frame(width: 60)
|
||||
}
|
||||
|
||||
Button {
|
||||
downloadService.cancelDownload(slug: progress.slug, chapter: progress.chapter, voice: progress.voice)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if case .failed(let error) = progress.status {
|
||||
VStack(alignment: .trailing) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("Failed")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downloaded Chapter Row
|
||||
|
||||
private struct DownloadedChapterRow: View {
|
||||
let key: String
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
private var components: (slug: String, chapter: String, voice: String) {
|
||||
let parts = key.split(separator: "-")
|
||||
if parts.count >= 3 {
|
||||
return (String(parts[0]), String(parts[1]), parts[2...].joined(separator: "-"))
|
||||
}
|
||||
return ("", "", "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Chapter \(components.chapter)")
|
||||
.font(.subheadline.bold())
|
||||
HStack(spacing: 4) {
|
||||
Text(components.slug)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(formatVoice(components.voice))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
let parts = components
|
||||
if let chapter = Int(parts.chapter) {
|
||||
try? downloadService.deleteDownload(slug: parts.slug, chapter: chapter, voice: parts.voice)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatVoice(_ voice: String) -> String {
|
||||
// Format voice name (e.g., "af_bella" -> "Bella (US F)")
|
||||
let parts = voice.split(separator: "_")
|
||||
guard parts.count == 2 else { return voice }
|
||||
|
||||
let prefix = String(parts[0])
|
||||
let name = String(parts[1]).capitalized
|
||||
|
||||
let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : ""
|
||||
let accent = prefix.hasPrefix("af") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : ""
|
||||
|
||||
if !gender.isEmpty && !accent.isEmpty {
|
||||
return "\(name) (\(accent) \(gender))"
|
||||
} else if !gender.isEmpty {
|
||||
return "\(name) (\(gender))"
|
||||
} else {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
451
ios/LibNovel/LibNovel/Views/Home/HomeView.swift
Normal file
451
ios/LibNovel/LibNovel/Views/Home/HomeView.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
@StateObject private var vm = HomeViewModel()
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
private var offlineBooks: [Book] {
|
||||
let offlineSlugs = downloadService.getOfflineBookSlugs()
|
||||
// Filter continue reading items that have offline downloads
|
||||
return vm.continueReading
|
||||
.filter { offlineSlugs.contains($0.book.slug) }
|
||||
.map { $0.book }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
||||
if !vm.continueReading.isEmpty {
|
||||
ShelfHeader(title: "Continue Reading")
|
||||
.padding(.top, 8)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ContinueReadingCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ContinueReadingContextMenu(
|
||||
item: item,
|
||||
onMarkFinished: {
|
||||
Task { await markAsFinished(item.book) }
|
||||
},
|
||||
onRemove: {
|
||||
Task { await removeFromLibrary(item.book.slug) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Offline books — books with downloaded chapters
|
||||
if !offlineBooks.isEmpty {
|
||||
HStack {
|
||||
ShelfHeader(title: "Downloaded for Offline")
|
||||
Spacer()
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(offlineBooks) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ShelfBookCard(book: book)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
Text("\(downloadService.getDownloadedChapterCount(for: book.slug)) chapters")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Stats strip
|
||||
if let stats = vm.stats {
|
||||
StatsStrip(stats: stats)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Recently updated shelf
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
ShelfHeader(title: "Recently Updated")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.recentlyUpdated) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
ShelfBookCard(book: book)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Subscription feed shelf
|
||||
if !vm.subscriptionFeed.isEmpty {
|
||||
ShelfHeader(title: "From People You Follow")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.subscriptionFeed) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
SubscriptionFeedCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: item.book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "Your library is empty",
|
||||
message: "Head to Discover to find novels to read."
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
if vm.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Reading Now")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
DownloadQueueButton()
|
||||
Divider()
|
||||
.frame(height: 18)
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func shareURL(for book: Book) -> URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
let item: ContinueReadingItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard item.book.totalChapters > 0 else { return 0 }
|
||||
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||
}
|
||||
|
||||
private var progressText: String {
|
||||
let percentage = progressFraction * 100
|
||||
|
||||
// For books with many chapters, show decimal precision when less than 10%
|
||||
if percentage < 10 && percentage > 0 {
|
||||
return String(format: "%.1f%% complete", percentage)
|
||||
}
|
||||
|
||||
// Otherwise, round to nearest integer (min 1% if any progress exists)
|
||||
let rounded = max(1, Int(round(percentage)))
|
||||
return "\(rounded)% complete"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Cover
|
||||
ZStack(alignment: .bottom) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.22), radius: 8, y: 4)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// Gradient scrim so badge is always readable
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0), Color.black.opacity(0.55)],
|
||||
startPoint: .center,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.frame(height: 60)
|
||||
|
||||
// "Continue" pill badge — centered at bottom over the scrim
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 130, alignment: .leading)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Progress bar — show at least a 4pt sliver so early chapters aren't invisible
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(Color.amber.opacity(0.9))
|
||||
.frame(width: max(4, geo.size.width * progressFraction))
|
||||
}
|
||||
}
|
||||
.frame(width: 130, height: 3)
|
||||
|
||||
// Progress label with smart rounding
|
||||
Text(progressText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: recently updated book card
|
||||
|
||||
private struct ShelfBookCard: View {
|
||||
let book: Book
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: book.slug)
|
||||
|
||||
// Chapter count badge
|
||||
Text("\(book.totalChapters) ch")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Color.black.opacity(0.55)))
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
Text(book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: subscription feed card
|
||||
|
||||
private struct SubscriptionFeedCard: View {
|
||||
let item: SubscriptionFeedItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
// Tappable "via @username" attribution
|
||||
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
|
||||
Text("via @\(item.readerUsername)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats strip (compact inline)
|
||||
|
||||
private struct StatsStrip: View {
|
||||
let stats: HomeStats
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context menus
|
||||
|
||||
private struct ContinueReadingContextMenu: View {
|
||||
let item: ContinueReadingItem
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
private var isFinished: Bool {
|
||||
guard item.book.totalChapters > 0 else { return false }
|
||||
return item.chapter >= item.book.totalChapters
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
|
||||
}
|
||||
}
|
||||
391
ios/LibNovel/LibNovel/Views/Library/LibraryView.swift
Normal file
391
ios/LibNovel/LibNovel/Views/Library/LibraryView.swift
Normal file
@@ -0,0 +1,391 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct LibraryView: View {
|
||||
@StateObject private var vm = LibraryViewModel()
|
||||
@State private var sortOrder: SortOrder = .recentlyRead
|
||||
@State private var readingFilter: ReadingFilter = .all
|
||||
@State private var selectedGenre: String = "all"
|
||||
|
||||
enum SortOrder: String, CaseIterable {
|
||||
case recentlyRead = "Recent"
|
||||
case title = "Title"
|
||||
case author = "Author"
|
||||
case progress = "Progress"
|
||||
}
|
||||
|
||||
enum ReadingFilter: String, CaseIterable {
|
||||
case all = "All"
|
||||
case inProgress = "In Progress"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
// All distinct genres across the library, sorted alphabetically.
|
||||
private var availableGenres: [String] {
|
||||
let all = vm.items.flatMap { $0.book.genres }
|
||||
let unique = Array(Set(all)).sorted()
|
||||
return unique
|
||||
}
|
||||
|
||||
private var filtered: [LibraryItem] {
|
||||
var result = vm.items
|
||||
|
||||
// 1. Reading filter
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
break
|
||||
case .inProgress:
|
||||
result = result.filter { !isCompleted($0) }
|
||||
case .completed:
|
||||
result = result.filter { isCompleted($0) }
|
||||
}
|
||||
|
||||
// 2. Genre filter
|
||||
if selectedGenre != "all" {
|
||||
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
||||
}
|
||||
|
||||
// 3. Sort
|
||||
switch sortOrder {
|
||||
case .recentlyRead:
|
||||
break // server returns by recency
|
||||
case .title:
|
||||
result = result.sorted { $0.book.title < $1.book.title }
|
||||
case .author:
|
||||
result = result.sorted { $0.book.author < $1.book.author }
|
||||
case .progress:
|
||||
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func isCompleted(_ item: LibraryItem) -> Bool {
|
||||
// Treat as completed if book status is "completed" OR
|
||||
// the user has read up to (or past) the total chapter count.
|
||||
if item.book.status.lowercased() == "completed",
|
||||
let ch = item.lastChapter,
|
||||
item.book.totalChapters > 0,
|
||||
ch >= item.book.totalChapters {
|
||||
return true
|
||||
}
|
||||
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.items.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.items.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "bookmark",
|
||||
title: "No saved books",
|
||||
message: "Books you save or start reading will appear here."
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Reading filter (All / In Progress / Completed)
|
||||
Picker("", selection: $readingFilter) {
|
||||
ForEach(ReadingFilter.allCases, id: \.self) { f in
|
||||
Text(f.rawValue).tag(f)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 16)
|
||||
|
||||
// Genre filter chips (only shown when genres are available)
|
||||
if !availableGenres.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
// "All" chip
|
||||
ChipButton(
|
||||
label: "All",
|
||||
isSelected: selectedGenre == "all",
|
||||
style: .filled
|
||||
) {
|
||||
withAnimation { selectedGenre = "all" }
|
||||
}
|
||||
ForEach(availableGenres, id: \.self) { genre in
|
||||
ChipButton(
|
||||
label: genre.capitalized,
|
||||
isSelected: selectedGenre == genre,
|
||||
style: .filled
|
||||
) {
|
||||
withAnimation {
|
||||
selectedGenre = selectedGenre == genre ? "all" : genre
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
// Sort chips
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SortOrder.allCases, id: \.self) { order in
|
||||
ChipButton(
|
||||
label: order.rawValue,
|
||||
isSelected: sortOrder == order,
|
||||
style: .outlined
|
||||
) {
|
||||
withAnimation { sortOrder = order }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
|
||||
// Book count
|
||||
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if filtered.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(emptyMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else {
|
||||
// 2-column grid (matches Discover)
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)
|
||||
],
|
||||
spacing: 14
|
||||
) {
|
||||
ForEach(filtered) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
LibraryBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
BookContextMenu(
|
||||
book: item.book,
|
||||
isFinished: isCompleted(item),
|
||||
onMarkFinished: {
|
||||
Task {
|
||||
await markAsFinished(item.book)
|
||||
}
|
||||
},
|
||||
onRemove: {
|
||||
Task {
|
||||
await removeFromLibrary(item.book.slug)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emptyMessage: String {
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library."
|
||||
case .inProgress:
|
||||
return "No books in progress."
|
||||
case .completed:
|
||||
return "No completed books yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library book card (3-column)
|
||||
|
||||
private struct LibraryBookCard: View {
|
||||
let item: LibraryItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
|
||||
return Double(ch) / Double(item.book.totalChapters)
|
||||
}
|
||||
|
||||
private var isCompleted: Bool {
|
||||
progressFraction >= 1.0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
// Cover image
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay(
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// Progress arc or completed checkmark in top-right corner
|
||||
if isCompleted {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.background(Circle().fill(Color.amber).padding(1))
|
||||
.padding(6)
|
||||
} else if progressFraction > 0 {
|
||||
ProgressArc(fraction: progressFraction)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
|
||||
// Title + chapter badge
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.book.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let ch = item.lastChapter {
|
||||
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular progress arc overlay
|
||||
|
||||
private struct ProgressArc: View {
|
||||
let fraction: Double // 0...1
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: fraction)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.5), value: fraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book context menu
|
||||
|
||||
private struct BookContextMenu: View {
|
||||
let book: Book
|
||||
let isFinished: Bool
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
// Share the book detail page URL
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
}
|
||||
2060
ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift
Normal file
2060
ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift
Normal file
File diff suppressed because it is too large
Load Diff
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
@@ -0,0 +1,341 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - AvatarNavButton
|
||||
// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet.
|
||||
//
|
||||
// Usage:
|
||||
// .toolbar { AvatarToolbarButton() }
|
||||
|
||||
struct AvatarToolbarButton: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@State private var showAccount = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showAccount = true
|
||||
} label: {
|
||||
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
|
||||
}
|
||||
.sheet(isPresented: $showAccount) {
|
||||
AccountMenuSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarThumb
|
||||
// Reusable small circular avatar (used by both toolbar button and the sheet header).
|
||||
|
||||
struct AvatarThumb: View {
|
||||
let urlString: String?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let str = urlString, let url = URL(string: str) {
|
||||
KFImage(url)
|
||||
.placeholder { placeholderCircle }
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
placeholderCircle
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
|
||||
}
|
||||
|
||||
private var placeholderCircle: some View {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(Color.amber)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AccountMenuSheet
|
||||
|
||||
struct AccountMenuSheet: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showChangePassword = false
|
||||
|
||||
// Avatar upload
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage?
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
Text(authStore.user?.role.capitalized ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let err = avatarError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// ── Reading settings ───────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
Toggle("Auto-advance chapter", isOn: Binding(
|
||||
get: { authStore.settings.autoNext },
|
||||
set: { newVal in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.autoNext = newVal
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(.amber)
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
ForEach(vm.sessions) { session in
|
||||
SessionRow(session: session) {
|
||||
Task { await vm.revokeSession(id: session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
dismiss()
|
||||
Task { await authStore.logout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.task { await vm.loadSessions() }
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||
set: { if $0 == nil { pendingCropImage = nil } }
|
||||
)) { item in
|
||||
AvatarCropView(image: item.image) { croppedData in
|
||||
pendingCropImage = nil
|
||||
Task { await uploadCroppedData(croppedData) }
|
||||
} onCancel: {
|
||||
pendingCropImage = nil
|
||||
}
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
private func loadImageForCrop(_ item: PhotosPickerItem) async {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else {
|
||||
avatarError = "Could not read image"
|
||||
return
|
||||
}
|
||||
pendingCropImage = image
|
||||
}
|
||||
|
||||
private func uploadCroppedData(_ data: Data) async {
|
||||
avatarUploading = true
|
||||
avatarError = nil
|
||||
defer { avatarUploading = false }
|
||||
do {
|
||||
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||
avatarURL = url
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.amber).frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
private var voicePicker: some View {
|
||||
Picker("TTS Voice", selection: Binding(
|
||||
get: { authStore.settings.voice },
|
||||
set: { newVoice in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.voice = newVoice
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if vm.voices.isEmpty {
|
||||
Text("Default").tag("af_bella")
|
||||
} else {
|
||||
ForEach(vm.voices, id: \.self) { v in
|
||||
Text(v).tag(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.loadVoices() }
|
||||
}
|
||||
|
||||
// MARK: - Speed slider
|
||||
|
||||
@ViewBuilder
|
||||
private var speedSlider: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Playback Speed")
|
||||
Spacer()
|
||||
Text("\(authStore.settings.speed, specifier: "%.1f")×")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { authStore.settings.speed },
|
||||
set: { newSpeed in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.speed = newSpeed
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session row (local copy — mirrors ProfileView.SessionRow)
|
||||
|
||||
private struct SessionRow: View {
|
||||
let session: UserSession
|
||||
let onRevoke: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "iphone")
|
||||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if session.isCurrent {
|
||||
Text("This device")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.amber)
|
||||
} else {
|
||||
Button("Revoke", role: .destructive, action: onRevoke)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text("Last seen: \(session.lastSeen.prefix(10))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropImageItem (Identifiable wrapper for the sheet)
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
270
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
270
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AvatarCropView
|
||||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||||
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||||
|
||||
struct AvatarCropView: View {
|
||||
let image: UIImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
// Crop circle diameter (points)
|
||||
private let cropSize: CGFloat = 280
|
||||
|
||||
// Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var lastOffset: CGSize = .zero
|
||||
|
||||
// Container size captured from GeometryReader
|
||||
@State private var containerSize: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Draggable / pinchable image
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let proposed = lastScale * value
|
||||
scale = max(minScale(in: geo.size), proposed)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = scale
|
||||
clampOffset(in: geo.size)
|
||||
lastOffset = offset
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
let proposed = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
offset = clampedOffset(proposed, in: geo.size)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastOffset = offset
|
||||
}
|
||||
)
|
||||
)
|
||||
.clipped()
|
||||
|
||||
// Dim overlay with transparent crop circle cut out
|
||||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.onAppear {
|
||||
containerSize = geo.size
|
||||
fitImageInitially(in: geo.size)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Crop Photo")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel", action: onCancel)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Use Photo") {
|
||||
confirmCrop()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initial fit
|
||||
|
||||
private func fitImageInitially(in size: CGSize) {
|
||||
// The image is displayed with .scaledToFill() in the container (size).
|
||||
// That means one dimension equals the container and the other overflows.
|
||||
// We want the image to be just large enough that the crop circle is fully
|
||||
// covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize.
|
||||
//
|
||||
// .scaledToFill fills the container, so the image already covers the container.
|
||||
// The minimum scale that covers the crop square is therefore 1.0 (image already
|
||||
// fills container which is >= cropSize on both axes).
|
||||
// We keep scale = 1.0 and centre the offset.
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
|
||||
// MARK: - Clamping helpers
|
||||
|
||||
/// Minimum scale: the image (at .scaledToFill in container) must cover the crop square.
|
||||
/// At scale=1 the image already fills the container; cropSize <= container dimension,
|
||||
/// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill.
|
||||
private func minScale(in containerSize: CGSize) -> CGFloat {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
/// The displayed (fill-fitted) image size in the container at the given user scale.
|
||||
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = containerSize.width / containerSize.height
|
||||
|
||||
// .scaledToFill base size before user scale
|
||||
let baseWidth: CGFloat
|
||||
let baseHeight: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
// image is wider — height fills container
|
||||
baseHeight = containerSize.height
|
||||
baseWidth = baseHeight * imgAspect
|
||||
} else {
|
||||
// image is taller — width fills container
|
||||
baseWidth = containerSize.width
|
||||
baseHeight = baseWidth / imgAspect
|
||||
}
|
||||
return CGSize(width: baseWidth * userScale, height: baseHeight * userScale)
|
||||
}
|
||||
|
||||
/// Maximum offset so the crop square is always covered by the image.
|
||||
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||||
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||||
// Half of how much the image overflows the container on each axis
|
||||
let maxX = max(0, (displayed.width - cropSize) / 2)
|
||||
let maxY = max(0, (displayed.height - cropSize) / 2)
|
||||
return CGSize(
|
||||
width: min(maxX, max(-maxX, proposed.width)),
|
||||
height: min(maxY, max(-maxY, proposed.height))
|
||||
)
|
||||
}
|
||||
|
||||
private func clampOffset(in containerSize: CGSize) {
|
||||
offset = clampedOffset(offset, in: containerSize)
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
private func confirmCrop() {
|
||||
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||
let outputSize = CGSize(width: 400, height: 400)
|
||||
|
||||
// --- Step 1: compute the fill-fitted base display size ---
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = size.width / size.height
|
||||
|
||||
let baseDisplayW: CGFloat
|
||||
let baseDisplayH: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
baseDisplayH = size.height
|
||||
baseDisplayW = baseDisplayH * imgAspect
|
||||
} else {
|
||||
baseDisplayW = size.width
|
||||
baseDisplayH = baseDisplayW / imgAspect
|
||||
}
|
||||
|
||||
// Displayed image size after user zoom
|
||||
let displayW = baseDisplayW * scale
|
||||
let displayH = baseDisplayH * scale
|
||||
|
||||
// --- Step 2: the crop square centre is the container centre ---
|
||||
// The image centre (after offset) in container coords:
|
||||
let imageCentreX = size.width / 2 + offset.width
|
||||
let imageCentreY = size.height / 2 + offset.height
|
||||
|
||||
// Top-left of the crop square in container coords:
|
||||
let cropOriginX = (size.width - cropSize) / 2
|
||||
let cropOriginY = (size.height - cropSize) / 2
|
||||
|
||||
// Top-left of the crop square relative to the image's top-left in display space:
|
||||
let imageOriginX = imageCentreX - displayW / 2
|
||||
let imageOriginY = imageCentreY - displayH / 2
|
||||
|
||||
let cropInImageX = cropOriginX - imageOriginX // pixels in display space
|
||||
let cropInImageY = cropOriginY - imageOriginY
|
||||
|
||||
// --- Step 3: convert display-space coords to image pixel coords ---
|
||||
let displayToPixelX = image.size.width / displayW
|
||||
let displayToPixelY = image.size.height / displayH
|
||||
|
||||
let pixelX = cropInImageX * displayToPixelX
|
||||
let pixelY = cropInImageY * displayToPixelY
|
||||
let pixelW = cropSize * displayToPixelX
|
||||
let pixelH = cropSize * displayToPixelY
|
||||
|
||||
let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH)
|
||||
.intersection(CGRect(origin: .zero, size: image.size))
|
||||
|
||||
guard cropRect.width > 0, cropRect.height > 0 else {
|
||||
// Fallback: use entire image
|
||||
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||
return
|
||||
}
|
||||
|
||||
// --- Step 4: render cropped region into 400×400 ---
|
||||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||
let cropped = renderer.image { _ in
|
||||
// Draw only the cropRect portion of the image scaled to fill outputSize
|
||||
let destRect = CGRect(origin: .zero, size: outputSize)
|
||||
// UIImage.draw(in:) draws the full image; we use CGImage cropping instead
|
||||
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||||
let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation)
|
||||
croppedUI.draw(in: destRect)
|
||||
} else {
|
||||
image.draw(in: destRect)
|
||||
}
|
||||
}
|
||||
|
||||
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||||
onConfirm(jpeg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop overlay
|
||||
|
||||
private struct CropOverlay: View {
|
||||
let cropSize: CGFloat
|
||||
let containerSize: CGSize
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
// Fill entire canvas with semi-transparent black
|
||||
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||
// Cut out the crop circle in the centre
|
||||
let origin = CGPoint(
|
||||
x: (size.width - cropSize) / 2,
|
||||
y: (size.height - cropSize) / 2
|
||||
)
|
||||
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||||
context.blendMode = .destinationOut
|
||||
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
|
||||
}
|
||||
.compositingGroup()
|
||||
.overlay {
|
||||
// Amber circle border around the crop region
|
||||
let origin = CGPoint(
|
||||
x: (containerSize.width - cropSize) / 2,
|
||||
y: (containerSize.height - cropSize) / 2
|
||||
)
|
||||
Circle()
|
||||
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||||
.frame(width: cropSize, height: cropSize)
|
||||
.position(
|
||||
x: origin.x + cropSize / 2,
|
||||
y: origin.y + cropSize / 2
|
||||
)
|
||||
}
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
362
ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift
Normal file
362
ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,362 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
struct ProfileView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@State private var showChangePassword = false
|
||||
@State private var showVoiceSelection = false
|
||||
@State private var showDownloads = false
|
||||
|
||||
// Avatar upload state
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage? // image waiting to be cropped
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
Text(authStore.user?.role.capitalized ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let err = avatarError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// ── Reading settings ───────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
Toggle("Auto-advance chapter", isOn: Binding(
|
||||
get: { authStore.settings.autoNext },
|
||||
set: { newVal in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.autoNext = newVal
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(.amber)
|
||||
|
||||
Button {
|
||||
showDownloads = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Downloads")
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
ForEach(vm.sessions) { session in
|
||||
SessionRow(session: session) {
|
||||
Task { await vm.revokeSession(id: session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
Task { await authStore.logout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.task {
|
||||
await vm.loadSessions()
|
||||
}
|
||||
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.sheet(isPresented: $showVoiceSelection) {
|
||||
VoiceSelectionView(currentVoice: authStore.settings.voice)
|
||||
}
|
||||
.sheet(isPresented: $showDownloads) {
|
||||
DownloadsView()
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||
set: { if $0 == nil { pendingCropImage = nil } }
|
||||
)) { item in
|
||||
AvatarCropView(image: item.image) { croppedData in
|
||||
pendingCropImage = nil
|
||||
Task { await uploadCroppedData(croppedData) }
|
||||
} onCancel: {
|
||||
pendingCropImage = nil
|
||||
}
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
/// Step 1: Load the raw image from the picker and show the crop sheet.
|
||||
private func loadImageForCrop(_ item: PhotosPickerItem) async {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else {
|
||||
avatarError = "Could not read image"
|
||||
return
|
||||
}
|
||||
pendingCropImage = image
|
||||
}
|
||||
|
||||
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
|
||||
private func uploadCroppedData(_ data: Data) async {
|
||||
avatarUploading = true
|
||||
avatarError = nil
|
||||
defer { avatarUploading = false }
|
||||
do {
|
||||
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||
avatarURL = url
|
||||
// Refresh user record so the new avatar persists across sessions
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera overlay badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
private var voicePicker: some View {
|
||||
Button {
|
||||
showVoiceSelection = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("TTS Voice")
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text(formatVoiceLabel(authStore.settings.voice))
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatVoiceLabel(_ voice: String) -> String {
|
||||
let parts = voice.split(separator: "_")
|
||||
guard parts.count >= 2 else { return voice }
|
||||
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||
return name
|
||||
}
|
||||
|
||||
// MARK: - Speed slider
|
||||
|
||||
@ViewBuilder
|
||||
private var speedSlider: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Playback Speed")
|
||||
Spacer()
|
||||
Text("\(authStore.settings.speed, specifier: "%.1f")×")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { authStore.settings.speed },
|
||||
set: { newSpeed in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.speed = newSpeed
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session row
|
||||
|
||||
private struct SessionRow: View {
|
||||
let session: UserSession
|
||||
let onRevoke: () -> Void
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "iphone")
|
||||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if session.isCurrent {
|
||||
Text("This device")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.amber)
|
||||
} else {
|
||||
Button("Revoke", role: .destructive, action: onRevoke)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text("Last seen: \(session.lastSeen.prefix(10))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Change password sheet
|
||||
|
||||
struct ChangePasswordView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@State private var current = ""
|
||||
@State private var newPwd = ""
|
||||
@State private var confirm = ""
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
@State private var success = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField("Current password", text: $current)
|
||||
SecureField("New password", text: $newPwd)
|
||||
SecureField("Confirm new password", text: $confirm)
|
||||
}
|
||||
if let error {
|
||||
Text(error).foregroundStyle(.red).font(.caption)
|
||||
}
|
||||
if success {
|
||||
Text("Password changed successfully").foregroundStyle(.green).font(.caption)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Change Password")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") { save() }
|
||||
.disabled(isLoading || newPwd.count < 4 || newPwd != confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard newPwd == confirm else { error = "Passwords do not match"; return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
struct Body: Encodable { let currentPassword, newPassword: String }
|
||||
let _: EmptyResponse = try await APIClient.shared.fetch(
|
||||
"/api/auth/change-password", method: "POST",
|
||||
body: Body(currentPassword: current, newPassword: newPwd)
|
||||
)
|
||||
success = true
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
197
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
197
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserProfileView: View {
|
||||
let username: String
|
||||
|
||||
@StateObject private var vm: UserProfileViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
_vm = StateObject(wrappedValue: UserProfileViewModel(username: username))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading && vm.profile == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else if let profile = vm.profile {
|
||||
profileHeader(profile)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
if !vm.currentlyReading.isEmpty {
|
||||
ShelfHeader(title: "Currently Reading")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.currentlyReading) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if !vm.library.isEmpty {
|
||||
ShelfHeader(title: "Library")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.library) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "No books yet",
|
||||
message: "\(username) hasn't read anything yet."
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
} else if let err = vm.error {
|
||||
EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("@\(username)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Profile header
|
||||
|
||||
@ViewBuilder
|
||||
private func profileHeader(_ profile: PublicUserProfile) -> some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
AvatarThumb(urlString: profile.avatarUrl, size: 80)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("@\(profile.username)")
|
||||
.font(.title3.bold())
|
||||
if !profile.created.isEmpty {
|
||||
Text("Joined \(shortDate(profile.created))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: 32) {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followerCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Followers")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followingCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Following")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow button — only shown for other users (not self)
|
||||
if !profile.isSelf && authStore.isAuthenticated {
|
||||
Button {
|
||||
Task { await vm.toggleSubscribe() }
|
||||
} label: {
|
||||
if vm.isTogglingSubscribe {
|
||||
ProgressView().controlSize(.small)
|
||||
.frame(width: 120, height: 34)
|
||||
} else if profile.isSubscribed {
|
||||
Label("Following", systemImage: "checkmark")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
} else {
|
||||
Text("Follow")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(profile.isSubscribed ? Color(.systemGray4) : .amber)
|
||||
.disabled(vm.isTogglingSubscribe)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 24)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func shortDate(_ iso: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = formatter.date(from: iso) {
|
||||
let out = DateFormatter()
|
||||
out.dateStyle = .medium
|
||||
out.timeStyle = .none
|
||||
return out.string(from: date)
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book card for profile shelves
|
||||
|
||||
private struct ProfileBookCard: View {
|
||||
let item: PublicLibraryItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
|
||||
// Chapter badge (if reading)
|
||||
if let ch = item.lastChapter, ch > 0 {
|
||||
Text("Ch.\(ch)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(item.book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift
Normal file
158
ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceSelectionView: View {
|
||||
@StateObject private var vm = VoiceSelectionViewModel()
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedVoice: String
|
||||
|
||||
init(currentVoice: String) {
|
||||
_selectedVoice = State(initialValue: currentVoice)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if vm.isLoading {
|
||||
ProgressView("Loading voices...")
|
||||
} else if let error = vm.error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.amber)
|
||||
Text(error)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
voiceList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Voice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
saveAndDismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.disabled(selectedVoice == authStore.settings.voice)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadVoices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice List
|
||||
|
||||
@ViewBuilder
|
||||
private var voiceList: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(vm.voices, id: \.self) { voice in
|
||||
VoiceRow(
|
||||
voice: voice,
|
||||
isSelected: voice == selectedVoice,
|
||||
isPlaying: vm.playingVoice == voice,
|
||||
voiceLabel: vm.voiceLabel(voice),
|
||||
voiceId: vm.voiceId(voice),
|
||||
onSelect: {
|
||||
vm.stopSample()
|
||||
selectedVoice = voice
|
||||
},
|
||||
onPlaySample: {
|
||||
Task {
|
||||
await vm.playSample(voice)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Available Voices")
|
||||
} footer: {
|
||||
if selectedVoice != authStore.settings.voice {
|
||||
Text("New voice will apply to next audio playback")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func saveAndDismiss() {
|
||||
Task {
|
||||
var settings = authStore.settings
|
||||
settings.voice = selectedVoice
|
||||
await authStore.saveSettings(settings)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice Row
|
||||
|
||||
private struct VoiceRow: View {
|
||||
let voice: String
|
||||
let isSelected: Bool
|
||||
let isPlaying: Bool
|
||||
let voiceLabel: String
|
||||
let voiceId: String
|
||||
let onSelect: () -> Void
|
||||
let onPlaySample: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Selection checkmark
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(isSelected ? .amber : .secondary.opacity(0.3))
|
||||
.frame(width: 28)
|
||||
|
||||
// Voice info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(voiceLabel)
|
||||
.font(.body)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
|
||||
Text(voiceId)
|
||||
.font(.caption)
|
||||
.fontDesign(.monospaced)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Play sample button
|
||||
Button {
|
||||
onPlaySample()
|
||||
} label: {
|
||||
Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(isPlaying ? .red : .amber)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
VoiceSelectionView(currentVoice: "af_bella")
|
||||
.environmentObject(AuthStore())
|
||||
}
|
||||
286
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
286
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
@@ -0,0 +1,286 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - SearchView
|
||||
// Dedicated search tab for intentional, fuzzy search.
|
||||
// Live search as you type, shows recent searches when idle.
|
||||
|
||||
struct SearchView: View {
|
||||
@StateObject private var vm = SearchViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
// ── Content ─────────────────────────────────────────────────
|
||||
if vm.query.isEmpty && vm.results.isEmpty {
|
||||
idleContent
|
||||
} else if vm.isLoading && vm.results.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.results.isEmpty && !vm.query.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "magnifyingglass",
|
||||
title: "No results",
|
||||
message: "Try a different title or author name."
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
resultsGrid
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Search")
|
||||
.searchable(
|
||||
text: $vm.query,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search novels, authors…"
|
||||
)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, newValue in
|
||||
vm.onQueryChange(newValue)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
vm.submitSearch()
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle screen (recent searches)
|
||||
|
||||
@ViewBuilder
|
||||
private var idleContent: some View {
|
||||
if vm.recentSearches.isEmpty {
|
||||
// Empty state - prompt to search
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
Text("Search for novels")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("Find your next favorite book by title, author, or genre")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
// Recent searches list
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Recent Searches")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
Button("Clear") { vm.clearRecent() }
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
ForEach(vm.recentSearches, id: \.self) { term in
|
||||
Button {
|
||||
vm.query = term
|
||||
vm.submitSearch()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
Text(term)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
if term != vm.recentSearches.last {
|
||||
Divider()
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results grid
|
||||
|
||||
@ViewBuilder
|
||||
private var resultsGrid: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
// Result count
|
||||
HStack {
|
||||
Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)
|
||||
],
|
||||
spacing: 14
|
||||
) {
|
||||
ForEach(vm.results) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
SearchNovelCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search novel card (compact 2-column)
|
||||
|
||||
private struct SearchNovelCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if !novel.author.isEmpty {
|
||||
Text(novel.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchViewModel
|
||||
|
||||
@MainActor
|
||||
final class SearchViewModel: ObservableObject {
|
||||
@Published var query: String = ""
|
||||
@Published var results: [BrowseNovel] = []
|
||||
@Published var isLoading = false
|
||||
|
||||
// Persisted in UserDefaults (max 10 recent terms)
|
||||
@Published var recentSearches: [String] = []
|
||||
|
||||
private let recentKey = "searchRecentTerms"
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
||||
}
|
||||
|
||||
/// Called when query changes - implements debounced live search
|
||||
func onQueryChange(_ newValue: String) {
|
||||
// Cancel previous search task
|
||||
searchTask?.cancel()
|
||||
|
||||
// If query is empty, clear results
|
||||
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
results = []
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: wait 300ms before searching
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
|
||||
guard !Task.isCancelled else { return }
|
||||
await runSearch(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
func submitSearch() {
|
||||
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { return }
|
||||
saveRecent(term)
|
||||
// Cancel debounce and search immediately
|
||||
searchTask?.cancel()
|
||||
Task { await runSearch(term) }
|
||||
}
|
||||
|
||||
func clear() {
|
||||
query = ""
|
||||
results = []
|
||||
searchTask?.cancel()
|
||||
}
|
||||
|
||||
func clearRecent() {
|
||||
recentSearches = []
|
||||
UserDefaults.standard.removeObject(forKey: recentKey)
|
||||
}
|
||||
|
||||
private func runSearch(_ term: String) async {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
results = []
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
do {
|
||||
let result = try await APIClient.shared.search(query: trimmed)
|
||||
// Only update results if query hasn't changed
|
||||
if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
||||
results = result.results
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
results = []
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func saveRecent(_ term: String) {
|
||||
var list = recentSearches.filter { $0 != term }
|
||||
list.insert(term, at: 0)
|
||||
if list.count > 10 { list = Array(list.prefix(10)) }
|
||||
recentSearches = list
|
||||
UserDefaults.standard.set(list, forKey: recentKey)
|
||||
}
|
||||
}
|
||||
9
ios/LibNovel/LibNovelTests/LibNovelTests.swift
Normal file
9
ios/LibNovel/LibNovelTests/LibNovelTests.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import XCTest
|
||||
@testable import LibNovel
|
||||
|
||||
final class LibNovelTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// Placeholder — add real tests here
|
||||
XCTAssert(true)
|
||||
}
|
||||
}
|
||||
36
ios/LibNovel/fastlane/Fastfile
Normal file
36
ios/LibNovel/fastlane/Fastfile
Normal file
@@ -0,0 +1,36 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
# Generate Xcode project from project.yml (one level up from fastlane/)
|
||||
sh("cd .. && xcodegen generate --spec project.yml --project .")
|
||||
|
||||
# Set build number from CI run number (passed as env var)
|
||||
increment_build_number(
|
||||
build_number: ENV["BUILD_NUMBER"] || "1",
|
||||
xcodeproj: "LibNovel.xcodeproj"
|
||||
)
|
||||
|
||||
# Build the app - signing settings are in project.yml Release config
|
||||
build_app(
|
||||
scheme: "LibNovel",
|
||||
export_method: "app-store",
|
||||
clean: true,
|
||||
configuration: "Release",
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
teamID: "GHZXC6FVMU",
|
||||
provisioningProfiles: {
|
||||
"com.kalekber.LibNovel" => "LibNovel Distribution"
|
||||
},
|
||||
signingStyle: "manual"
|
||||
}
|
||||
)
|
||||
|
||||
# Upload to TestFlight
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
91
ios/LibNovel/project.yml
Normal file
91
ios/LibNovel/project.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
name: LibNovel
|
||||
options:
|
||||
bundleIdPrefix: com.kalekber
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
indentWidth: 4
|
||||
tabWidth: 4
|
||||
usesTabs: false
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.10"
|
||||
ENABLE_PREVIEWS: YES
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc"
|
||||
configs:
|
||||
Debug:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
|
||||
Release:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
||||
|
||||
packages:
|
||||
# Async image loading with caching
|
||||
Kingfisher:
|
||||
url: https://github.com/onevcat/Kingfisher
|
||||
from: "8.0.0"
|
||||
|
||||
targets:
|
||||
LibNovel:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- path: LibNovel
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
- "Resources/Info.plist"
|
||||
resources:
|
||||
- path: LibNovel/Resources/Assets.xcassets
|
||||
dependencies:
|
||||
- package: Kingfisher
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
INFOPLIST_FILE: LibNovel/Resources/Info.plist
|
||||
configs:
|
||||
Release:
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: GHZXC6FVMU
|
||||
CODE_SIGN_IDENTITY: "Apple Distribution"
|
||||
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
|
||||
|
||||
LibNovelTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- path: LibNovelTests
|
||||
dependencies:
|
||||
- target: LibNovel
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
|
||||
|
||||
schemes:
|
||||
LibNovel:
|
||||
build:
|
||||
targets:
|
||||
LibNovel: all
|
||||
run:
|
||||
config: Debug
|
||||
environmentVariables:
|
||||
LIBNOVEL_BASE_URL:
|
||||
value: "https://v2.libnovel.kalekber.cc"
|
||||
isEnabled: true
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- LibNovelTests
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
32
ios/LibNovel/test-build.sh
Executable file
32
ios/LibNovel/test-build.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Test script for local iOS build iteration
|
||||
# Run from ios/LibNovel directory
|
||||
|
||||
echo "=== Generating Xcode project ==="
|
||||
xcodegen generate --spec project.yml --project .
|
||||
|
||||
echo ""
|
||||
echo "=== Listing available provisioning profiles ==="
|
||||
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found"
|
||||
|
||||
echo ""
|
||||
echo "=== Listing available signing identities ==="
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
echo ""
|
||||
echo "=== Attempting archive build ==="
|
||||
xcodebuild archive \
|
||||
-project LibNovel.xcodeproj \
|
||||
-scheme LibNovel \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath ./build/LibNovel.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
DEVELOPMENT_TEAM="GHZXC6FVMU"
|
||||
|
||||
echo ""
|
||||
echo "=== Build succeeded! ==="
|
||||
19
ios/LibNovelV2/App/ContentView.swift
Normal file
19
ios/LibNovelV2/App/ContentView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Root content view
|
||||
// Switches between AuthView (unauthenticated) and RootTabView (authenticated).
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authStore.isAuthenticated {
|
||||
RootTabView()
|
||||
} else {
|
||||
AuthView()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: authStore.isAuthenticated)
|
||||
}
|
||||
}
|
||||
21
ios/LibNovelV2/App/LibNovelV2App.swift
Normal file
21
ios/LibNovelV2/App/LibNovelV2App.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct LibNovelV2App: App {
|
||||
@StateObject private var authStore = AuthStore()
|
||||
@StateObject private var audioPlayer = AudioPlayerService()
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@StateObject private var networkMonitor = NetworkMonitor()
|
||||
@StateObject private var bookVoicePrefs = BookVoicePreferences.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(authStore)
|
||||
.environmentObject(audioPlayer)
|
||||
.environmentObject(downloadService)
|
||||
.environmentObject(networkMonitor)
|
||||
.environmentObject(bookVoicePrefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
ios/LibNovelV2/App/RootTabView.swift
Normal file
90
ios/LibNovelV2/App/RootTabView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Root tab container with persistent mini-player overlay
|
||||
|
||||
struct RootTabView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var showFullPlayer: Bool = false
|
||||
@State private var readerIsActive: Bool = false
|
||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, library, browse, search, profile
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
.tag(Tab.home)
|
||||
|
||||
LibraryView()
|
||||
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
SearchView()
|
||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||
.tag(Tab.search)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Profile", systemImage: "person.fill") }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
|
||||
// Mini player bar — sits above the tab bar
|
||||
if audioPlayer.isActive && !showFullPlayer && !readerIsActive {
|
||||
MiniPlayerBar(showFullPlayer: $showFullPlayer)
|
||||
.padding(.bottom, 49)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
}
|
||||
|
||||
// Full player — slides up from the bottom
|
||||
if showFullPlayer {
|
||||
FullPlayerView(onDismiss: {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
showFullPlayer = false
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
})
|
||||
.offset(y: max(fullPlayerDragOffset, 0))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
if value.translation.height > 0 {
|
||||
fullPlayerDragOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let velocity = value.predictedEndTranslation.height - value.translation.height
|
||||
if value.translation.height > 120 || velocity > 400 {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
showFullPlayer = false
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
.onPreferenceChange(HideMiniPlayerKey.self) { hide in
|
||||
readerIsActive = hide
|
||||
}
|
||||
}
|
||||
}
|
||||
138
ios/LibNovelV2/Extensions/NavDestination.swift
Normal file
138
ios/LibNovelV2/Extensions/NavDestination.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Navigation destination enum
|
||||
|
||||
enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
case userProfile(String) // username
|
||||
case browseCategory(sort: String, genre: String, status: String, title: String)
|
||||
}
|
||||
|
||||
// MARK: - View helpers
|
||||
|
||||
extension View {
|
||||
/// Registers app-wide navigationDestination for NavDestination values.
|
||||
/// Apply once per NavigationStack.
|
||||
func appNavigationDestination() -> some View {
|
||||
modifier(AppNavigationDestinationModifier())
|
||||
}
|
||||
|
||||
/// Standard "Error" alert driven by an optional String binding.
|
||||
/// Suppresses network errors silently when offline (banner handles them).
|
||||
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||
modifier(ErrorAlertModifier(error: error))
|
||||
}
|
||||
|
||||
/// Signal to the root overlay that the mini player should be hidden.
|
||||
func hideMiniPlayer() -> some View {
|
||||
preference(key: HideMiniPlayerKey.self, value: true)
|
||||
}
|
||||
|
||||
/// Marks a cover image as the zoom source for a book navigation transition (iOS 18+).
|
||||
func bookCoverZoomSource(slug: String) -> some View {
|
||||
modifier(BookCoverZoomSource(slug: slug))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error alert modifier
|
||||
|
||||
private struct ErrorAlertModifier: ViewModifier {
|
||||
@Binding var error: String?
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
private var shouldShowAlert: Bool {
|
||||
guard let msg = error else { return false }
|
||||
if !networkMonitor.isConnected {
|
||||
let keywords = ["internet", "offline", "network", "connection", "unreachable", "timed out", "no data"]
|
||||
if keywords.contains(where: { msg.lowercased().contains($0) }) {
|
||||
DispatchQueue.main.async { self.error = nil }
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert("Error", isPresented: Binding(
|
||||
get: { shouldShowAlert },
|
||||
set: { if !$0 { error = nil } }
|
||||
)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation destination modifier
|
||||
|
||||
private struct AppNavigationDestinationModifier: ViewModifier {
|
||||
@Namespace private var zoomNamespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug):
|
||||
BookDetailView(slug: slug)
|
||||
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||
case .chapter(let slug, let n):
|
||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username):
|
||||
UserProfileView(username: username)
|
||||
case .browseCategory(let sort, let genre, let status, let title):
|
||||
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||
}
|
||||
}
|
||||
.environment(\.bookZoomNamespace, zoomNamespace)
|
||||
} else {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username): UserProfileView(username: username)
|
||||
case .browseCategory(let sort, let genre, let status, let title):
|
||||
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment key: zoom namespace
|
||||
|
||||
struct BookZoomNamespaceKey: EnvironmentKey {
|
||||
static var defaultValue: Namespace.ID? { nil }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var bookZoomNamespace: Namespace.ID? {
|
||||
get { self[BookZoomNamespaceKey.self] }
|
||||
set { self[BookZoomNamespaceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preference key: hide mini player
|
||||
|
||||
struct HideMiniPlayerKey: PreferenceKey {
|
||||
static var defaultValue = false
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) { value = value || nextValue() }
|
||||
}
|
||||
|
||||
// MARK: - Cover zoom source modifier
|
||||
|
||||
struct BookCoverZoomSource: ViewModifier {
|
||||
let slug: String
|
||||
@Environment(\.bookZoomNamespace) private var namespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *), let ns = namespace {
|
||||
content.matchedTransitionSource(id: slug, in: ns)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
19
ios/LibNovelV2/Extensions/String+App.swift
Normal file
19
ios/LibNovelV2/Extensions/String+App.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Strips trailing date parentheticals from chapter titles.
|
||||
/// Handles formats like:
|
||||
/// " (January 5, 2025)"
|
||||
/// " - Jan 01 2024"
|
||||
func strippingTrailingDate() -> String {
|
||||
let patterns = [
|
||||
#"\s*\([A-Za-z]+ \d{1,2},\s+\d{4}\)\s*$"#,
|
||||
#"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#,
|
||||
]
|
||||
var result = self
|
||||
for pattern in patterns {
|
||||
result = result.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
|
||||
}
|
||||
return result.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
577
ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj
Normal file
577
ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,577 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */; };
|
||||
280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */; };
|
||||
29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */; };
|
||||
2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2634D20198A966396121230 /* LibraryView.swift */; };
|
||||
30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */; };
|
||||
43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */; };
|
||||
464782001051686356AF728B /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736DA6CB7D7759E1791F6236 /* SearchView.swift */; };
|
||||
4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378336A1684E738283821857 /* ContentView.swift */; };
|
||||
5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A3006B972DFF660959FE3 /* APIClient.swift */; };
|
||||
6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37A1BCABF9787BA6E243C8F /* ProfileView.swift */; };
|
||||
64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */; };
|
||||
7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C68D19B123EC191D53A694E /* BookDetailView.swift */; };
|
||||
78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D006B236F6FE653131FFD2 /* PlayerViews.swift */; };
|
||||
792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125054A25A37A42295D49B10 /* NetworkMonitor.swift */; };
|
||||
7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF859D4970913FEBA89CB0F /* CommonViews.swift */; };
|
||||
9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F48756100041DE38F573449 /* ChapterReaderView.swift */; };
|
||||
9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */; };
|
||||
A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880A0B86A80386BEA76FF388 /* NavDestination.swift */; };
|
||||
B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */; };
|
||||
ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */; };
|
||||
ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92BE9AB59740382D85BD5296 /* DownloadsView.swift */; };
|
||||
ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */; };
|
||||
B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5E37231B0150A128C72D49 /* HomeView.swift */; };
|
||||
B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC088495FFC3053AAE0F124 /* RootTabView.swift */; };
|
||||
BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D715E3B2A6FE40FB628ADD2D /* AuthView.swift */; };
|
||||
C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB713100B2B1F429924A107C /* VoiceSelectionView.swift */; };
|
||||
DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */; };
|
||||
E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */; };
|
||||
E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */; };
|
||||
F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98B6C380A20E783F1F7A7DB /* AuthStore.swift */; };
|
||||
F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B8078366569A958BE54D23 /* BrowseViewModel.swift */; };
|
||||
FC954C552CC0BDFB619BF207 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4180EB2AEECC51E4A7F5231 /* Models.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
06C95D52A96318B6CAD22EB0 /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||
125054A25A37A42295D49B10 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||
1548C08BADD28B057A9DFD5F /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||
2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; };
|
||||
378336A1684E738283821857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
3C5E37231B0150A128C72D49 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
3EF859D4970913FEBA89CB0F /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
|
||||
4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
|
||||
4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
5C68D19B123EC191D53A694E /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||
71D006B236F6FE653131FFD2 /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||
72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
|
||||
736DA6CB7D7759E1791F6236 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
7F4A3006B972DFF660959FE3 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
84D88622224F38A541CE9F8D /* LibNovelV2App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelV2App.swift; sourceTree = "<group>"; };
|
||||
880A0B86A80386BEA76FF388 /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
|
||||
C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
8F48756100041DE38F573449 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
|
||||
92BE9AB59740382D85BD5296 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
|
||||
930C6A69F3E601E2297071CD /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
94CB555099A941E16AD0531A /* LibNovelV2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovelV2.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCategoryView.swift; sourceTree = "<group>"; };
|
||||
B4180EB2AEECC51E4A7F5231 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
BFC088495FFC3053AAE0F124 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||
D37A1BCABF9787BA6E243C8F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
D715E3B2A6FE40FB628ADD2D /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
DB713100B2B1F429924A107C /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; };
|
||||
F2634D20198A966396121230 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
F2B8078366569A958BE54D23 /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||
F98B6C380A20E783F1F7A7DB /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||
FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
03533E32FF0C2EAF1915AD15 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
94CB555099A941E16AD0531A /* LibNovelV2.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
19F98554C19DCB1FD6ED835E /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */,
|
||||
930C6A69F3E601E2297071CD /* AudioPlayerService.swift */,
|
||||
F98B6C380A20E783F1F7A7DB /* AuthStore.swift */,
|
||||
2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */,
|
||||
125054A25A37A42295D49B10 /* NetworkMonitor.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
20E9B4B0C0EDDB3313149544 /* Common */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3EF859D4970913FEBA89CB0F /* CommonViews.swift */,
|
||||
);
|
||||
path = Common;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
25D179F65B0041EE826DEF5B /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
378336A1684E738283821857 /* ContentView.swift */,
|
||||
84D88622224F38A541CE9F8D /* LibNovelV2App.swift */,
|
||||
BFC088495FFC3053AAE0F124 /* RootTabView.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2F4B97A2A2234F71AE2C46B2 /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3C5E37231B0150A128C72D49 /* HomeView.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
36240FA179A3701F15D1AAE1 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
880A0B86A80386BEA76FF388 /* NavDestination.swift */,
|
||||
C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3A6125CA86E249F3D6DC7F8C /* BookDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C68D19B123EC191D53A694E /* BookDetailView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
448620B67D4AEEEF2CAED3C0 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4180EB2AEECC51E4A7F5231 /* Models.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
716D22431B17611F7A418D9F /* Profile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D37A1BCABF9787BA6E243C8F /* ProfileView.swift */,
|
||||
1548C08BADD28B057A9DFD5F /* UserProfileView.swift */,
|
||||
DB713100B2B1F429924A107C /* VoiceSelectionView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8BCE05349B706BF8EE0E16DD /* LibNovelV2 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = LibNovelV2;
|
||||
path = .;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9AFE0816FF2E9D8DBBA470BD /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
92BE9AB59740382D85BD5296 /* DownloadsView.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9CFE23EEA1B9E264A36D0FC4 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
736DA6CB7D7759E1791F6236 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9E5A2471B9D5ECAF6B65FD22 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */,
|
||||
F2B8078366569A958BE54D23 /* BrowseViewModel.swift */,
|
||||
4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */,
|
||||
4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */,
|
||||
96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */,
|
||||
FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A05A1FE213A8E179B2302EF2 /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D715E3B2A6FE40FB628ADD2D /* AuthView.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA1F8D9C3DA40A1ADCF2B432 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
25D179F65B0041EE826DEF5B /* App */,
|
||||
36240FA179A3701F15D1AAE1 /* Extensions */,
|
||||
8BCE05349B706BF8EE0E16DD /* LibNovelV2 */,
|
||||
448620B67D4AEEEF2CAED3C0 /* Models */,
|
||||
AFDC950B142FEDA471F394EC /* Networking */,
|
||||
C468271A8BC443D1B82A1BE0 /* Resources */,
|
||||
19F98554C19DCB1FD6ED835E /* Services */,
|
||||
9E5A2471B9D5ECAF6B65FD22 /* ViewModels */,
|
||||
CBC1A32FA53E9B3D5E15995D /* Views */,
|
||||
03533E32FF0C2EAF1915AD15 /* Products */,
|
||||
);
|
||||
indentWidth = 4;
|
||||
sourceTree = "<group>";
|
||||
tabWidth = 4;
|
||||
usesTabs = 0;
|
||||
};
|
||||
AF1FE530FDE94947D4966251 /* ChapterReader */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8F48756100041DE38F573449 /* ChapterReaderView.swift */,
|
||||
);
|
||||
path = ChapterReader;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AFDC950B142FEDA471F394EC /* Networking */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7F4A3006B972DFF660959FE3 /* APIClient.swift */,
|
||||
);
|
||||
path = Networking;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BFA030D1CE2D312C539318DA /* Browse */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */,
|
||||
06C95D52A96318B6CAD22EB0 /* BrowseView.swift */,
|
||||
);
|
||||
path = Browse;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C468271A8BC443D1B82A1BE0 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CBC1A32FA53E9B3D5E15995D /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A05A1FE213A8E179B2302EF2 /* Auth */,
|
||||
3A6125CA86E249F3D6DC7F8C /* BookDetail */,
|
||||
BFA030D1CE2D312C539318DA /* Browse */,
|
||||
AF1FE530FDE94947D4966251 /* ChapterReader */,
|
||||
20E9B4B0C0EDDB3313149544 /* Common */,
|
||||
9AFE0816FF2E9D8DBBA470BD /* Downloads */,
|
||||
2F4B97A2A2234F71AE2C46B2 /* Home */,
|
||||
ED5843EA1B9CB1AD97664571 /* Library */,
|
||||
F9025CCFC608DCEB21B4D9F5 /* Player */,
|
||||
716D22431B17611F7A418D9F /* Profile */,
|
||||
9CFE23EEA1B9E264A36D0FC4 /* Search */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED5843EA1B9CB1AD97664571 /* Library */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F2634D20198A966396121230 /* LibraryView.swift */,
|
||||
);
|
||||
path = Library;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F9025CCFC608DCEB21B4D9F5 /* Player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
71D006B236F6FE653131FFD2 /* PlayerViews.swift */,
|
||||
);
|
||||
path = Player;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
7EEA688C50B734EA22C04CF1 /* LibNovelV2 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */;
|
||||
buildPhases = (
|
||||
BE6BEAD873B53447AABD2346 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = LibNovelV2;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = LibNovelV2;
|
||||
productReference = 94CB555099A941E16AD0531A /* LibNovelV2.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1AC8476B8E9026EB9CE2B4FF /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
};
|
||||
buildConfigurationList = 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = AA1F8D9C3DA40A1ADCF2B432;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 03533E32FF0C2EAF1915AD15 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
7EEA688C50B734EA22C04CF1 /* LibNovelV2 */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
BE6BEAD873B53447AABD2346 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */,
|
||||
280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */,
|
||||
E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */,
|
||||
F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */,
|
||||
BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */,
|
||||
7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */,
|
||||
30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */,
|
||||
ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */,
|
||||
43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */,
|
||||
ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */,
|
||||
F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */,
|
||||
9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */,
|
||||
64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */,
|
||||
7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */,
|
||||
4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */,
|
||||
ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */,
|
||||
B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */,
|
||||
075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */,
|
||||
9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */,
|
||||
2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */,
|
||||
DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */,
|
||||
FC954C552CC0BDFB619BF207 /* Models.swift in Sources */,
|
||||
A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */,
|
||||
B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */,
|
||||
792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */,
|
||||
78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */,
|
||||
6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */,
|
||||
B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */,
|
||||
464782001051686356AF728B /* SearchView.swift in Sources */,
|
||||
29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */,
|
||||
E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */,
|
||||
C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
019B1386650D49B9F4F6CCF7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.10;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
086D97837CBA0A9177D50BB2 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2;
|
||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1A953D152E39A2F172BB4DE4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C91972DB753AE2CF04BED70E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.10;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A953D152E39A2F172BB4DE4 /* Debug */,
|
||||
086D97837CBA0A9177D50BB2 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C91972DB753AE2CF04BED70E /* Debug */,
|
||||
019B1386650D49B9F4F6CCF7 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 1AC8476B8E9026EB9CE2B4FF /* Project object */;
|
||||
}
|
||||
7
ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||
BuildableName = "LibNovelV2.app"
|
||||
BlueprintName = "LibNovelV2"
|
||||
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||
BuildableName = "LibNovelV2.app"
|
||||
BlueprintName = "LibNovelV2"
|
||||
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||
BuildableName = "LibNovelV2.app"
|
||||
BlueprintName = "LibNovelV2"
|
||||
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "LIBNOVEL_BASE_URL"
|
||||
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||
BuildableName = "LibNovelV2.app"
|
||||
BlueprintName = "LibNovelV2"
|
||||
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
417
ios/LibNovelV2/Models/Models.swift
Normal file
417
ios/LibNovelV2/Models/Models.swift
Normal file
@@ -0,0 +1,417 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Book
|
||||
|
||||
struct Book: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String
|
||||
let cover: String // proxied via /api/cover/...
|
||||
let status: String
|
||||
let genres: [String]
|
||||
let summary: String
|
||||
let totalChapters: Int
|
||||
let sourceURL: String
|
||||
let ranking: Int
|
||||
let metaUpdated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, title, author, cover, status, genres, summary, ranking
|
||||
case totalChapters = "total_chapters"
|
||||
case sourceURL = "source_url"
|
||||
case metaUpdated = "meta_updated"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
slug = try c.decode(String.self, forKey: .slug)
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||
totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
|
||||
sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
|
||||
ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
|
||||
metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
|
||||
summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? ""
|
||||
|
||||
// genres can arrive as a JSON-encoded string or a real array
|
||||
if let arr = try? c.decode([String].self, forKey: .genres) {
|
||||
genres = arr
|
||||
} else if let raw = try? c.decode(String.self, forKey: .genres),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
genres = arr
|
||||
} else {
|
||||
genres = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapter index
|
||||
|
||||
struct ChapterIndex: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let number: Int
|
||||
let title: String
|
||||
let dateLabel: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, number, title
|
||||
case dateLabel = "date_label"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterBrief: Identifiable, Codable, Hashable {
|
||||
var id: Int { number }
|
||||
let number: Int
|
||||
let title: String
|
||||
}
|
||||
|
||||
// Full chapter response from /api/chapter-text/{slug}/{n}
|
||||
struct ChapterResponse: Decodable {
|
||||
struct BookBrief: Decodable {
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
}
|
||||
struct ChapterDetail: Decodable {
|
||||
let number: Int
|
||||
let title: String
|
||||
let dateLabel: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case number, title
|
||||
case dateLabel = "date_label"
|
||||
}
|
||||
}
|
||||
|
||||
let book: BookBrief
|
||||
let chapter: ChapterDetail
|
||||
let chapters: [ChapterBrief]
|
||||
let html: String
|
||||
let text: String
|
||||
let prev: Int?
|
||||
let next: Int?
|
||||
}
|
||||
|
||||
// MARK: - Ranking
|
||||
|
||||
struct RankingItem: Codable, Identifiable {
|
||||
var id: String { slug }
|
||||
let rank: Int
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String
|
||||
let cover: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
let sourceURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case rank, slug, title, author, cover, status, genres
|
||||
case sourceURL = "source_url"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse listing
|
||||
|
||||
struct NovelListing: Codable, Identifiable {
|
||||
var id: String { slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String?
|
||||
let cover: String?
|
||||
let status: String?
|
||||
let genres: [String]?
|
||||
let rank: Int?
|
||||
let rating: String?
|
||||
let chapters: String? // e.g. "123 chapters"
|
||||
let url: String?
|
||||
let sourceURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, author, cover, status, genres, rank, rating, chapters, url
|
||||
case sourceURL = "source_url"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
struct HomeStats: Codable {
|
||||
let totalBooks: Int
|
||||
let totalChapters: Int
|
||||
let booksInProgress: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalBooks = "total_books"
|
||||
case totalChapters = "total_chapters"
|
||||
case booksInProgress = "books_in_progress"
|
||||
}
|
||||
}
|
||||
|
||||
struct ContinueReadingItem: Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let chapter: Int
|
||||
}
|
||||
|
||||
struct SubscriptionFeedItem: Identifiable, Decodable {
|
||||
var id: String { book.id + readerUsername }
|
||||
let book: Book
|
||||
let readerUsername: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case readerUsername = "readerUsername"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User
|
||||
|
||||
struct AppUser: Codable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let role: String
|
||||
let created: String
|
||||
let avatarURL: String?
|
||||
|
||||
var isAdmin: Bool { role == "admin" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, role, created
|
||||
case avatarURL = "avatar_url"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
|
||||
}
|
||||
|
||||
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.role = role
|
||||
self.created = created
|
||||
self.avatarURL = avatarURL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User settings
|
||||
|
||||
struct UserSettings: Codable {
|
||||
var autoNext: Bool
|
||||
var voice: String
|
||||
var speed: Double
|
||||
|
||||
static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
struct UserSession: Codable, Identifiable {
|
||||
let id: String
|
||||
let userAgent: String
|
||||
let ip: String
|
||||
let createdAt: String
|
||||
let lastSeen: String
|
||||
var isCurrent: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, ip
|
||||
case userAgent = "user_agent"
|
||||
case createdAt = "created_at"
|
||||
case lastSeen = "last_seen"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
struct BookComment: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let userId: String
|
||||
let username: String
|
||||
let body: String
|
||||
var upvotes: Int
|
||||
var downvotes: Int
|
||||
let created: String
|
||||
let parentId: String
|
||||
var replies: [BookComment]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, username, body, upvotes, downvotes, created, replies
|
||||
case userId = "user_id"
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
|
||||
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
|
||||
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
|
||||
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsResponse: Decodable {
|
||||
let comments: [BookComment]
|
||||
let myVotes: [String: String]
|
||||
let avatarUrls: [String: String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case comments, myVotes, avatarUrls
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
comments = try c.decode([BookComment].self, forKey: .comments)
|
||||
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
|
||||
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public user profile
|
||||
|
||||
struct PublicUserProfile: Decodable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let avatarUrl: String?
|
||||
let created: String
|
||||
let followerCount: Int
|
||||
let followingCount: Int
|
||||
let isSubscribed: Bool
|
||||
let isSelf: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, created
|
||||
case avatarUrl = "avatarUrl"
|
||||
case followerCount = "followerCount"
|
||||
case followingCount = "followingCount"
|
||||
case isSubscribed = "isSubscribed"
|
||||
case isSelf = "isSelf"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
|
||||
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
|
||||
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicLibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let lastChapter: Int?
|
||||
let saved: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case lastChapter = "last_chapter"
|
||||
case saved
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicUserLibraryResponse: Decodable {
|
||||
let currentlyReading: [PublicLibraryItem]
|
||||
let library: [PublicLibraryItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentlyReading = "currently_reading"
|
||||
case library
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reader Settings (local — UserDefaults)
|
||||
|
||||
enum ReaderTheme: String, CaseIterable, Codable {
|
||||
case white, sepia, night
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
||||
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
||||
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 0.10, opacity: 1)
|
||||
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
||||
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .white: return nil
|
||||
case .sepia: return .light
|
||||
case .night: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderFont: String, CaseIterable, Codable {
|
||||
case system = "System"
|
||||
case georgia = "Georgia"
|
||||
case newYork = "New York"
|
||||
|
||||
var fontName: String? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .georgia: return "Georgia"
|
||||
case .newYork: return "NewYorkMedium-Regular"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderSettings: Codable, Equatable {
|
||||
var fontSize: CGFloat = 17
|
||||
var lineSpacing: CGFloat = 1.7
|
||||
var font: ReaderFont = .system
|
||||
var theme: ReaderTheme = .white
|
||||
var scrollMode: Bool = false
|
||||
|
||||
private static let key = "v2.readerSettings"
|
||||
|
||||
static func load() -> ReaderSettings {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||
else { return ReaderSettings() }
|
||||
return decoded
|
||||
}
|
||||
|
||||
func save() {
|
||||
if let data = try? JSONEncoder().encode(self) {
|
||||
UserDefaults.standard.set(data, forKey: ReaderSettings.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio prefetch status
|
||||
|
||||
enum NextPrefetchStatus {
|
||||
case none, prefetching, prefetched, failed
|
||||
}
|
||||
520
ios/LibNovelV2/Networking/APIClient.swift
Normal file
520
ios/LibNovelV2/Networking/APIClient.swift
Normal file
@@ -0,0 +1,520 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Client
|
||||
// Communicates with the SvelteKit UI server (/api/* endpoints).
|
||||
// Auth is carried via the libnovel_auth cookie (HMAC-signed token).
|
||||
|
||||
actor APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
var baseURL: URL
|
||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||
|
||||
private let session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
private init() {
|
||||
let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
baseURL = URL(string: urlString)!
|
||||
}
|
||||
|
||||
// MARK: - Auth cookie management
|
||||
|
||||
func setAuthCookie(_ value: String?) {
|
||||
authCookie = value
|
||||
if let value {
|
||||
let cookieProps: [HTTPCookiePropertyKey: Any] = [
|
||||
.name: "libnovel_auth",
|
||||
.value: value,
|
||||
.domain: baseURL.host ?? "localhost",
|
||||
.path: "/"
|
||||
]
|
||||
if let cookie = HTTPCookie(properties: cookieProps) {
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
} else {
|
||||
let storage = HTTPCookieStorage.shared
|
||||
storage.cookies(for: baseURL)?.forEach { storage.deleteCookie($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Low-level request builder
|
||||
|
||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = URL(string: urlString) else { throw APIError.invalidResponse }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// MARK: - Generic fetch
|
||||
|
||||
func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>"
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder.apiDecoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>"
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
private struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let token: String
|
||||
let user: AppUser
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> LoginResponse {
|
||||
try await fetch("/api/auth/login", method: "POST",
|
||||
body: LoginRequest(username: username, password: password))
|
||||
}
|
||||
|
||||
func register(username: String, password: String) async throws -> LoginResponse {
|
||||
try await fetch("/api/auth/register", method: "POST",
|
||||
body: LoginRequest(username: username, password: password))
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||
setAuthCookie(nil)
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
func homeData() async throws -> HomeDataResponse {
|
||||
try await fetch("/api/home")
|
||||
}
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
func library() async throws -> [LibraryItem] {
|
||||
try await fetch("/api/library")
|
||||
}
|
||||
|
||||
func saveBook(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST")
|
||||
}
|
||||
|
||||
func unsaveBook(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Book Detail
|
||||
|
||||
func bookDetail(slug: String) async throws -> BookDetailResponse {
|
||||
try await fetch("/api/book/\(slug)")
|
||||
}
|
||||
|
||||
// MARK: - Chapter
|
||||
|
||||
func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse {
|
||||
try await fetch("/api/chapter/\(slug)/\(chapter)")
|
||||
}
|
||||
|
||||
// MARK: - Browse
|
||||
|
||||
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
||||
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||
return try await fetch("/api/browse-page\(query)")
|
||||
}
|
||||
|
||||
func search(query: String) async throws -> SearchResponse {
|
||||
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
||||
return try await fetch("/api/search?q=\(encoded)")
|
||||
}
|
||||
|
||||
func ranking() async throws -> [RankingItem] {
|
||||
try await fetch("/api/ranking")
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
func progress() async throws -> [ProgressEntry] {
|
||||
try await fetch("/api/progress")
|
||||
}
|
||||
|
||||
func setProgress(slug: String, chapter: Int) async throws {
|
||||
struct Body: Encodable { let chapter: Int }
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
||||
}
|
||||
|
||||
func deleteProgress(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||
struct Response: Decodable {
|
||||
let audioTime: Double?
|
||||
enum CodingKeys: String, CodingKey { case audioTime = "audio_time" }
|
||||
}
|
||||
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||
return r.audioTime
|
||||
}
|
||||
|
||||
func setAudioTime(slug: String, chapter: Int, time: Double) async throws {
|
||||
struct Body: Encodable {
|
||||
let slug: String; let chapter: Int; let audioTime: Double
|
||||
enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" }
|
||||
}
|
||||
let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH",
|
||||
body: Body(slug: slug, chapter: chapter, audioTime: time))
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse {
|
||||
struct Body: Encodable { let voice: String; let speed: Double }
|
||||
return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed))
|
||||
}
|
||||
|
||||
/// Poll until the TTS job is done, failed, or the task is cancelled.
|
||||
/// Returns the playback URL on success.
|
||||
func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||
let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)"
|
||||
struct StatusResponse: Decodable {
|
||||
let status: String
|
||||
let url: String?
|
||||
let error: String?
|
||||
}
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let r: StatusResponse = try await fetch(path)
|
||||
switch r.status {
|
||||
case "done":
|
||||
guard let url = r.url, !url.isEmpty else { throw URLError(.badServerResponse) }
|
||||
return url
|
||||
case "failed":
|
||||
throw NSError(domain: "AudioGeneration", code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"])
|
||||
default:
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||
struct Response: Decodable { let url: String }
|
||||
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)")
|
||||
return r.url
|
||||
}
|
||||
|
||||
func presignVoiceSample(voice: String) async throws -> String {
|
||||
struct Response: Decodable { let url: String }
|
||||
let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)")
|
||||
return r.url
|
||||
}
|
||||
|
||||
func voices() async throws -> [String] {
|
||||
struct Response: Decodable { let voices: [String] }
|
||||
let r: Response = try await fetch("/api/voices")
|
||||
return r.voices
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func settings() async throws -> UserSettings {
|
||||
try await fetch("/api/settings")
|
||||
}
|
||||
|
||||
func updateSettings(_ settings: UserSettings) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings)
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func sessions() async throws -> [UserSession] {
|
||||
struct Response: Decodable { let sessions: [UserSession] }
|
||||
let r: Response = try await fetch("/api/sessions")
|
||||
return r.sessions
|
||||
}
|
||||
|
||||
func revokeSession(id: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
struct AvatarPresignResponse: Decodable {
|
||||
let uploadURL: String
|
||||
let key: String
|
||||
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||
}
|
||||
|
||||
struct AvatarResponse: Decodable {
|
||||
let avatarURL: String?
|
||||
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||
}
|
||||
|
||||
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||
let presign: AvatarPresignResponse = try await fetch(
|
||||
"/api/profile/avatar", method: "POST", body: ["mime_type": mimeType])
|
||||
|
||||
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||
var putReq = URLRequest(url: putURL)
|
||||
putReq.httpMethod = "PUT"
|
||||
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
putReq.httpBody = imageData
|
||||
let (_, putResp) = try await session.data(for: putReq)
|
||||
guard let putHttp = putResp as? HTTPURLResponse, (200..<300).contains(putHttp.statusCode) else {
|
||||
throw APIError.httpError((putResp as? HTTPURLResponse)?.statusCode ?? 0, "MinIO PUT failed")
|
||||
}
|
||||
|
||||
let result: AvatarResponse = try await fetch("/api/profile/avatar", method: "PATCH", body: ["key": presign.key])
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
func fetchAvatarPresignedURL() async throws -> String? {
|
||||
let result: AvatarResponse = try await fetch("/api/profile/avatar")
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
// MARK: - User Profiles & Subscriptions
|
||||
|
||||
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
|
||||
try await fetch("/api/users/\(username)")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func unsubscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
|
||||
try await fetch("/api/users/\(username)/library")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||
try await fetch("/api/comments/\(slug)?sort=\(sort)")
|
||||
}
|
||||
|
||||
private struct PostCommentBody: Encodable {
|
||||
let body: String
|
||||
let parent_id: String?
|
||||
}
|
||||
|
||||
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(slug)", method: "POST",
|
||||
body: PostCommentBody(body: body, parent_id: parentId))
|
||||
}
|
||||
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
struct VoteBody: Encodable { let vote: String }
|
||||
return try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
}
|
||||
|
||||
func deleteComment(commentId: String) async throws {
|
||||
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response types
|
||||
|
||||
struct HomeDataResponse: Decodable {
|
||||
struct ContinueItem: Decodable {
|
||||
let book: Book
|
||||
let chapter: Int
|
||||
}
|
||||
let continueReading: [ContinueItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
let subscriptionFeed: [SubscriptionFeedItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case continueReading = "continue_reading"
|
||||
case recentlyUpdated = "recently_updated"
|
||||
case stats
|
||||
case subscriptionFeed = "subscription_feed"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
|
||||
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
|
||||
stats = try c.decode(HomeStats.self, forKey: .stats)
|
||||
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let savedAt: String
|
||||
let lastChapter: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case savedAt = "saved_at"
|
||||
case lastChapter = "last_chapter"
|
||||
}
|
||||
}
|
||||
|
||||
struct BookDetailResponse: Decodable {
|
||||
let book: Book
|
||||
let chapters: [ChapterIndex]
|
||||
let inLib: Bool
|
||||
let saved: Bool
|
||||
let lastChapter: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book, chapters
|
||||
case inLib = "in_lib"
|
||||
case saved
|
||||
case lastChapter = "last_chapter"
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowseResponse: Decodable {
|
||||
let novels: [BrowseNovel]
|
||||
let page: Int
|
||||
let hasNext: Bool
|
||||
}
|
||||
|
||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||
var id: String { slug.isEmpty ? url : slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
let rank: String
|
||||
let rating: String
|
||||
let chapters: String
|
||||
let url: String
|
||||
let author: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, cover, rank, rating, chapters, url, author, status, genres
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||
rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? ""
|
||||
rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? ""
|
||||
chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? ""
|
||||
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||
genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResponse: Decodable {
|
||||
let results: [BrowseNovel]
|
||||
let localCount: Int
|
||||
let remoteCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case results
|
||||
case localCount = "local_count"
|
||||
case remoteCount = "remote_count"
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioTriggerResponse: Decodable {
|
||||
let jobId: String?
|
||||
let status: String?
|
||||
let url: String?
|
||||
let filename: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobId = "job_id"
|
||||
case status, url, filename
|
||||
}
|
||||
|
||||
var isAsync: Bool { jobId != nil }
|
||||
}
|
||||
|
||||
struct ProgressEntry: Decodable, Identifiable {
|
||||
var id: String { slug }
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let audioTime: Double?
|
||||
let updated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, chapter, updated
|
||||
case audioTime = "audio_time"
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
|
||||
// MARK: - API Error
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(Int, String)
|
||||
case decodingError(Error)
|
||||
case unauthorized
|
||||
case networkError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "Invalid server response"
|
||||
case .httpError(let code, let m): return "HTTP \(code): \(m)"
|
||||
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
|
||||
case .unauthorized: return "Not authenticated"
|
||||
case .networkError(let e): return e.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSONDecoder helper
|
||||
|
||||
extension JSONDecoder {
|
||||
static let apiDecoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "0.043", "green": "0.620", "red": "0.961" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
3
ios/LibNovelV2/Resources/Assets.xcassets/Contents.json
Normal file
3
ios/LibNovelV2/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
45
ios/LibNovelV2/Resources/Info.plist
Normal file
45
ios/LibNovelV2/Resources/Info.plist
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LIBNOVEL_BASE_URL</key>
|
||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
230
ios/LibNovelV2/Services/AudioDownloadService.swift
Normal file
230
ios/LibNovelV2/Services/AudioDownloadService.swift
Normal file
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - AudioDownloadService
|
||||
// Manages offline TTS audio downloads with progress tracking.
|
||||
// Uses a background URLSession so downloads survive app suspension.
|
||||
// Keys use "::" separator (slugs contain hyphens).
|
||||
|
||||
@MainActor
|
||||
final class AudioDownloadService: NSObject, ObservableObject {
|
||||
static let shared = AudioDownloadService()
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
@Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice"
|
||||
@Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice"
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var session: URLSession!
|
||||
private var activeTasks: [String: URLSessionDownloadTask] = [:]
|
||||
private let fileManager = FileManager.default
|
||||
private let metadataKey = "v2.downloadedChapters"
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
let config = URLSessionConfiguration.background(
|
||||
withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads")
|
||||
config.isDiscretionary = false
|
||||
config.sessionSendsLaunchEvents = true
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
loadMetadata()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool {
|
||||
downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice))
|
||||
}
|
||||
|
||||
func localURL(slug: String, chapter: Int, voice: String) -> URL? {
|
||||
guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil }
|
||||
return audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
func download(slug: String, chapter: Int, voice: String) async throws {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return }
|
||||
|
||||
let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice)
|
||||
guard let url = URL(string: urlString) else { throw URLError(.badURL) }
|
||||
|
||||
let task = session.downloadTask(with: url)
|
||||
task.taskDescription = key
|
||||
activeTasks[key] = task
|
||||
|
||||
downloads[key] = DownloadProgress(
|
||||
slug: slug, chapter: chapter, voice: voice,
|
||||
progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading)
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func cancelDownload(slug: String, chapter: Int, voice: String) {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
activeTasks[key]?.cancel()
|
||||
activeTasks.removeValue(forKey: key)
|
||||
downloads.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func deleteDownload(slug: String, chapter: Int, voice: String) throws {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
downloadedChapters.remove(key)
|
||||
downloads.removeValue(forKey: key)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
func deleteAllDownloads() throws {
|
||||
if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let audioDir = docs.appendingPathComponent("audio")
|
||||
if fileManager.fileExists(atPath: audioDir.path) {
|
||||
try fileManager.removeItem(at: audioDir)
|
||||
}
|
||||
}
|
||||
downloadedChapters.removeAll()
|
||||
downloads.removeAll()
|
||||
activeTasks.values.forEach { $0.cancel() }
|
||||
activeTasks.removeAll()
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
func totalStorageUsed() -> Int64 {
|
||||
guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 }
|
||||
let audioDir = docs.appendingPathComponent("audio")
|
||||
guard let enumerator = fileManager.enumerator(at: audioDir,
|
||||
includingPropertiesForKeys: [.fileSizeKey]) else { return 0 }
|
||||
var total: Int64 = 0
|
||||
for case let url as URL in enumerator {
|
||||
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
total += Int64(size)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func offlineBookSlugs() -> [String] {
|
||||
Array(Set(downloadedChapters.compactMap { key -> String? in
|
||||
let parts = key.split(separator: "::")
|
||||
return parts.count == 3 ? String(parts[0]) : nil
|
||||
})).sorted()
|
||||
}
|
||||
|
||||
func downloadedChapterCount(for slug: String) -> Int {
|
||||
downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count
|
||||
}
|
||||
|
||||
// MARK: - Key / path helpers
|
||||
|
||||
func makeKey(slug: String, chapter: Int, voice: String) -> String {
|
||||
"\(slug)::\(chapter)::\(voice)"
|
||||
}
|
||||
|
||||
nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return docs
|
||||
.appendingPathComponent("audio")
|
||||
.appendingPathComponent(slug)
|
||||
.appendingPathComponent("\(chapter)-\(voice).mp3")
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func loadMetadata() {
|
||||
if let data = UserDefaults.standard.data(forKey: metadataKey),
|
||||
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||
downloadedChapters = decoded
|
||||
}
|
||||
}
|
||||
|
||||
private func saveMetadata() {
|
||||
if let encoded = try? JSONEncoder().encode(downloadedChapters) {
|
||||
UserDefaults.standard.set(encoded, forKey: metadataKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDownloadDelegate
|
||||
|
||||
extension AudioDownloadService: URLSessionDownloadDelegate {
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
let parts = key.split(separator: "::")
|
||||
guard parts.count == 3, let chapter = Int(parts[1]) else { return }
|
||||
let slug = String(parts[0])
|
||||
let voice = String(parts[2])
|
||||
let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
do {
|
||||
let dir = dest.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.moveItem(at: location, to: dest)
|
||||
Task { @MainActor in
|
||||
self.downloadedChapters.insert(key)
|
||||
self.downloads.removeValue(forKey: key)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
self.saveMetadata()
|
||||
}
|
||||
} catch {
|
||||
Task { @MainActor in
|
||||
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||
didWriteData _: Int64, totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
let progress = totalBytesExpectedToWrite > 0
|
||||
? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||||
Task { @MainActor in
|
||||
if var p = self.downloads[key] {
|
||||
p.downloadedBytes = totalBytesWritten
|
||||
p.totalBytes = totalBytesExpectedToWrite
|
||||
p.progress = progress
|
||||
self.downloads[key] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
didCompleteWithError error: Error?) {
|
||||
guard let key = task.taskDescription, let error else { return }
|
||||
let nsErr = error as NSError
|
||||
guard nsErr.code != NSURLErrorCancelled else { return }
|
||||
Task { @MainActor in
|
||||
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting types
|
||||
|
||||
struct DownloadProgress: Equatable {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let voice: String
|
||||
var progress: Double
|
||||
var totalBytes: Int64
|
||||
var downloadedBytes: Int64
|
||||
var status: DownloadStatus
|
||||
}
|
||||
|
||||
enum DownloadStatus: Equatable {
|
||||
case downloading
|
||||
case completed
|
||||
case failed(String)
|
||||
}
|
||||
492
ios/LibNovelV2/Services/AudioPlayerService.swift
Normal file
492
ios/LibNovelV2/Services/AudioPlayerService.swift
Normal file
@@ -0,0 +1,492 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
|
||||
// MARK: - PlaybackProgress
|
||||
// High-frequency playback state isolated into its own ObservableObject so that
|
||||
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||
// subscribe to this object (seek bar, play/pause button), leaving menus and
|
||||
// other stable UI untouched.
|
||||
|
||||
@MainActor
|
||||
final class PlaybackProgress: ObservableObject {
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - AudioPlayerService
|
||||
// Central singleton owning AVPlayer, lock-screen controls (NowPlayingInfoCenter
|
||||
// + MPRemoteCommandCenter), and next-chapter prefetch.
|
||||
|
||||
@MainActor
|
||||
final class AudioPlayerService: ObservableObject {
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
@Published var slug: String = ""
|
||||
@Published var chapter: Int = 0
|
||||
@Published var chapterTitle: String = ""
|
||||
@Published var bookTitle: String = ""
|
||||
@Published var coverURL: String = ""
|
||||
@Published var voice: String = "af_bella"
|
||||
@Published var speed: Double = 1.0
|
||||
@Published var chapters: [ChapterBrief] = []
|
||||
|
||||
@Published var status: AudioPlayerStatus = .idle
|
||||
@Published var audioURL: String = ""
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var generationProgress: Double = 0
|
||||
|
||||
/// High-frequency playback state — subscribe directly to avoid re-rendering parents.
|
||||
let progress = PlaybackProgress()
|
||||
|
||||
// Convenience forwarders for callers that don't need granular isolation.
|
||||
var currentTime: Double { get { progress.currentTime } set { progress.currentTime = newValue } }
|
||||
var duration: Double { get { progress.duration } set { progress.duration = newValue } }
|
||||
var isPlaying: Bool { get { progress.isPlaying } set { progress.isPlaying = newValue } }
|
||||
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
@Published var sleepTimerRemainingText: String = ""
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@Published var nextPrefetchedChapter: Int? = nil
|
||||
|
||||
var isActive: Bool {
|
||||
if case .idle = status { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var playerItem: AVPlayerItem?
|
||||
private var timeObserver: Any?
|
||||
private var statusObserver: AnyCancellable?
|
||||
private var durationObserver: AnyCancellable?
|
||||
private var finishObserver: AnyCancellable?
|
||||
private var generationTask: Task<Void, Never>?
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
private var cachedCoverURL: String = ""
|
||||
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
private var sleepTimerDeadline: Date? = nil
|
||||
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
configureAudioSession()
|
||||
setupRemoteCommandCenter()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||
chapters: [ChapterBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||
generationTask?.cancel()
|
||||
prefetchTask?.cancel()
|
||||
stop()
|
||||
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.bookTitle = bookTitle
|
||||
self.coverURL = coverURL
|
||||
self.voice = voice
|
||||
self.speed = speed
|
||||
self.chapters = chapters
|
||||
self.nextChapter = nextChapter
|
||||
self.prevChapter = prevChapter
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
|
||||
if case .chapters = sleepTimer { sleepTimerStartChapter = chapter }
|
||||
|
||||
status = .generating
|
||||
generationProgress = 0
|
||||
|
||||
if coverURL != cachedCoverURL {
|
||||
cachedCoverArtwork = nil
|
||||
cachedCoverURL = coverURL
|
||||
Task { await prefetchCoverArtwork(from: coverURL) }
|
||||
}
|
||||
|
||||
generationTask = Task { await generateAudio() }
|
||||
}
|
||||
|
||||
func play() {
|
||||
player?.play()
|
||||
player?.rate = Float(speed)
|
||||
isPlaying = true
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
let time = CMTime(seconds: seconds, preferredTimescale: 600)
|
||||
currentTime = seconds
|
||||
player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.updateNowPlaying() }
|
||||
}
|
||||
}
|
||||
|
||||
func skip(by seconds: Double) {
|
||||
seek(to: max(0, min(currentTime + seconds, duration)))
|
||||
}
|
||||
|
||||
func setSpeed(_ newSpeed: Double) {
|
||||
speed = newSpeed
|
||||
if isPlaying { player?.rate = Float(newSpeed) }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
sleepTimerTask?.cancel(); sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else { sleepTimerRemainingText = ""; return }
|
||||
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
updateChapterTimerLabel(chaptersRemaining: count)
|
||||
|
||||
case .minutes(let minutes):
|
||||
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||
sleepTimerDeadline = deadline
|
||||
sleepTimerTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run { self.stop(); self.sleepTimer = nil; self.sleepTimerRemainingText = "" }
|
||||
}
|
||||
sleepTimerCountdownTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let d = self.sleepTimerDeadline else { return }
|
||||
self.sleepTimerRemainingText = Self.formatCountdown(max(0, d.timeIntervalSinceNow))
|
||||
}
|
||||
}
|
||||
}
|
||||
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
teardownPlayer()
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
sleepTimerTask?.cancel(); sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
sleepTimer = nil
|
||||
sleepTimerRemainingText = ""
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func updateChapterTimerLabel(chaptersRemaining: Int) {
|
||||
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
|
||||
}
|
||||
|
||||
private static func formatCountdown(_ seconds: Double) -> String {
|
||||
let s = Int(max(0, seconds))
|
||||
return "\(s / 60):\(String(format: "%02d", s % 60))"
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
|
||||
private func generateAudio() async {
|
||||
guard !slug.isEmpty, chapter > 0 else { return }
|
||||
|
||||
// Local file first (offline download)
|
||||
if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) {
|
||||
audioURL = localURL.absoluteString
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(localURL.absoluteString)
|
||||
await prefetchNext()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Fast path: audio already in MinIO
|
||||
if let presigned = try? await APIClient.shared.presignAudio(
|
||||
slug: slug, chapter: chapter, voice: voice) {
|
||||
audioURL = presigned
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(presigned)
|
||||
await prefetchNext()
|
||||
return
|
||||
}
|
||||
|
||||
// Slow path: trigger TTS generation
|
||||
status = .generating
|
||||
generationProgress = 10
|
||||
let trigger = try await APIClient.shared.triggerAudio(
|
||||
slug: slug, chapter: chapter, voice: voice, speed: speed)
|
||||
|
||||
let playableURL: String
|
||||
if trigger.isAsync {
|
||||
generationProgress = 30
|
||||
playableURL = try await APIClient.shared.pollAudioStatus(
|
||||
slug: slug, chapter: chapter, voice: voice)
|
||||
} else {
|
||||
guard let url = trigger.url, !url.isEmpty else { throw URLError(.badServerResponse) }
|
||||
playableURL = url
|
||||
}
|
||||
|
||||
audioURL = playableURL
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(playableURL)
|
||||
await prefetchNext()
|
||||
} catch is CancellationError {
|
||||
// Cancelled — no-op
|
||||
} catch {
|
||||
status = .error(error.localizedDescription)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prefetch next chapter
|
||||
|
||||
private func prefetchNext() async {
|
||||
guard let next = nextChapter, !Task.isCancelled else { return }
|
||||
nextPrefetchStatus = .prefetching
|
||||
nextPrefetchedChapter = next
|
||||
do {
|
||||
if let presigned = try? await APIClient.shared.presignAudio(
|
||||
slug: slug, chapter: next, voice: voice) {
|
||||
nextAudioURL = presigned
|
||||
nextPrefetchStatus = .prefetched
|
||||
return
|
||||
}
|
||||
let trigger = try await APIClient.shared.triggerAudio(
|
||||
slug: slug, chapter: next, voice: voice, speed: speed)
|
||||
let url: String
|
||||
if trigger.isAsync {
|
||||
url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice)
|
||||
} else {
|
||||
guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) }
|
||||
url = u
|
||||
}
|
||||
nextAudioURL = url
|
||||
nextPrefetchStatus = .prefetched
|
||||
} catch {
|
||||
nextPrefetchStatus = .failed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayer management
|
||||
|
||||
private func playURL(_ urlString: String) async {
|
||||
let resolved: URL?
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||
resolved = URL(string: urlString)
|
||||
} else {
|
||||
resolved = URL(string: urlString,
|
||||
relativeTo: await APIClient.shared.baseURL)?.absoluteURL
|
||||
}
|
||||
guard let url = resolved else { return }
|
||||
|
||||
teardownPlayer()
|
||||
let item = AVPlayerItem(url: url)
|
||||
playerItem = item
|
||||
player = AVPlayer(playerItem: item)
|
||||
|
||||
durationObserver = item.publisher(for: \.duration)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] dur in
|
||||
guard let self else { return }
|
||||
let secs = dur.seconds
|
||||
if secs.isFinite && secs > 0 { self.duration = secs; self.updateNowPlaying() }
|
||||
}
|
||||
|
||||
statusObserver = item.publisher(for: \.status)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] s in
|
||||
guard let self else { return }
|
||||
switch s {
|
||||
case .readyToPlay:
|
||||
self.player?.rate = Float(self.speed)
|
||||
self.isPlaying = true
|
||||
self.updateNowPlaying()
|
||||
case .failed:
|
||||
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
|
||||
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
timeObserver = player?.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
|
||||
queue: .main
|
||||
) { [weak self] time in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let secs = time.seconds
|
||||
if secs.isFinite && secs >= 0 { self.currentTime = secs }
|
||||
}
|
||||
}
|
||||
|
||||
finishObserver = NotificationCenter.default
|
||||
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
|
||||
.sink { [weak self] _ in Task { @MainActor in self?.handlePlaybackFinished() } }
|
||||
|
||||
player?.play()
|
||||
}
|
||||
|
||||
private func teardownPlayer() {
|
||||
if let obs = timeObserver { player?.removeTimeObserver(obs) }
|
||||
timeObserver = nil; statusObserver = nil; durationObserver = nil; finishObserver = nil
|
||||
player = nil; playerItem = nil
|
||||
}
|
||||
|
||||
private func handlePlaybackFinished() {
|
||||
isPlaying = false
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Chapter-based sleep timer
|
||||
if case .chapters(let count) = sleepTimer {
|
||||
let played = chapter - sleepTimerStartChapter + 1
|
||||
if played >= count { stop(); return }
|
||||
updateChapterTimerLabel(chaptersRemaining: count - played)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: .audioDidFinishChapter, object: nil,
|
||||
userInfo: ["next": next, "autoNext": autoNext])
|
||||
|
||||
guard autoNext else { return }
|
||||
|
||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||
|
||||
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
|
||||
let url = nextAudioURL
|
||||
chapter = next
|
||||
chapterTitle = nextTitle
|
||||
nextChapter = nextNextChapter
|
||||
prevChapter = chapter
|
||||
nextPrefetchStatus = .none
|
||||
nextAudioURL = ""
|
||||
nextPrefetchedChapter = nil
|
||||
audioURL = url
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
if case .chapters = sleepTimer { sleepTimerStartChapter = next }
|
||||
generationTask = Task { await playURL(url); await prefetchNext() }
|
||||
} else {
|
||||
load(slug: slug, chapter: next, chapterTitle: nextTitle,
|
||||
bookTitle: bookTitle, coverURL: coverURL,
|
||||
voice: voice, speed: speed, chapters: chapters,
|
||||
nextChapter: nextNextChapter, prevChapter: chapter)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover art (URLSession — no Kingfisher)
|
||||
|
||||
private func prefetchCoverArtwork(from urlString: String) async {
|
||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let image = UIImage(data: data) else { return }
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
cachedCoverArtwork = artwork
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
// MARK: - Audio session
|
||||
|
||||
private func configureAudioSession() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
|
||||
// MARK: - Lock-screen controls
|
||||
|
||||
private func setupRemoteCommandCenter() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.addTarget { [weak self] _ in self?.play(); return .success }
|
||||
center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
|
||||
center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success }
|
||||
center.skipForwardCommand.preferredIntervals = [15]
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 15); return .success }
|
||||
center.skipBackwardCommand.preferredIntervals = [15]
|
||||
center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -15); return .success }
|
||||
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
if let e = event as? MPChangePlaybackPositionCommandEvent { self?.seek(to: e.positionTime) }
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle,
|
||||
MPMediaItemPropertyArtist: bookTitle,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
||||
MPMediaItemPropertyPlaybackDuration: duration,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0
|
||||
]
|
||||
if let artwork = cachedCoverArtwork { info[MPMediaItemPropertyArtwork] = artwork }
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting types
|
||||
|
||||
enum AudioPlayerStatus: Equatable {
|
||||
case idle
|
||||
case generating
|
||||
case ready
|
||||
case error(String)
|
||||
|
||||
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle), (.generating, .generating), (.ready, .ready): return true
|
||||
case (.error(let a), .error(let b)): return a == b
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case chapters(Int)
|
||||
case minutes(Int)
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("v2.audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("v2.skipToNextChapter")
|
||||
static let skipToPrevChapter = Notification.Name("v2.skipToPrevChapter")
|
||||
}
|
||||
144
ios/LibNovelV2/Services/AuthStore.swift
Normal file
144
ios/LibNovelV2/Services/AuthStore.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - AuthStore
|
||||
// Owns the authenticated user, the HMAC auth token, and user settings.
|
||||
// Persists the token to Keychain so the user stays logged in across launches.
|
||||
|
||||
@MainActor
|
||||
final class AuthStore: ObservableObject {
|
||||
@Published var user: AppUser?
|
||||
@Published var settings: UserSettings = .default
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: String?
|
||||
|
||||
var isAuthenticated: Bool { user != nil }
|
||||
|
||||
private let keychainKey = "libnovel_v2_auth_token"
|
||||
|
||||
init() {
|
||||
if let token = loadToken() {
|
||||
Task { await validateToken(token) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login / Register
|
||||
|
||||
func login(username: String, password: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.login(username: username, password: password)
|
||||
await APIClient.shared.setAuthCookie(response.token)
|
||||
saveToken(response.token)
|
||||
user = response.user
|
||||
await loadSettings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func register(username: String, password: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.register(username: username, password: password)
|
||||
await APIClient.shared.setAuthCookie(response.token)
|
||||
saveToken(response.token)
|
||||
user = response.user
|
||||
await loadSettings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
do { try await APIClient.shared.logout() } catch {}
|
||||
clearToken()
|
||||
user = nil
|
||||
settings = .default
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func loadSettings() async {
|
||||
do { settings = try await APIClient.shared.settings() } catch {}
|
||||
}
|
||||
|
||||
func saveSettings(_ updated: UserSettings) async {
|
||||
do {
|
||||
try await APIClient.shared.updateSettings(updated)
|
||||
settings = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token validation
|
||||
|
||||
func validateToken() async {
|
||||
guard let token = loadToken() else { return }
|
||||
await validateToken(token)
|
||||
}
|
||||
|
||||
private func validateToken(_ token: String) async {
|
||||
await APIClient.shared.setAuthCookie(token)
|
||||
do {
|
||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||
async let s: UserSettings = APIClient.shared.settings()
|
||||
var (restoredUser, restoredSettings) = try await (me, s)
|
||||
// Exchange raw MinIO key for a presigned URL if needed.
|
||||
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
||||
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
||||
restoredUser = AppUser(
|
||||
id: restoredUser.id,
|
||||
username: restoredUser.username,
|
||||
role: restoredUser.role,
|
||||
created: restoredUser.created,
|
||||
avatarURL: presignedURL
|
||||
)
|
||||
}
|
||||
}
|
||||
user = restoredUser
|
||||
settings = restoredSettings
|
||||
} catch let e as APIError {
|
||||
if case .httpError(let code, _) = e, code == 401 { clearToken() }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// MARK: - Keychain helpers
|
||||
|
||||
private func saveToken(_ token: String) {
|
||||
let data = Data(token.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func clearToken() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
59
ios/LibNovelV2/Services/BookVoicePreferences.swift
Normal file
59
ios/LibNovelV2/Services/BookVoicePreferences.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BookVoicePreferences
|
||||
// Manages per-book voice overrides with global fallback.
|
||||
// Persisted in UserDefaults as a slug → voice dictionary.
|
||||
|
||||
@MainActor
|
||||
final class BookVoicePreferences: ObservableObject {
|
||||
static let shared = BookVoicePreferences()
|
||||
|
||||
@Published private(set) var bookVoices: [String: String] = [:]
|
||||
|
||||
private let key = "v2.bookVoicePreferences"
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||
bookVoices = decoded
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func voice(for slug: String) -> String? {
|
||||
bookVoices[slug]
|
||||
}
|
||||
|
||||
/// Voice priority: book override → globalVoice → "af_bella"
|
||||
func voiceWithFallback(for slug: String, globalVoice: String) -> String {
|
||||
bookVoices[slug] ?? globalVoice
|
||||
}
|
||||
|
||||
func setVoice(_ voice: String, for slug: String) {
|
||||
bookVoices[slug] = voice
|
||||
save()
|
||||
}
|
||||
|
||||
func removeVoice(for slug: String) {
|
||||
bookVoices.removeValue(forKey: slug)
|
||||
save()
|
||||
}
|
||||
|
||||
func hasOverride(for slug: String) -> Bool {
|
||||
bookVoices[slug] != nil
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
bookVoices.removeAll()
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func save() {
|
||||
if let encoded = try? JSONEncoder().encode(bookVoices) {
|
||||
UserDefaults.standard.set(encoded, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ios/LibNovelV2/Services/NetworkMonitor.swift
Normal file
43
ios/LibNovelV2/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
// MARK: - NetworkMonitor
|
||||
// Monitors network connectivity. Inject as an environment object for offline UI.
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
|
||||
@Published var isConnected: Bool = true
|
||||
@Published var connectionType: NWInterface.InterfaceType?
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "cc.kalekber.libnovel.v2.network-monitor")
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isConnected = path.status == .satisfied
|
||||
self?.connectionType = path.availableInterfaces.first?.type
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension NWInterface.InterfaceType {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .wifi: return "Wi-Fi"
|
||||
case .cellular: return "Cellular"
|
||||
case .wiredEthernet: return "Ethernet"
|
||||
case .loopback: return "Loopback"
|
||||
case .other: return "Other"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
80
ios/LibNovelV2/ViewModels/BookDetailViewModel.swift
Normal file
80
ios/LibNovelV2/ViewModels/BookDetailViewModel.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BookDetailViewModel
|
||||
// Loads book metadata, chapter index, save state, and reading progress.
|
||||
// Uses @Observable (iOS 17+).
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class BookDetailViewModel {
|
||||
let slug: String
|
||||
|
||||
var book: Book?
|
||||
var chapters: [ChapterIndex] = []
|
||||
var inLib: Bool = false
|
||||
var saved: Bool = false
|
||||
var lastChapter: Int?
|
||||
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var error: String?
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
// MARK: - Load
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.bookDetail(slug: slug)
|
||||
book = response.book
|
||||
chapters = response.chapters
|
||||
inLib = response.inLib
|
||||
saved = response.saved
|
||||
lastChapter = response.lastChapter
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Toggle saved (bookmark)
|
||||
|
||||
func toggleSaved() async {
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
let targetSaved = !saved
|
||||
saved = targetSaved // optimistic update
|
||||
do {
|
||||
if targetSaved {
|
||||
try await APIClient.shared.saveBook(slug: slug)
|
||||
if !inLib { inLib = true }
|
||||
} else {
|
||||
try await APIClient.shared.unsaveBook(slug: slug)
|
||||
}
|
||||
} catch {
|
||||
saved = !targetSaved // revert on failure
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
// MARK: - Chapter helpers
|
||||
|
||||
/// Title stripped of trailing " - Month DD YYYY" date suffixes.
|
||||
func displayTitle(for chapter: ChapterIndex) -> String {
|
||||
let stripped = chapter.title.strippingTrailingDate()
|
||||
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||
return "Chapter \(chapter.number)"
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
146
ios/LibNovelV2/ViewModels/BrowseViewModel.swift
Normal file
146
ios/LibNovelV2/ViewModels/BrowseViewModel.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BrowseViewModel
|
||||
// Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView).
|
||||
// Uses @Observable (iOS 17+).
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class BrowseViewModel {
|
||||
|
||||
// MARK: - Discover shelves (BrowseView)
|
||||
|
||||
var trending: [BrowseNovel] = []
|
||||
var newReleases: [BrowseNovel] = []
|
||||
var recentlyUpdated: [BrowseNovel] = []
|
||||
var ranking: [BrowseNovel] = []
|
||||
|
||||
// MARK: - Paginated grid (BrowseCategoryView)
|
||||
|
||||
var novels: [BrowseNovel] = []
|
||||
var currentPage = 1
|
||||
var hasNext = false
|
||||
|
||||
// Filter params (BrowseCategoryView sets these before calling loadFirstPage)
|
||||
var sort: String = "popular"
|
||||
var genre: String = "all"
|
||||
var status: String = "all"
|
||||
|
||||
// MARK: - UI state
|
||||
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var error: String?
|
||||
|
||||
// MARK: - Discover load (fetches multiple shelves in parallel)
|
||||
|
||||
func loadShelves() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
|
||||
async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all")
|
||||
async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all")
|
||||
async let rankingTask = APIClient.shared.ranking()
|
||||
|
||||
let (trendingResp, newResp, updatedResp, rankItems) = try await (
|
||||
trendingTask, newTask, updatedTask, rankingTask
|
||||
)
|
||||
|
||||
trending = Array(trendingResp.novels.prefix(12))
|
||||
newReleases = Array(newResp.novels.prefix(12))
|
||||
recentlyUpdated = Array(updatedResp.novels.prefix(12))
|
||||
ranking = rankItems.prefix(12).map { item in
|
||||
BrowseNovelFromRanking(item)
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Paginated category load
|
||||
|
||||
func loadFirstPage() async {
|
||||
guard !isLoading else { return }
|
||||
novels = []
|
||||
currentPage = 1
|
||||
hasNext = false
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let resp = try await APIClient.shared.browse(
|
||||
page: 1, genre: genre, sort: sort, status: status)
|
||||
novels = resp.novels
|
||||
currentPage = resp.page
|
||||
hasNext = resp.hasNext
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadNextPage() async {
|
||||
guard hasNext, !isLoadingMore, !isLoading else { return }
|
||||
isLoadingMore = true
|
||||
|
||||
let next = currentPage + 1
|
||||
do {
|
||||
let resp = try await APIClient.shared.browse(
|
||||
page: next, genre: genre, sort: sort, status: status)
|
||||
novels += resp.novels
|
||||
currentPage = resp.page
|
||||
hasNext = resp.hasNext
|
||||
} catch {
|
||||
// Silently ignore — user can scroll again
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
// MARK: - Ranking load (for rank sort mode)
|
||||
|
||||
func loadRanking() async {
|
||||
guard !isLoading else { return }
|
||||
novels = []
|
||||
hasNext = false
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let items = try await APIClient.shared.ranking()
|
||||
novels = items.map { BrowseNovelFromRanking($0) }
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RankingItem → BrowseNovel adapter
|
||||
|
||||
private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel {
|
||||
// Synthesise a minimal JSON blob so we can decode via the standard init
|
||||
let rankStr = "#\(item.rank)"
|
||||
let dict: [String: Any] = [
|
||||
"slug": item.slug,
|
||||
"title": item.title,
|
||||
"cover": item.cover,
|
||||
"rank": rankStr,
|
||||
"rating": "",
|
||||
"chapters": "",
|
||||
"url": item.sourceURL,
|
||||
"author": item.author,
|
||||
"status": item.status,
|
||||
"genres": item.genres
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data)
|
||||
}
|
||||
69
ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift
Normal file
69
ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChapterReaderViewModel
|
||||
|
||||
@Observable @MainActor
|
||||
final class ChapterReaderViewModel {
|
||||
let slug: String
|
||||
private(set) var chapter: Int
|
||||
|
||||
var content: ChapterResponse?
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
init(slug: String, chapter: Int) {
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
/// Switch to a different chapter in-place; `chapter` change causes `.task(id: chapter)` to re-fire `load()`.
|
||||
func switchChapter(to newChapter: Int) {
|
||||
guard newChapter != chapter else { return }
|
||||
chapter = newChapter
|
||||
content = nil
|
||||
error = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
|
||||
guard let content else { return }
|
||||
|
||||
let isCurrent = audioPlayer.isActive
|
||||
&& audioPlayer.slug == slug
|
||||
&& audioPlayer.chapter == chapter
|
||||
|
||||
if isCurrent {
|
||||
audioPlayer.togglePlayPause()
|
||||
} else {
|
||||
let voice = BookVoicePreferences.shared.voiceWithFallback(
|
||||
for: slug,
|
||||
globalVoice: settings.voice
|
||||
)
|
||||
audioPlayer.load(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
chapterTitle: content.chapter.title,
|
||||
bookTitle: content.book.title,
|
||||
coverURL: content.book.cover,
|
||||
voice: voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
nextChapter: content.next,
|
||||
prevChapter: content.prev
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
ios/LibNovelV2/ViewModels/HomeViewModel.swift
Normal file
35
ios/LibNovelV2/ViewModels/HomeViewModel.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - HomeViewModel
|
||||
// Fetches home-screen data: continue reading, recently updated, stats, subscription feed.
|
||||
// Uses @Observable (iOS 17+).
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class HomeViewModel {
|
||||
var continueReading: [ContinueReadingItem] = []
|
||||
var recentlyUpdated: [Book] = []
|
||||
var stats: HomeStats?
|
||||
var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let data = try await APIClient.shared.homeData()
|
||||
continueReading = data.continueReading.map {
|
||||
ContinueReadingItem(book: $0.book, chapter: $0.chapter)
|
||||
}
|
||||
recentlyUpdated = data.recentlyUpdated
|
||||
stats = data.stats
|
||||
subscriptionFeed = data.subscriptionFeed
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
164
ios/LibNovelV2/ViewModels/LibraryViewModel.swift
Normal file
164
ios/LibNovelV2/ViewModels/LibraryViewModel.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - LibraryViewModel
|
||||
// Loads library items and exposes filtered/sorted views for LibraryView.
|
||||
// Uses @Observable (iOS 17+).
|
||||
|
||||
enum LibrarySortOrder: String, CaseIterable {
|
||||
case recent = "Recent"
|
||||
case title = "Title"
|
||||
case author = "Author"
|
||||
case progress = "Progress"
|
||||
}
|
||||
|
||||
enum LibraryReadingFilter: String, CaseIterable {
|
||||
case all = "All"
|
||||
case inProgress = "In Progress"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class LibraryViewModel {
|
||||
// Raw data
|
||||
var items: [LibraryItem] = []
|
||||
var progressMap: [String: Int] = [:] // slug -> last chapter read
|
||||
|
||||
// Filter & sort state
|
||||
var sortOrder: LibrarySortOrder = .recent
|
||||
var readingFilter: LibraryReadingFilter = .all
|
||||
var selectedGenre: String = "All"
|
||||
|
||||
// UI state
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
var allGenres: [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = ["All"]
|
||||
for item in items {
|
||||
for genre in item.book.genres where !seen.contains(genre) {
|
||||
seen.insert(genre)
|
||||
result.append(genre)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var filteredItems: [LibraryItem] {
|
||||
var list = items
|
||||
|
||||
// Genre filter
|
||||
if selectedGenre != "All" {
|
||||
list = list.filter { $0.book.genres.contains(selectedGenre) }
|
||||
}
|
||||
|
||||
// Reading filter
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
break
|
||||
case .inProgress:
|
||||
list = list.filter { item in
|
||||
let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
||||
return ch > 0 && ch < item.book.totalChapters
|
||||
}
|
||||
case .completed:
|
||||
list = list.filter { item in
|
||||
let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
||||
return item.book.totalChapters > 0 && ch >= item.book.totalChapters
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch sortOrder {
|
||||
case .recent:
|
||||
// server already returns newest-saved first; preserve order
|
||||
break
|
||||
case .title:
|
||||
list.sort { $0.book.title.localizedCaseInsensitiveCompare($1.book.title) == .orderedAscending }
|
||||
case .author:
|
||||
list.sort { $0.book.author.localizedCaseInsensitiveCompare($1.book.author) == .orderedAscending }
|
||||
case .progress:
|
||||
list.sort { a, b in
|
||||
let pa = progressFraction(for: a)
|
||||
let pb = progressFraction(for: b)
|
||||
return pa > pb
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// MARK: - Progress helpers
|
||||
|
||||
func lastChapter(for item: LibraryItem) -> Int {
|
||||
progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
||||
}
|
||||
|
||||
func progressFraction(for item: LibraryItem) -> Double {
|
||||
let total = item.book.totalChapters
|
||||
guard total > 0 else { return 0 }
|
||||
return Double(lastChapter(for: item)) / Double(total)
|
||||
}
|
||||
|
||||
func progressPercent(for item: LibraryItem) -> String {
|
||||
let fraction = progressFraction(for: item)
|
||||
let pct = fraction * 100
|
||||
if pct < 10 {
|
||||
return String(format: "%.1f%%", pct)
|
||||
} else {
|
||||
return String(format: "%.0f%%", pct)
|
||||
}
|
||||
}
|
||||
|
||||
func isCompleted(for item: LibraryItem) -> Bool {
|
||||
let total = item.book.totalChapters
|
||||
guard total > 0 else { return false }
|
||||
return lastChapter(for: item) >= total
|
||||
}
|
||||
|
||||
// MARK: - Load
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let libraryTask = APIClient.shared.library()
|
||||
async let progressTask = APIClient.shared.progress()
|
||||
|
||||
let (library, progressEntries) = try await (libraryTask, progressTask)
|
||||
items = library
|
||||
progressMap = Dictionary(uniqueKeysWithValues: progressEntries.map { ($0.slug, $0.chapter) })
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
func removeFromLibrary(slug: String) async {
|
||||
// Optimistic remove
|
||||
items.removeAll { $0.book.slug == slug }
|
||||
do {
|
||||
try await APIClient.shared.unsaveBook(slug: slug)
|
||||
} catch {
|
||||
// Silently fail — user can pull-to-refresh to restore
|
||||
}
|
||||
}
|
||||
|
||||
func markFinished(item: LibraryItem) async {
|
||||
let total = item.book.totalChapters
|
||||
guard total > 0 else { return }
|
||||
progressMap[item.book.slug] = total
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: item.book.slug, chapter: total)
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
115
ios/LibNovelV2/ViewModels/SearchViewModel.swift
Normal file
115
ios/LibNovelV2/ViewModels/SearchViewModel.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - SearchViewModel
|
||||
// Debounced live search (300 ms) + recent searches persisted in UserDefaults.
|
||||
// Uses @Observable (iOS 17+).
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SearchViewModel {
|
||||
var query: String = ""
|
||||
var results: [BrowseNovel] = []
|
||||
var localCount: Int = 0
|
||||
var remoteCount: Int = 0
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
// Persisted recent searches (max 10, prefixed with "v2.")
|
||||
var recentSearches: [String] = []
|
||||
|
||||
private let recentKey = "v2.searchRecentTerms"
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
recentSearches = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
|
||||
}
|
||||
|
||||
// MARK: - Query change (debounced)
|
||||
|
||||
func onQueryChange(_ newValue: String) {
|
||||
searchTask?.cancel()
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
results = []
|
||||
localCount = 0
|
||||
remoteCount = 0
|
||||
return
|
||||
}
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // 300 ms debounce
|
||||
guard !Task.isCancelled else { return }
|
||||
await runSearch(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Submit (immediate, saves to recent)
|
||||
|
||||
func submitSearch() {
|
||||
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { return }
|
||||
saveRecent(term)
|
||||
searchTask?.cancel()
|
||||
searchTask = Task { await runSearch(term) }
|
||||
}
|
||||
|
||||
// MARK: - Recent search tap
|
||||
|
||||
func selectRecent(_ term: String) {
|
||||
query = term
|
||||
searchTask?.cancel()
|
||||
searchTask = Task { await runSearch(term) }
|
||||
}
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
func clear() {
|
||||
query = ""
|
||||
results = []
|
||||
localCount = 0
|
||||
remoteCount = 0
|
||||
error = nil
|
||||
searchTask?.cancel()
|
||||
}
|
||||
|
||||
func clearRecent() {
|
||||
recentSearches = []
|
||||
UserDefaults.standard.removeObject(forKey: recentKey)
|
||||
}
|
||||
|
||||
// MARK: - Core search
|
||||
|
||||
private func runSearch(_ term: String) async {
|
||||
guard !term.isEmpty else {
|
||||
results = []
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.search(query: term)
|
||||
// Only update if the query hasn't changed since we started
|
||||
let currentTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if currentTrimmed == term || currentTrimmed.isEmpty {
|
||||
results = response.results
|
||||
localCount = response.localCount
|
||||
remoteCount = response.remoteCount
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
results = []
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Persist recent
|
||||
|
||||
private func saveRecent(_ term: String) {
|
||||
var list = recentSearches.filter { $0 != term }
|
||||
list.insert(term, at: 0)
|
||||
if list.count > 10 { list = Array(list.prefix(10)) }
|
||||
recentSearches = list
|
||||
UserDefaults.standard.set(list, forKey: recentKey)
|
||||
}
|
||||
}
|
||||
386
ios/LibNovelV2/Views/Auth/AuthView.swift
Normal file
386
ios/LibNovelV2/Views/Auth/AuthView.swift
Normal file
@@ -0,0 +1,386 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AuthView
|
||||
// Full-screen login / register view.
|
||||
// Mirrors the web UI's login page: zinc-900 background, tab switcher with
|
||||
// amber underline indicator, zinc-800 text fields with amber focus ring,
|
||||
// amber CTA button, inline error banner, loading state.
|
||||
|
||||
struct AuthView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
@State private var mode: AuthMode = .login
|
||||
|
||||
// Login fields
|
||||
@State private var loginUsername: String = ""
|
||||
@State private var loginPassword: String = ""
|
||||
|
||||
// Register fields
|
||||
@State private var regUsername: String = ""
|
||||
@State private var regPassword: String = ""
|
||||
@State private var regConfirm: String = ""
|
||||
|
||||
// Focus management
|
||||
@FocusState private var focus: AuthField?
|
||||
|
||||
// Local validation error (client-side, e.g. password mismatch)
|
||||
@State private var localError: String?
|
||||
|
||||
private var displayError: String? { localError ?? authStore.error }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 60)
|
||||
|
||||
// ── Wordmark ──────────────────────────────────────────
|
||||
wordmark
|
||||
|
||||
Spacer(minLength: 48)
|
||||
|
||||
// ── Card ──────────────────────────────────────────────
|
||||
VStack(spacing: 0) {
|
||||
tabSwitcher
|
||||
formContent
|
||||
}
|
||||
.background(Color.cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in
|
||||
localError = nil
|
||||
authStore.error = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wordmark
|
||||
|
||||
private var wordmark: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Color.amber)
|
||||
.symbolEffect(.bounce, value: mode)
|
||||
|
||||
Text("libnovel")
|
||||
.font(.title.bold())
|
||||
.fontDesign(.serif)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab switcher
|
||||
|
||||
private var tabSwitcher: some View {
|
||||
HStack(spacing: 0) {
|
||||
tabButton(label: "Sign in", tab: .login)
|
||||
tabButton(label: "Create account", tab: .register)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.cardBorder)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tabButton(label: String, tab: AuthMode) -> some View {
|
||||
let isActive = mode == tab
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
mode = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 0) {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(isActive ? Color.amber : Color.secondary)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Active underline indicator
|
||||
Rectangle()
|
||||
.fill(isActive ? Color.amber : Color.clear)
|
||||
.frame(height: 2)
|
||||
.offset(y: 1) // sits on top of the border
|
||||
}
|
||||
}
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
|
||||
// MARK: - Form content
|
||||
|
||||
@ViewBuilder
|
||||
private var formContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Error banner
|
||||
if let err = displayError {
|
||||
errorBanner(err)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .login: loginForm
|
||||
case .register: registerForm
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode)
|
||||
}
|
||||
|
||||
// MARK: - Error banner
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Color.errorText)
|
||||
.font(.footnote)
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.errorText)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.errorBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color.errorBorder, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: - Login form
|
||||
|
||||
private var loginForm: some View {
|
||||
VStack(spacing: 16) {
|
||||
AuthInputField(
|
||||
label: "Username",
|
||||
placeholder: "your_username",
|
||||
text: $loginUsername,
|
||||
contentType: .username,
|
||||
keyboardType: .default,
|
||||
focusState: $focus,
|
||||
field: .loginUsername,
|
||||
nextField: .loginPassword
|
||||
)
|
||||
|
||||
AuthInputField(
|
||||
label: "Password",
|
||||
placeholder: "••••••••",
|
||||
text: $loginPassword,
|
||||
contentType: .password,
|
||||
isSecure: true,
|
||||
focusState: $focus,
|
||||
field: .loginPassword,
|
||||
onSubmit: submitLogin
|
||||
)
|
||||
|
||||
ctaButton(label: "Sign in", action: submitLogin)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Register form
|
||||
|
||||
private var registerForm: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 4) {
|
||||
AuthInputField(
|
||||
label: "Username",
|
||||
placeholder: "your_username",
|
||||
text: $regUsername,
|
||||
contentType: .username,
|
||||
focusState: $focus,
|
||||
field: .regUsername,
|
||||
nextField: .regPassword
|
||||
)
|
||||
Text("3–32 characters: letters, numbers, _ or -")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
AuthInputField(
|
||||
label: "Password",
|
||||
placeholder: "••••••••",
|
||||
text: $regPassword,
|
||||
contentType: .newPassword,
|
||||
isSecure: true,
|
||||
focusState: $focus,
|
||||
field: .regPassword,
|
||||
nextField: .regConfirm
|
||||
)
|
||||
Text("At least 8 characters")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
|
||||
AuthInputField(
|
||||
label: "Confirm password",
|
||||
placeholder: "••••••••",
|
||||
text: $regConfirm,
|
||||
contentType: .newPassword,
|
||||
isSecure: true,
|
||||
focusState: $focus,
|
||||
field: .regConfirm,
|
||||
onSubmit: submitRegister
|
||||
)
|
||||
|
||||
ctaButton(label: "Create account", action: submitRegister)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CTA button
|
||||
|
||||
private func ctaButton(label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
if authStore.isLoading {
|
||||
ProgressView()
|
||||
.tint(Color(uiColor: .systemBackground))
|
||||
} else {
|
||||
Text(label)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.ctaText)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.background(Color.amber)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.disabled(authStore.isLoading || !networkMonitor.isConnected)
|
||||
.opacity(authStore.isLoading ? 0.8 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: authStore.isLoading)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func submitLogin() {
|
||||
guard !authStore.isLoading else { return }
|
||||
localError = nil
|
||||
focus = nil
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
Task { await authStore.login(username: loginUsername, password: loginPassword) }
|
||||
}
|
||||
|
||||
private func submitRegister() {
|
||||
guard !authStore.isLoading else { return }
|
||||
localError = nil
|
||||
// Client-side validation
|
||||
if regUsername.count < 3 || regUsername.count > 32 {
|
||||
localError = "Username must be 3–32 characters."
|
||||
return
|
||||
}
|
||||
if regPassword.count < 8 {
|
||||
localError = "Password must be at least 8 characters."
|
||||
return
|
||||
}
|
||||
if regPassword != regConfirm {
|
||||
localError = "Passwords do not match."
|
||||
return
|
||||
}
|
||||
focus = nil
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
Task { await authStore.register(username: regUsername, password: regPassword) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth mode enum
|
||||
|
||||
private enum AuthMode: Equatable { case login, register }
|
||||
|
||||
// MARK: - Focus field enum
|
||||
|
||||
private enum AuthField: Hashable {
|
||||
case loginUsername, loginPassword
|
||||
case regUsername, regPassword, regConfirm
|
||||
}
|
||||
|
||||
// MARK: - AuthInputField component
|
||||
|
||||
private struct AuthInputField: View {
|
||||
let label: String
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var contentType: UITextContentType? = nil
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var isSecure: Bool = false
|
||||
@FocusState.Binding var focusState: AuthField?
|
||||
let field: AuthField
|
||||
var nextField: AuthField? = nil
|
||||
var onSubmit: (() -> Void)? = nil
|
||||
|
||||
private var isFocused: Bool { focusState == field }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Group {
|
||||
if isSecure {
|
||||
SecureField(placeholder, text: $text)
|
||||
} else {
|
||||
TextField(placeholder, text: $text)
|
||||
.keyboardType(keyboardType)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
.textContentType(contentType)
|
||||
.focused($focusState, equals: field)
|
||||
.submitLabel(nextField != nil ? .next : .done)
|
||||
.onSubmit {
|
||||
if let next = nextField {
|
||||
focusState = next
|
||||
} else {
|
||||
onSubmit?()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 44)
|
||||
.background(Color.fieldBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(
|
||||
isFocused ? Color.amber : Color.cardBorder,
|
||||
lineWidth: isFocused ? 1.5 : 1
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local color helpers
|
||||
|
||||
private extension Color {
|
||||
static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground })
|
||||
static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground })
|
||||
static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator })
|
||||
static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground })
|
||||
static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900
|
||||
static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40)
|
||||
static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60)
|
||||
static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60)
|
||||
}
|
||||
671
ios/LibNovelV2/Views/BookDetail/BookDetailView.swift
Normal file
671
ios/LibNovelV2/Views/BookDetail/BookDetailView.swift
Normal file
@@ -0,0 +1,671 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - BookDetailView
|
||||
// Displays book hero (blurred cover bg + cover art + title), meta stats,
|
||||
// expandable summary, CTA buttons, chapters row (→ sheet), and bottom save toggle.
|
||||
// Matches the web UI at ui/src/routes/books/[slug]/+page.svelte.
|
||||
|
||||
struct BookDetailView: View {
|
||||
let slug: String
|
||||
|
||||
@State private var vm: BookDetailViewModel
|
||||
@State private var showChapters = false
|
||||
@State private var summaryExpanded = false
|
||||
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
_vm = State(initialValue: BookDetailViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.book == nil {
|
||||
loadingState
|
||||
} else if let book = vm.book {
|
||||
content(book: book)
|
||||
} else if vm.error != nil {
|
||||
errorState
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appNavigationDestination()
|
||||
.toolbar { toolbarContent }
|
||||
.task {
|
||||
guard networkMonitor.isConnected else { return }
|
||||
await vm.load()
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showChapters) {
|
||||
BookChaptersSheet(
|
||||
slug: slug,
|
||||
chapters: vm.chapters,
|
||||
lastChapter: vm.lastChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main content
|
||||
|
||||
private func content(book: Book) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
heroSection(book: book)
|
||||
statsRow(book: book)
|
||||
Divider()
|
||||
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
.padding(.horizontal, 16)
|
||||
summarySection(book: book)
|
||||
Divider()
|
||||
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
.padding(.horizontal, 16)
|
||||
ctaButtons
|
||||
Divider()
|
||||
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
chaptersRow
|
||||
Divider()
|
||||
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
|
||||
private func heroSection(book: Book) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Blurred cover background
|
||||
AsyncCoverImage(url: book.cover, isBackground: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 340)
|
||||
.blur(radius: 28)
|
||||
.clipped()
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.2),
|
||||
Color.black.opacity(0.72),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Cover art
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||
|
||||
// Title + author
|
||||
VStack(spacing: 5) {
|
||||
Text(book.title)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if !book.author.isEmpty {
|
||||
Text(book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge + genre chips
|
||||
VStack(spacing: 8) {
|
||||
if !book.status.isEmpty {
|
||||
BookStatusBadge(status: book.status)
|
||||
}
|
||||
if !book.genres.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||
Text(genre)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Not in library" badge
|
||||
if !vm.inLib {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
.font(.caption2)
|
||||
Text("Not in library")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.frame(minHeight: 340)
|
||||
}
|
||||
|
||||
// MARK: - Stats row
|
||||
|
||||
private func statsRow(book: Book) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
BookMetaStat(
|
||||
value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)",
|
||||
label: "Chapters",
|
||||
icon: "doc.text"
|
||||
)
|
||||
Divider().frame(height: 36)
|
||||
BookMetaStat(
|
||||
value: book.status.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status",
|
||||
icon: "flag"
|
||||
)
|
||||
if book.ranking > 0 {
|
||||
Divider().frame(height: 36)
|
||||
BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||
}
|
||||
|
||||
// MARK: - Summary
|
||||
|
||||
private func summarySection(book: Book) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
if book.summary.isEmpty {
|
||||
Text("No description available.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
if book.summary.count > 200 {
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
summaryExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Text(summaryExpanded ? "Less" : "More")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - CTA buttons
|
||||
|
||||
private var ctaButtons: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
// Continue reading
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.font(.subheadline.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Color.amber)
|
||||
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
})
|
||||
|
||||
// Start from ch.1
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.font(.subheadline.bold())
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
.foregroundStyle(.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
})
|
||||
} else {
|
||||
// Start reading
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill")
|
||||
.font(.subheadline.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber)
|
||||
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(vm.chapters.isEmpty)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
// MARK: - Chapters row
|
||||
|
||||
private var chaptersRow: some View {
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
showChapters = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "list.number")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 28)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapters")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
let count = vm.chapters.count
|
||||
if let last = vm.lastChapter, last > 0, count > 0 {
|
||||
Text("Reading Ch.\(last) of \(count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if count > 0 {
|
||||
Text("\(count) chapter\(count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if vm.isLoading {
|
||||
Text("Loading…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.frame(minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Chapters list")
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
Task { await vm.toggleSaved() }
|
||||
} label: {
|
||||
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
|
||||
.foregroundStyle(vm.saved ? Color.amber : .primary)
|
||||
.contentTransition(.symbolEffect(.replace.downUp))
|
||||
}
|
||||
.disabled(vm.isSaving)
|
||||
.accessibilityLabel(vm.saved ? "Remove from library" : "Save to library")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading / Error states
|
||||
|
||||
private var loadingState: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(Color.amber)
|
||||
.scaleEffect(1.4)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var errorState: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "wifi.slash",
|
||||
title: "Couldn't load book",
|
||||
message: vm.error ?? "Something went wrong.",
|
||||
ctaLabel: "Retry",
|
||||
ctaAction: {
|
||||
Task {
|
||||
guard networkMonitor.isConnected else { return }
|
||||
await vm.load()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookChaptersSheet
|
||||
// Shows all chapters in groups of 100 with a searchable list and right-edge jump bar.
|
||||
|
||||
struct BookChaptersSheet: View {
|
||||
let slug: String
|
||||
let chapters: [ChapterIndex]
|
||||
let lastChapter: Int?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
|
||||
private var filtered: [ChapterIndex] {
|
||||
guard !searchText.isEmpty else { return chapters }
|
||||
let q = searchText.lowercased()
|
||||
return chapters.filter {
|
||||
"\($0.number)".contains(q) || $0.title.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapters in blocks of 100, or a flat "Results" group when searching.
|
||||
private var groups: [(label: String, chapters: [ChapterIndex])] {
|
||||
guard searchText.isEmpty else {
|
||||
return filtered.isEmpty ? [] : [("Results", filtered)]
|
||||
}
|
||||
guard !filtered.isEmpty else { return [] }
|
||||
let blockSize = 100
|
||||
let minN = filtered.map(\.number).min() ?? 1
|
||||
let maxN = filtered.map(\.number).max() ?? 1
|
||||
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
|
||||
var result: [(label: String, chapters: [ChapterIndex])] = []
|
||||
var blockStart = firstBlock
|
||||
while blockStart <= maxN {
|
||||
let blockEnd = blockStart + blockSize - 1
|
||||
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
|
||||
if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) }
|
||||
blockStart += blockSize
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@State private var activeBlock: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .trailing) {
|
||||
List {
|
||||
ForEach(groups, id: \.label) { group in
|
||||
Section {
|
||||
ForEach(group.chapters, id: \.number) { ch in
|
||||
ChapterListRow(
|
||||
chapter: ch,
|
||||
slug: slug,
|
||||
isCurrent: ch.number == lastChapter
|
||||
)
|
||||
.id(ch.number)
|
||||
}
|
||||
} header: {
|
||||
if searchText.isEmpty {
|
||||
Text(group.label)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.id("header_\(group.label)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chapters.isEmpty {
|
||||
Section {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Chapter number or title"
|
||||
)
|
||||
.scrollPosition(id: $activeBlock, anchor: .top)
|
||||
.appNavigationDestination()
|
||||
|
||||
// Jump bar (hidden while searching)
|
||||
if searchText.isEmpty && groups.count > 1 {
|
||||
ChapterJumpBar(
|
||||
labels: groups.map(\.label),
|
||||
currentChapter: lastChapter ?? 0,
|
||||
groups: groups
|
||||
) { label in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
activeBlock = label
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapters (\(filtered.count))")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Scroll to current chapter's block on open
|
||||
if let block = groups.first(where: { g in
|
||||
g.chapters.contains(where: { $0.number == (lastChapter ?? 0) })
|
||||
}) {
|
||||
activeBlock = block.label
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChapterListRow
|
||||
|
||||
private struct ChapterListRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let slug: String
|
||||
let isCurrent: Bool
|
||||
|
||||
private var displayTitle: String {
|
||||
let pattern = #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#
|
||||
let stripped = (try? NSRegularExpression(pattern: pattern))?
|
||||
.stringByReplacingMatches(
|
||||
in: chapter.title,
|
||||
range: NSRange(chapter.title.startIndex..., in: chapter.title),
|
||||
withTemplate: ""
|
||||
).trimmingCharacters(in: .whitespaces) ?? chapter.title
|
||||
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||
return "Chapter \(chapter.number)"
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
|
||||
HStack(spacing: 14) {
|
||||
// Number badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isCurrent ? Color.amber : Color(.systemGray5))
|
||||
.frame(width: 40, height: 40)
|
||||
Text("\(chapter.number)")
|
||||
.font(.caption.bold().monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .white : .secondary)
|
||||
.minimumScaleFactor(0.6)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(displayTitle)
|
||||
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
|
||||
.foregroundStyle(isCurrent ? Color.amber : .primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if isCurrent {
|
||||
Label("Reading", systemImage: "bookmark.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.amber)
|
||||
} else if !chapter.dateLabel.isEmpty {
|
||||
Text(chapter.dateLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||||
.listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChapterJumpBar
|
||||
|
||||
private struct ChapterJumpBar: View {
|
||||
let labels: [String]
|
||||
let currentChapter: Int
|
||||
let groups: [(label: String, chapters: [ChapterIndex])]
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
private func shortLabel(_ full: String) -> String {
|
||||
full.components(separatedBy: "–").first ?? full
|
||||
}
|
||||
|
||||
private var currentBlock: String? {
|
||||
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(labels, id: \.self) { label in
|
||||
let isCurrent = label == currentBlock
|
||||
Text(shortLabel(label))
|
||||
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
|
||||
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(label) }
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.shadow(color: .black.opacity(0.15), radius: 4)
|
||||
)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
let itemHeight: CGFloat = 28
|
||||
let index = Int(value.location.y / itemHeight)
|
||||
let clamped = max(0, min(labels.count - 1, index))
|
||||
onSelect(labels[clamped])
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookStatusBadge
|
||||
|
||||
private struct BookStatusBadge: View {
|
||||
let status: String
|
||||
|
||||
private var color: Color {
|
||||
switch status.lowercased() {
|
||||
case "ongoing", "active": return .green
|
||||
case "completed": return .blue
|
||||
case "hiatus": return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(color).frame(width: 6, height: 6)
|
||||
Text(status.capitalized)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(color.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookMetaStat
|
||||
|
||||
private struct BookMetaStat: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
446
ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift
Normal file
446
ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift
Normal file
@@ -0,0 +1,446 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - BrowseCategoryView
|
||||
// Full paginated grid for "See All" / genre deep-dives.
|
||||
// Supports browse (infinite scroll) and rank (flat list) modes.
|
||||
// Sort/genre/status can be adjusted via the filters sheet.
|
||||
|
||||
struct BrowseCategoryView: View {
|
||||
let sort: String
|
||||
let genre: String
|
||||
let status: String
|
||||
let title: String
|
||||
|
||||
@State private var vm = BrowseViewModel()
|
||||
@State private var showFilters = false
|
||||
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||
|
||||
init(sort: String, genre: String, status: String, title: String) {
|
||||
self.sort = sort
|
||||
self.genre = genre
|
||||
self.status = status
|
||||
self.title = title
|
||||
}
|
||||
|
||||
private var isRankMode: Bool { sort == "rank" }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if vm.isLoading && vm.novels.isEmpty {
|
||||
loadingState
|
||||
} else if let err = vm.error, vm.novels.isEmpty {
|
||||
errorState(message: err)
|
||||
} else if vm.novels.isEmpty && !vm.isLoading {
|
||||
emptyState
|
||||
} else if isRankMode {
|
||||
rankList
|
||||
} else {
|
||||
novelGrid
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.toolbar { toolbarContent }
|
||||
.task {
|
||||
guard networkMonitor.isConnected else { return }
|
||||
vm.sort = sort
|
||||
vm.genre = genre
|
||||
vm.status = status
|
||||
if vm.novels.isEmpty {
|
||||
if isRankMode {
|
||||
await vm.loadRanking()
|
||||
} else {
|
||||
await vm.loadFirstPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: vm.sort) { _, _ in
|
||||
Task { await refreshForFilters() }
|
||||
}
|
||||
.onChange(of: vm.genre) { _, _ in
|
||||
Task { await refreshForFilters() }
|
||||
}
|
||||
.onChange(of: vm.status) { _, _ in
|
||||
Task { await refreshForFilters() }
|
||||
}
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersSheet(vm: vm)
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Grid view
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)
|
||||
]
|
||||
|
||||
private var novelGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
ForEach(vm.novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
BrowseCategoryCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
if novel.id == vm.novels.last?.id && vm.hasNext {
|
||||
Task { await vm.loadNextPage() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
// Load-more indicator
|
||||
if vm.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding(.vertical, 24)
|
||||
.tint(Color.amber)
|
||||
} else if !vm.hasNext && !vm.novels.isEmpty {
|
||||
Text("All novels loaded")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.quaternary)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
.refreshable { await vm.loadFirstPage() }
|
||||
}
|
||||
|
||||
// MARK: - Rank list view
|
||||
|
||||
private var rankList: some View {
|
||||
List {
|
||||
ForEach(vm.novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
RankListRow(novel: novel)
|
||||
}
|
||||
.listRowBackground(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||
.listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable { await vm.loadRanking() }
|
||||
}
|
||||
|
||||
// MARK: - Loading / error / empty
|
||||
|
||||
private var loadingState: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
ForEach(0..<10, id: \.self) { _ in
|
||||
BrowseCategoryCardSkeleton()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorState(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "wifi.slash",
|
||||
title: "Couldn't load",
|
||||
message: message,
|
||||
ctaLabel: "Retry",
|
||||
ctaAction: {
|
||||
Task {
|
||||
if isRankMode { await vm.loadRanking() }
|
||||
else { await vm.loadFirstPage() }
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "No novels found",
|
||||
message: "Try different filters.",
|
||||
ctaLabel: "Change Filters",
|
||||
ctaAction: { showFilters = true }
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
showFilters = true
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
.accessibilityLabel("Filter novels")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter change
|
||||
|
||||
private func refreshForFilters() async {
|
||||
if vm.sort == "rank" {
|
||||
await vm.loadRanking()
|
||||
} else {
|
||||
await vm.loadFirstPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowseCategoryCard
|
||||
|
||||
struct BrowseCategoryCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(Color.amber)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !novel.author.isEmpty {
|
||||
Text(novel.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowseCategoryCardSkeleton
|
||||
|
||||
private struct BrowseCategoryCardSkeleton: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1)))
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||||
.frame(height: 14)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||||
.frame(width: 80, height: 11)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RankListRow
|
||||
|
||||
private struct RankListRow: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Rank number
|
||||
Text(novel.rank.isEmpty ? "–" : novel.rank)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
|
||||
// Cover thumbnail
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(width: 44, height: 62)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
|
||||
// Title + meta
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if !novel.author.isEmpty {
|
||||
Text(novel.author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if !novel.status.isEmpty {
|
||||
TagChip(label: novel.status.capitalized)
|
||||
}
|
||||
if !novel.rating.isEmpty {
|
||||
TagChip(label: "★ \(novel.rating)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowseFiltersSheet
|
||||
|
||||
struct BrowseFiltersSheet: View {
|
||||
var vm: BrowseViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let sortOptions: [(value: String, label: String)] = [
|
||||
("popular", "Popular"),
|
||||
("new", "New"),
|
||||
("update", "Updated"),
|
||||
("rank", "Ranking"),
|
||||
]
|
||||
private let genreOptions: [(value: String, label: String)] = [
|
||||
("all", "All Genres"),
|
||||
("action", "Action"),
|
||||
("adventure", "Adventure"),
|
||||
("comedy", "Comedy"),
|
||||
("drama", "Drama"),
|
||||
("fantasy", "Fantasy"),
|
||||
("harem", "Harem"),
|
||||
("historical", "Historical"),
|
||||
("horror", "Horror"),
|
||||
("isekai", "Isekai"),
|
||||
("martial-arts", "Martial Arts"),
|
||||
("mystery", "Mystery"),
|
||||
("psychological", "Psychological"),
|
||||
("romance", "Romance"),
|
||||
("sci-fi", "Sci-Fi"),
|
||||
("system", "System"),
|
||||
("xianxia", "Xianxia"),
|
||||
]
|
||||
private let statusOptions: [(value: String, label: String)] = [
|
||||
("all", "All"),
|
||||
("ongoing", "Ongoing"),
|
||||
("completed", "Completed"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Sort") {
|
||||
ForEach(sortOptions, id: \.value) { opt in
|
||||
filterRow(label: opt.label, isSelected: vm.sort == opt.value) {
|
||||
vm.sort = opt.value
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Genre") {
|
||||
ForEach(genreOptions, id: \.value) { opt in
|
||||
filterRow(label: opt.label, isSelected: vm.genre == opt.value) {
|
||||
vm.genre = opt.value
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(vm.sort == "rank")
|
||||
|
||||
Section("Status") {
|
||||
ForEach(statusOptions, id: \.value) { opt in
|
||||
filterRow(label: opt.label, isSelected: vm.status == opt.value) {
|
||||
vm.status = opt.value
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(vm.sort == "rank")
|
||||
|
||||
if vm.sort == "rank" {
|
||||
Section {
|
||||
Text("Genre & status filters apply to Browse only")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func filterRow(label: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Color.amber)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
action()
|
||||
}
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
}
|
||||
411
ios/LibNovelV2/Views/Browse/BrowseView.swift
Normal file
411
ios/LibNovelV2/Views/Browse/BrowseView.swift
Normal file
@@ -0,0 +1,411 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - BrowseView
|
||||
// "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking)
|
||||
// plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience.
|
||||
|
||||
struct BrowseView: View {
|
||||
@State private var vm = BrowseViewModel()
|
||||
@State private var showGenreSheet = false
|
||||
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.trending.isEmpty {
|
||||
loadingState
|
||||
} else if let err = vm.error, vm.trending.isEmpty {
|
||||
errorState(message: err)
|
||||
} else {
|
||||
shelvesContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||
.navigationTitle("Discover")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.task {
|
||||
guard networkMonitor.isConnected else { return }
|
||||
if vm.trending.isEmpty { await vm.loadShelves() }
|
||||
}
|
||||
.refreshable { await vm.loadShelves() }
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showGenreSheet) {
|
||||
GenrePickerSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelves content
|
||||
|
||||
private var shelvesContent: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 32) {
|
||||
|
||||
// Trending Now
|
||||
if !vm.trending.isEmpty {
|
||||
BrowseShelf(
|
||||
title: "Trending Now",
|
||||
novels: vm.trending,
|
||||
destination: NavDestination.browseCategory(
|
||||
sort: "popular", genre: "all", status: "all", title: "Trending Now"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// New Releases
|
||||
if !vm.newReleases.isEmpty {
|
||||
BrowseShelf(
|
||||
title: "New Releases",
|
||||
novels: vm.newReleases,
|
||||
destination: NavDestination.browseCategory(
|
||||
sort: "new", genre: "all", status: "all", title: "New Releases"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Recently Updated
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
BrowseShelf(
|
||||
title: "Recently Updated",
|
||||
novels: vm.recentlyUpdated,
|
||||
destination: NavDestination.browseCategory(
|
||||
sort: "update", genre: "all", status: "all", title: "Recently Updated"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Rankings (list-style shelf)
|
||||
if !vm.ranking.isEmpty {
|
||||
BrowseShelf(
|
||||
title: "Rankings",
|
||||
novels: vm.ranking,
|
||||
destination: NavDestination.browseCategory(
|
||||
sort: "rank", genre: "all", status: "all", title: "Rankings"
|
||||
),
|
||||
showRank: true
|
||||
)
|
||||
}
|
||||
|
||||
// Browse by Genre
|
||||
CategoriesRow { showGenreSheet = true }
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading / error states
|
||||
|
||||
private var loadingState: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 32) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
BrowseShelfSkeleton()
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorState(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "wifi.slash",
|
||||
title: "Couldn't load",
|
||||
message: message,
|
||||
ctaLabel: "Retry",
|
||||
ctaAction: { Task { await vm.loadShelves() } }
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowseShelf
|
||||
// Amber-accented header + horizontal card scroll + "See All" link.
|
||||
|
||||
struct BrowseShelf: View {
|
||||
let title: String
|
||||
let novels: [BrowseNovel]
|
||||
let destination: NavDestination
|
||||
var showRank: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Header row
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Color.amber)
|
||||
.frame(width: 3, height: 18)
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
NavigationLink(value: destination) {
|
||||
HStack(spacing: 4) {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.bold())
|
||||
}
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
// Horizontal scroll
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
BrowseShelfCard(novel: novel, showRank: showRank)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowseShelfCard
|
||||
|
||||
struct BrowseShelfCard: View {
|
||||
let novel: BrowseNovel
|
||||
var showRank: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(width: 120, height: 173)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if showRank && !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(Color.amber)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
} else if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
|
||||
if !novel.author.isEmpty {
|
||||
Text(novel.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
} else if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(width: 132)
|
||||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowseShelfSkeleton
|
||||
|
||||
struct BrowseShelfSkeleton: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Header skeleton
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.amber.opacity(0.3))
|
||||
.frame(width: 3, height: 18)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||||
.frame(width: 140, height: 20)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
// Cards skeleton
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(0..<5, id: \.self) { _ in
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1)))
|
||||
.frame(width: 132, height: 220)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CategoriesRow
|
||||
|
||||
struct CategoriesRow: View {
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
onTap()
|
||||
}) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.amber.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Browse by Genre")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Action, Fantasy, Romance & more")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Browse by Genre")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GenrePickerSheet
|
||||
|
||||
struct GenrePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let genres: [(label: String, value: String, icon: String)] = [
|
||||
("All Novels", "all", "books.vertical.fill"),
|
||||
("Action", "action", "bolt.fill"),
|
||||
("Adventure", "adventure", "map.fill"),
|
||||
("Comedy", "comedy", "face.smiling.fill"),
|
||||
("Drama", "drama", "theatermasks.fill"),
|
||||
("Fantasy", "fantasy", "wand.and.stars"),
|
||||
("Harem", "harem", "person.3.fill"),
|
||||
("Historical", "historical", "building.columns.fill"),
|
||||
("Horror", "horror", "moon.fill"),
|
||||
("Isekai", "isekai", "globe.americas.fill"),
|
||||
("Martial Arts", "martial-arts", "figure.martial.arts"),
|
||||
("Mystery", "mystery", "magnifyingglass"),
|
||||
("Psychological","psychological","brain.head.profile"),
|
||||
("Romance", "romance", "heart.fill"),
|
||||
("Sci-Fi", "sci-fi", "sparkles"),
|
||||
("System", "system", "cpu"),
|
||||
("Xianxia", "xianxia", "leaf.fill"),
|
||||
]
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(genres, id: \.value) { item in
|
||||
NavigationLink(value: NavDestination.browseCategory(
|
||||
sort: "popular",
|
||||
genre: item.value,
|
||||
status: "all",
|
||||
title: item.label
|
||||
)) {
|
||||
GenreTile(label: item.label, icon: item.icon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Genres")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationCornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GenreTile
|
||||
|
||||
private struct GenreTile: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 24)
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user