Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0ae2bcc4 | ||
|
|
f8de17723a | ||
|
|
4769e832c4 | ||
|
|
03c3e151dc | ||
|
|
3576a4415a | ||
|
|
3325b64463 | ||
|
|
a9430f3c06 | ||
|
|
96fd2aad42 | ||
|
|
1f30602c23 | ||
|
|
18bed79d3a | ||
|
|
fe7eab91e8 | ||
|
|
295c4b0664 | ||
|
|
db96ea91cd | ||
|
|
408a972665 | ||
|
|
6508abc00f | ||
|
|
3ed852515b | ||
|
|
f62b46fb15 | ||
|
|
35d8ba32e5 | ||
|
|
80bfc1a77b | ||
|
|
970beee4b8 | ||
|
|
fa5b952c54 | ||
|
|
ff17d14694 | ||
|
|
807899b137 | ||
|
|
6791d611f0 | ||
|
|
e5315f1b94 |
No files matched your search
@@ -71,6 +71,16 @@ were name-only across ALL libraries. Fixed; do not regress.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
- **THE DOCTRINE — tests are never run against a real/production
|
||||||
|
server.** The dev DB is the test environment, and the invariant every
|
||||||
|
test must uphold is: **leave the testuser experience alone** — its
|
||||||
|
settings, scans, and reading data must be exactly as the suite found
|
||||||
|
them. The helpers exist to enforce this; if a test needs protection
|
||||||
|
the helpers don't provide, EXPAND THE HELPERS rather than hand-rolling
|
||||||
|
(2026-10-21: `setSystemSettingRestored` — snapshot-and-restore for
|
||||||
|
system_settings — exists because a hardcoded ev restore could clobber
|
||||||
|
a deliberate dev bump, and the live dev server obeys that setting on
|
||||||
|
its next poll).
|
||||||
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
|
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
|
||||||
(see the Conventions section below for the pool/cleanup rules).
|
(see the Conventions section below for the pool/cleanup rules).
|
||||||
- Integration tests run against the SHARED dev database: never write
|
- Integration tests run against the SHARED dev database: never write
|
||||||
@@ -113,6 +123,23 @@ were name-only across ALL libraries. Fixed; do not regress.
|
|||||||
tested with synthetic ffprobeReport values. Recipes:
|
tested with synthetic ffprobeReport values. Recipes:
|
||||||
docs/contributing/development.md → Media fixtures.
|
docs/contributing/development.md → Media fixtures.
|
||||||
|
|
||||||
|
## Web (TypeScript) tests
|
||||||
|
|
||||||
|
- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts`
|
||||||
|
files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch
|
||||||
|
to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes
|
||||||
|
the markdown security suite test nothing (verified empirically; jsdom is the
|
||||||
|
documented fallback for exactly this reason).
|
||||||
|
- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint),
|
||||||
|
`npx vp fmt web/src` to apply formatting.
|
||||||
|
- Tests are never bundled (build entries are main.ts/reader.ts only). When
|
||||||
|
touching build config, re-prove it: back-to-back `npm run build:ts` runs must
|
||||||
|
produce byte-identical hashes, and no test-only strings may appear in
|
||||||
|
web/static.
|
||||||
|
- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks,
|
||||||
|
parser-level html escaping). Loosening any assertion there is a security
|
||||||
|
decision, not cleanup.
|
||||||
|
|
||||||
## Timezones
|
## Timezones
|
||||||
|
|
||||||
- The server owns display conversion: clients send instants (RFC3339),
|
- The server owns display conversion: clients send instants (RFC3339),
|
||||||
@@ -206,3 +233,25 @@ of a server bug: stop and flag the user.
|
|||||||
test pools use `max_conns=1`; never create a `pgxpool` per subtest
|
test pools use `max_conns=1`; never create a `pgxpool` per subtest
|
||||||
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
||||||
subtests over duplicate functions.
|
subtests over duplicate functions.
|
||||||
|
7. **Scanner re-extraction is write-after-success** — a failed/empty
|
||||||
|
extraction must NEVER clear scan-derived columns. `updateMediaItem`
|
||||||
|
(generic full-row path) preserves `existing.AudioMetadata` when the
|
||||||
|
new extraction carries no audio payload, and `updateAudioBookItem`
|
||||||
|
refuses empty payloads over populated rows (both guarded 2026-10-21
|
||||||
|
after an ev-gated re-extract NULLed every multi-file audiobook's
|
||||||
|
audio_metadata — books displayed as 1 second long and lost their
|
||||||
|
track manifests). New scan-owned column writes need the same guard.
|
||||||
|
8. **templ double-escapes style attribute values** — the codegen wraps
|
||||||
|
style exprs in a sanitizer AND `templ.EscapeString`, so ANY quote
|
||||||
|
character (even inside `templ.SafeCSS`) reaches the DOM as literal
|
||||||
|
`'` text and the CSS never parses (2026-10-21: every book-detail
|
||||||
|
hero backdrop broken + Firefox fetched `/media/&` → 500 spam).
|
||||||
|
Emit quote-free CSS: unquoted `url(...)` tokens via `SafeCSS` +
|
||||||
|
`cssURL()` in book_detail.templ.
|
||||||
|
9. **`vp test` runs the vite config** — plugins with build hooks fire
|
||||||
|
in the test context too. `cleanStaleChunks` guards on
|
||||||
|
`produced.size === 0` precisely because a bare sweep deleted every
|
||||||
|
hashed chunk in `web/static` when run under vitest (2026-10-21:
|
||||||
|
`npm run test` gutted the build; every page 404'd its imports).
|
||||||
|
Any future outDir-mutating plugin needs the same "am I a build?"
|
||||||
|
guard.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Playback speed preferences (player plan §8): a per-user PER-BOOK
|
||||||
|
// speed plus a user-level default, resolved at the chapters endpoint
|
||||||
|
// (per-book row → users.default_audio_speed → 1.0). All speed writes
|
||||||
|
// ride the fresh per-test regular user — the shared admin account is
|
||||||
|
// never touched (AGENTS doctrine). Fixture books live in test-named
|
||||||
|
// libraries that cascade away with the library delete.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
|
||||||
|
// multiple fixture books never collide on the scanner's content dedup.
|
||||||
|
// Falls back to dummy bytes (ingest is ffprobe-optional) like
|
||||||
|
// writeTestTrack.
|
||||||
|
func writeTestTrackTone(t *testing.T, path string, hz int) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||||
|
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
|
||||||
|
if out, err := cmd.CombinedOutput(); err == nil {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioPlaybackSpeed(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
|
||||||
|
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
|
||||||
|
// dedup doesn't skip the second book as a byte-duplicate.
|
||||||
|
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
|
||||||
|
// Grouping depends on track count (single-track folders ingest as
|
||||||
|
// single-file items whose path IS the file; multi-track folders get
|
||||||
|
// the folder path) — find by the library-relative path CONTAINING
|
||||||
|
// the book folder so the test holds either way.
|
||||||
|
var bookPath, book2Path string
|
||||||
|
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||||
|
p := filepath.ToSlash(item.FilePath)
|
||||||
|
t.Logf("ingested item path: %s", p)
|
||||||
|
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
|
||||||
|
book2Path = p
|
||||||
|
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
|
||||||
|
bookPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, bookPath, "Speed Book item must exist")
|
||||||
|
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, bookPath)
|
||||||
|
require.True(t, found)
|
||||||
|
bookID := uuid.UUID(book.ID.Bytes)
|
||||||
|
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
|
||||||
|
require.True(t, found)
|
||||||
|
book2ID := uuid.UUID(book2.ID.Bytes)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
token := setup.RegularToken
|
||||||
|
authed := func(method, url, body string) (*http.Response, func()) {
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req, _ = http.NewRequest(method, url, nil)
|
||||||
|
} else {
|
||||||
|
req, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, func() { _ = resp.Body.Close() }
|
||||||
|
}
|
||||||
|
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
|
||||||
|
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var data struct {
|
||||||
|
PlaybackSpeed float64 `json:"playback_speed"`
|
||||||
|
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||||
|
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unset resolves to 1x", func(t *testing.T) {
|
||||||
|
playback, def := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.0, playback)
|
||||||
|
require.Equal(t, 1.0, def)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var saved struct {
|
||||||
|
PlaybackSpeed float64 `json:"playback_speed"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
|
||||||
|
require.Equal(t, 1.5, saved.PlaybackSpeed)
|
||||||
|
|
||||||
|
playback, def := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback)
|
||||||
|
require.Equal(t, 1.0, def) // user default untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("out of range speed is rejected", func(t *testing.T) {
|
||||||
|
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
closer()
|
||||||
|
}
|
||||||
|
// The stored speed is untouched by the rejects.
|
||||||
|
playback, _ := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Per-book row wins over the default on book one...
|
||||||
|
playback, def := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback)
|
||||||
|
require.Equal(t, 1.25, def)
|
||||||
|
// ...book two has no row and picks the default up.
|
||||||
|
playback2, _ := getSpeeds(book2ID)
|
||||||
|
require.Equal(t, 1.25, playback2)
|
||||||
|
|
||||||
|
// GET /profile carries it for the profile form.
|
||||||
|
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
|
||||||
|
defer closer2()
|
||||||
|
var profile struct {
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
|
||||||
|
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
playback2, _ := getSpeeds(book2ID)
|
||||||
|
require.Equal(t, 0.75, playback2)
|
||||||
|
playback1, _ := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback1) // per-book pin survives
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -47,6 +47,24 @@ func writeTestTrack(t *testing.T, path string) {
|
|||||||
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
|
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeTestTrackM4B is writeTestTrack for the m4b (AAC/ipod muxer)
|
||||||
|
// container — the audiobook format whose rescan route is under guard.
|
||||||
|
// ffmpeg needs -f ipod for the .m4b extension; falls back to dummy bytes.
|
||||||
|
func writeTestTrackM4B(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||||
|
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "aac", "-b:a", "32k", "-f", "ipod", path)
|
||||||
|
if out, err := cmd.CombinedOutput(); err == nil {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Logf("ffmpeg m4b render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("dummy m4b audio"), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
// audioTestLibrary builds an audiobooks-type library with a fresh temp
|
// audioTestLibrary builds an audiobooks-type library with a fresh temp
|
||||||
// folder, returning its UUID and the folder path.
|
// folder, returning its UUID and the folder path.
|
||||||
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
||||||
|
|||||||
@@ -17,11 +17,9 @@ package main
|
|||||||
// equal → stable (second corruption persists)
|
// equal → stable (second corruption persists)
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -58,14 +56,11 @@ func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID,
|
|||||||
|
|
||||||
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
|
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, _ := json.Marshal(map[string]string{"key": "scan_extractor_version", "value": strconv.Itoa(v)})
|
// Restore-to-prior (not a hardcoded value): the shared dev DB's
|
||||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
|
// setting must survive the suite exactly as the test found it — if a
|
||||||
req.Header.Set("Content-Type", "application/json")
|
// parser-fix bump was in flight, a hardcoded 1 would silently cancel
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
// it and the live server would stop re-extracting.
|
||||||
resp, err := http.DefaultClient.Do(req)
|
setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractorVersionReextract(t *testing.T) {
|
func TestExtractorVersionReextract(t *testing.T) {
|
||||||
@@ -123,7 +118,8 @@ func TestExtractorVersionReextract(t *testing.T) {
|
|||||||
// re-extract once: the corruption heals and the fresh manifest
|
// re-extract once: the corruption heals and the fresh manifest
|
||||||
// carries ev=2.
|
// carries ev=2.
|
||||||
setExtractorVersion(t, setup, 2)
|
setExtractorVersion(t, setup, 2)
|
||||||
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
|
// Prior-value restore rides inside setExtractorVersion — never a
|
||||||
|
// hardcoded 1, so a deliberate dev bump survives the suite.
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||||
require.True(t, found)
|
require.True(t, found)
|
||||||
@@ -168,7 +164,8 @@ func TestExtractorVersionMultifile(t *testing.T) {
|
|||||||
// Bump → the ev gate fires through multifileSidecarChanged → the
|
// Bump → the ev gate fires through multifileSidecarChanged → the
|
||||||
// editorial apply re-derives the title from the folder basename.
|
// editorial apply re-derives the title from the folder basename.
|
||||||
setExtractorVersion(t, setup, 2)
|
setExtractorVersion(t, setup, 2)
|
||||||
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
|
// Prior-value restore rides inside setExtractorVersion — never a
|
||||||
|
// hardcoded 1, so a deliberate dev bump survives the suite.
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
||||||
require.True(t, found)
|
require.True(t, found)
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ import (
|
|||||||
// the default 1 for the test's duration. Prior interrupted runs can
|
// the default 1 for the test's duration. Prior interrupted runs can
|
||||||
// leave it bumped (an early test failure aborts before its restore
|
// leave it bumped (an early test failure aborts before its restore
|
||||||
// cleanup registers), and every stamp assertion in these tests assumes
|
// cleanup registers), and every stamp assertion in these tests assumes
|
||||||
// the default. The cleanup restores 1 — the repo's steady state.
|
// the default. Restore-to-prior rides inside setExtractorVersion: the
|
||||||
|
// suite leaves the setting exactly as it found it.
|
||||||
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
|
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
setExtractorVersion(t, setup, 1)
|
setExtractorVersion(t, setup, 1)
|
||||||
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// forceScanTestFolder runs a FORCE scan over one folder.
|
// forceScanTestFolder runs a FORCE scan over one folder.
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRescanMediaItemPreservesAudioMetadata pins the 2026-10-21 incident:
|
||||||
|
// a per-item rescan of a multi-file audiobook routes book-level metadata
|
||||||
|
// (sidecars/tags — AudioMeta nil) through the generic row update, whose
|
||||||
|
// unguarded write NULLed audio_metadata. A NULL payload drops every
|
||||||
|
// track/chapter and the duration, so the book displays as 1 second long
|
||||||
|
// and the audio reader loses its track manifest. Re-extraction must be
|
||||||
|
// write-after-success: no fresh audio payload from the audio chain means
|
||||||
|
// the existing column survives byte-for-byte.
|
||||||
|
func TestRescanMediaItemPreservesAudioMetadata(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Guard %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "01 One.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "02 Two.mp3"))
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
rel := filepath.ToSlash("Guard Author/Guard Book")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||||
|
require.True(t, found, "folder book must import")
|
||||||
|
|
||||||
|
audioBytes := func(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var raw []byte
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
before := audioBytes(t)
|
||||||
|
require.NotNil(t, before, "initial scan must populate audio_metadata")
|
||||||
|
var payload services.AudioMetadataPayload
|
||||||
|
require.NoError(t, json.Unmarshal(before, &payload), "payload must be valid JSON")
|
||||||
|
require.Greater(t, payload.DurationSeconds, 0.0, "initial scan must carry a duration")
|
||||||
|
require.Len(t, payload.Tracks, 2, "initial scan must carry the track manifest")
|
||||||
|
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
defer func() { _ = scanner.Close() }()
|
||||||
|
scanner.SetAdminID(admin)
|
||||||
|
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false), "per-item rescan must succeed")
|
||||||
|
|
||||||
|
after := audioBytes(t)
|
||||||
|
require.NotNil(t, after, "rescan must never NULL audio_metadata (2026-10-21 incident)")
|
||||||
|
var afterPayload services.AudioMetadataPayload
|
||||||
|
require.NoError(t, json.Unmarshal(after, &afterPayload), "surviving payload must stay valid JSON")
|
||||||
|
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the rescan")
|
||||||
|
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the rescan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetadataEditorSavePreservesAudioMetadata pins the editor-side half
|
||||||
|
// of the incident: PUT /api/media-items/:id (the metadata editor) writes
|
||||||
|
// the FULL row, and audio_metadata has no editor field — an unguarded
|
||||||
|
// save NULLed every audiobook's payload on a plain metadata edit.
|
||||||
|
func TestMetadataEditorSavePreservesAudioMetadata(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Editor Guard %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "01 One.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "02 Two.mp3"))
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
rel := filepath.ToSlash("Editor Author/Editor Book")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||||
|
require.True(t, found, "folder book must import")
|
||||||
|
|
||||||
|
audioBytes := func(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var raw []byte
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
before := audioBytes(t)
|
||||||
|
require.NotNil(t, before, "initial scan must populate audio_metadata")
|
||||||
|
|
||||||
|
// A plain metadata edit through the editor endpoint (admin token —
|
||||||
|
// the editor is admin-only), no audio fields involved.
|
||||||
|
editorSave(t, setup, book.ID, map[string]interface{}{
|
||||||
|
"title": "Editor Book Renamed",
|
||||||
|
"cover_action": "keep",
|
||||||
|
})
|
||||||
|
|
||||||
|
after := audioBytes(t)
|
||||||
|
require.NotNil(t, after, "metadata edit must never NULL audio_metadata")
|
||||||
|
var afterPayload services.AudioMetadataPayload
|
||||||
|
require.NoError(t, json.Unmarshal(after, &afterPayload))
|
||||||
|
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the edit")
|
||||||
|
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the edit")
|
||||||
|
|
||||||
|
renamed, found := findAudioItemByPath(t, setup, libID, rel)
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, "Editor Book Renamed", renamed.Title, "the edit itself must still apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRescanMediaItemPreservesAudioMetadataSingleFileM4B is the incident
|
||||||
|
// class verbatim: the failing books were SINGLE-FILE m4b audiobooks
|
||||||
|
// (audio_metadata without a layout key), which RescanMediaItem routes
|
||||||
|
// through the generic per-file updater — the same writer whose unguarded
|
||||||
|
// pass NULLed their payloads. The whole flow must keep the m4b's
|
||||||
|
// duration and chapters intact.
|
||||||
|
func TestRescanMediaItemPreservesAudioMetadataSingleFileM4B(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test M4B Guard %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
writeTestTrackM4B(t, filepath.Join(folder, "M4B Guard Author", "M4B Guard Book.m4b"))
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
rel := filepath.ToSlash("M4B Guard Author/M4B Guard Book.m4b")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||||
|
require.True(t, found, "single-file m4b must import")
|
||||||
|
|
||||||
|
audioBytes := func(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var raw []byte
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
before := audioBytes(t)
|
||||||
|
require.NotNil(t, before, "initial scan must populate audio_metadata")
|
||||||
|
var beforePayload services.AudioMetadataPayload
|
||||||
|
require.NoError(t, json.Unmarshal(before, &beforePayload))
|
||||||
|
require.Greater(t, beforePayload.DurationSeconds, 0.0, "m4b probe must carry a real duration")
|
||||||
|
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
defer func() { _ = scanner.Close() }()
|
||||||
|
scanner.SetAdminID(admin)
|
||||||
|
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false))
|
||||||
|
|
||||||
|
after := audioBytes(t)
|
||||||
|
require.NotNil(t, after, "m4b rescan must never NULL audio_metadata")
|
||||||
|
var afterPayload services.AudioMetadataPayload
|
||||||
|
require.NoError(t, json.Unmarshal(after, &afterPayload))
|
||||||
|
require.Greater(t, afterPayload.DurationSeconds, 0.0, "m4b duration must survive the rescan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorSave drives PUT /api/media-items/:id as the admin (the metadata
|
||||||
|
// editor's endpoint; admin-only per the handler).
|
||||||
|
func editorSave(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, fields map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(fields)
|
||||||
|
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+uuid.UUID(itemID.Bytes).String(), bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "metadata save must succeed")
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -181,6 +182,54 @@ func getUploadPath() string {
|
|||||||
return "./uploads" // Host path (left side of volume mount)
|
return "./uploads" // Host path (left side of volume mount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSystemSettingRestored writes a system setting through the sanctioned
|
||||||
|
// API path and registers a cleanup that restores the value the row had
|
||||||
|
// BEFORE the write. The testuser-experience rule: the suite must leave
|
||||||
|
// shared settings exactly as it found them — a hardcoded restore would
|
||||||
|
// silently clobber a deliberate dev bump (e.g. scan_extractor_version
|
||||||
|
// pinned mid-deploy), and the live dev server obeys this setting on its
|
||||||
|
// next poll, so what tests write here reaches real scans.
|
||||||
|
//
|
||||||
|
// Writes ride the settings API (never raw SQL) because the registry
|
||||||
|
// caches values and only the API path reloads it. If the row did not
|
||||||
|
// exist before the write, the test value is left in place and the test
|
||||||
|
// log says so — there is no sanctioned delete; settings rows that
|
||||||
|
// matter (scan_extractor_version) always exist.
|
||||||
|
func setSystemSettingRestored(t *testing.T, setup *TestServerSetup, key, value string) {
|
||||||
|
t.Helper()
|
||||||
|
var prior *string
|
||||||
|
err := setup.DBPool.QueryRow(context.Background(),
|
||||||
|
`SELECT setting_value FROM system_settings WHERE setting_key = $1`, key).Scan(&prior)
|
||||||
|
if err != nil && err != pgx.ErrNoRows {
|
||||||
|
require.NoError(t, err, "failed to read prior value of %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingViaAPI(t, setup, key, value)
|
||||||
|
|
||||||
|
if prior == nil {
|
||||||
|
t.Logf("setSystemSettingRestored: %s had no prior row; leaving %q in place after the test", key, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
priorValue := *prior
|
||||||
|
t.Cleanup(func() {
|
||||||
|
setSettingViaAPI(t, setup, key, priorValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSettingViaAPI PUTs one setting through /api/system/settings — the
|
||||||
|
// only sanctioned write path (the settings registry reloads here).
|
||||||
|
func setSettingViaAPI(t *testing.T, setup *TestServerSetup, key, value string) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]string{"key": key, "value": value})
|
||||||
|
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
|
||||||
|
}
|
||||||
|
|
||||||
// getCachePath returns the appropriate cache path based on runtime environment
|
// getCachePath returns the appropriate cache path based on runtime environment
|
||||||
func getCachePath() string {
|
func getCachePath() string {
|
||||||
// Check for explicit override first
|
// Check for explicit override first
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- User-level default audiobook playback speed (player plan §8): books
|
||||||
|
-- without a per-book audio_playback_speed row pick this up. REAL
|
||||||
|
-- mirrors the per-book table's column.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
|
||||||
|
|
||||||
-- Create system_settings table
|
-- Create system_settings table
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -1563,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
||||||
|
|
||||||
|
-- Per-user PER-BOOK playback speed preference (player plan §8): a
|
||||||
|
-- preference, NOT a measurement (reading_speed holds measured rates),
|
||||||
|
-- and NOT a reading_progress column (a speed tap must not create a
|
||||||
|
-- "currently reading" row). Resolution lives at the chapters endpoint:
|
||||||
|
-- per-book row → users.default_audio_speed → 1.0.
|
||||||
|
CREATE TABLE IF NOT EXISTS audio_playback_speed (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
|
speed REAL NOT NULL DEFAULT 1.0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, media_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id);
|
||||||
|
|
||||||
-- Dictionary cache (for offline use)
|
-- Dictionary cache (for offline use)
|
||||||
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
+22
-12
@@ -8,6 +8,15 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AudioPlaybackSpeed struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Speed float32 `db:"speed" json:"speed"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CollectionItems struct {
|
type CollectionItems struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||||
@@ -575,16 +584,17 @@ type UserDashboardPreferencesGlobal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Users struct {
|
type Users struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
}
|
}
|
||||||
@@ -202,6 +202,8 @@ type Querier interface {
|
|||||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||||
|
// Per-book playback speed preference (player plan §8).
|
||||||
|
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
|
||||||
// Lifetime session rows for ONE book (the book-detail stats card).
|
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||||
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
@@ -696,12 +698,14 @@ type Querier interface {
|
|||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||||
|
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
|
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
|
||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||||
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
|
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
|
||||||
|
|||||||
@@ -3761,6 +3761,25 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one
|
||||||
|
|
||||||
|
SELECT speed FROM audio_playback_speed
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAudioPlaybackSpeedParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-book playback speed preference (player plan §8).
|
||||||
|
func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID)
|
||||||
|
var speed float32
|
||||||
|
err := row.Scan(&speed)
|
||||||
|
return speed, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
||||||
SELECT
|
SELECT
|
||||||
rh.id, rh.device_id, rh.progress_percentage,
|
rh.id, rh.device_id, rh.progress_percentage,
|
||||||
@@ -9922,24 +9941,26 @@ SELECT
|
|||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
u.timezone,
|
u.timezone,
|
||||||
|
u.default_audio_speed,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1
|
WHERE u.id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetUserRow struct {
|
type GetUserRow struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
DeviceCount int64 `db:"device_count" json:"device_count"`
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
|
DeviceCount int64 `db:"device_count" json:"device_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
||||||
@@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
|
&i.DefaultAudioSpeed,
|
||||||
&i.DeviceCount,
|
&i.DeviceCount,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
|
||||||
|
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserDefaultAudioSpeedParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
||||||
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
||||||
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at
|
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserMaxDevicesParams struct {
|
type UpdateUserMaxDevicesParams struct {
|
||||||
@@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.DefaultAudioSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one
|
||||||
|
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, media_item_id)
|
||||||
|
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
|
||||||
|
RETURNING speed
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertAudioPlaybackSpeedParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Speed float32 `db:"speed" json:"speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed)
|
||||||
|
var speed float32
|
||||||
|
err := row.Scan(&speed)
|
||||||
|
return speed, err
|
||||||
|
}
|
||||||
|
|
||||||
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
|
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
|
||||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ SELECT
|
|||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
u.timezone,
|
u.timezone,
|
||||||
|
u.default_audio_speed,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1;
|
WHERE u.id = $1;
|
||||||
@@ -511,6 +512,22 @@ RETURNING id, email, username, role;
|
|||||||
-- name: UpdateUserTimezone :exec
|
-- name: UpdateUserTimezone :exec
|
||||||
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateUserDefaultAudioSpeed :exec
|
||||||
|
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- Per-book playback speed preference (player plan §8).
|
||||||
|
|
||||||
|
-- name: GetAudioPlaybackSpeed :one
|
||||||
|
SELECT speed FROM audio_playback_speed
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2;
|
||||||
|
|
||||||
|
-- name: UpsertAudioPlaybackSpeed :one
|
||||||
|
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, media_item_id)
|
||||||
|
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
|
||||||
|
RETURNING speed;
|
||||||
|
|
||||||
-- name: GetSystemTimezone :one
|
-- name: GetSystemTimezone :one
|
||||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -120,6 +121,8 @@ type UserProfile struct {
|
|||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Theme string `json:"theme,omitempty"`
|
Theme string `json:"theme,omitempty"`
|
||||||
|
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
|
|||||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
|
// Per the SPEEDS list bounds; the profile select always sends it,
|
||||||
|
// JSON clients omit it (0 = untouched).
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUpdateUserRequest struct {
|
type AdminUpdateUserRequest struct {
|
||||||
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
|
|||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||||
|
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /api/auth/register
|
// Register handles POST /api/auth/register
|
||||||
@@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
|||||||
lastName = user.LastName.String
|
lastName = user.LastName.String
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, UserProfile{
|
return c.JSON(http.StatusOK, UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
FirstName: firstName,
|
FirstName: firstName,
|
||||||
LastName: lastName,
|
LastName: lastName,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
Theme: user.Theme.String,
|
Theme: user.Theme.String,
|
||||||
|
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the default audiobook playback speed (plan §8). The form
|
||||||
|
// select always sends it; JSON clients that omit it decode as 0 and
|
||||||
|
// leave the column untouched.
|
||||||
|
if req.DefaultAudioSpeed != 0 {
|
||||||
|
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
|
||||||
|
ID: targetUserUUID,
|
||||||
|
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Browser path (htmx modal + profile page): refresh so every rendered
|
// Browser path (htmx modal + profile page): refresh so every rendered
|
||||||
// value (timezone select, table rows, header) reflects the save and the
|
// value (timezone select, table rows, header) reflects the save and the
|
||||||
// modal closes. A bare JSON body swapped into #profile-result read as
|
// modal closes. A bare JSON body swapped into #profile-result read as
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -1610,6 +1611,14 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// audio_metadata is scan-owned (track manifest, chapters, duration)
|
||||||
|
// and has no editor field: the full-row update writes it
|
||||||
|
// unconditionally, so an unguarded save NULLed every audiobook's
|
||||||
|
// payload — books dropped back to "1 second long" on a plain
|
||||||
|
// metadata edit (2026-10-21 incident class). Write-after-success:
|
||||||
|
// carry the existing bytes; only the audio chain replaces them.
|
||||||
|
params.AudioMetadata = existing.AudioMetadata
|
||||||
|
|
||||||
// Fields the user actually changed become overrides so future scans keep
|
// Fields the user actually changed become overrides so future scans keep
|
||||||
// the custom values. An explicit cover upload/removal always overrides.
|
// the custom values. An explicit cover upload/removal always overrides.
|
||||||
overrides := utils.DetectMetadataOverrides(params, existing)
|
overrides := utils.DetectMetadataOverrides(params, existing)
|
||||||
@@ -2982,16 +2991,96 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playback speed (plan §8): resolve per-book row → user default →
|
||||||
|
// 1.0 IN the payload so the player applies it with zero extra round
|
||||||
|
// trips; default_playback_speed powers the dropdown's "(default)"
|
||||||
|
// marker. Speed is a preference, so this rides the authenticated
|
||||||
|
// user — anonymous readers just get 1.0.
|
||||||
|
playbackSpeed, defaultSpeed := 1.0, 1.0
|
||||||
|
if userID, ok := c.Get("user_id").(string); ok {
|
||||||
|
if userUUID, err := uuid.Parse(userID); err == nil {
|
||||||
|
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||||
|
if err == nil && userRow.DefaultAudioSpeed > 0 {
|
||||||
|
defaultSpeed = float64(userRow.DefaultAudioSpeed)
|
||||||
|
}
|
||||||
|
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err == nil && bookSpeed > 0 {
|
||||||
|
playbackSpeed = float64(bookSpeed)
|
||||||
|
} else {
|
||||||
|
playbackSpeed = defaultSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"media_item_id": item.ID,
|
"media_item_id": item.ID,
|
||||||
"chapters": payload.Chapters,
|
"chapters": payload.Chapters,
|
||||||
"duration_seconds": payload.DurationSeconds,
|
"duration_seconds": payload.DurationSeconds,
|
||||||
"codec_profile": payload.CodecProfile,
|
"codec_profile": payload.CodecProfile,
|
||||||
"layout": payload.Layout,
|
"layout": payload.Layout,
|
||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
|
"playback_speed": playbackSpeed,
|
||||||
|
"default_playback_speed": defaultSpeed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the
|
||||||
|
// per-user per-book playback speed upsert (plan §8). The client SPEEDS
|
||||||
|
// list is the real gate; the server sanity-checks the range.
|
||||||
|
func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
userID, ok := c.Get("user_id").(string)
|
||||||
|
if !ok || userID == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The book must exist (and be visible): a preference for a hidden
|
||||||
|
// library must not be writable — family-lens rule.
|
||||||
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||||
|
}
|
||||||
|
if item.LibraryID.Valid {
|
||||||
|
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||||
|
}
|
||||||
|
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
Speed: float32(req.Speed),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)})
|
||||||
|
}
|
||||||
|
|
||||||
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
||||||
// per-track byte stream for multi-file audiobooks (folder books serve the
|
// per-track byte stream for multi-file audiobooks (folder books serve the
|
||||||
// real file; zip books extract the entry in place). Library visibility is
|
// real file; zip books extract the entry in place). Library visibility is
|
||||||
|
|||||||
+11
-10
@@ -56,16 +56,17 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
return templates.User{
|
return templates.User{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
Username: userUsername,
|
Username: userUsername,
|
||||||
Role: userRole,
|
Role: userRole,
|
||||||
Theme: userTheme,
|
Theme: userTheme,
|
||||||
Token: token,
|
Token: token,
|
||||||
Timezone: userTimezone,
|
Timezone: userTimezone,
|
||||||
FirstName: utils.PgTextString(userDB.FirstName),
|
FirstName: utils.PgTextString(userDB.FirstName),
|
||||||
LastName: utils.PgTextString(userDB.LastName),
|
LastName: utils.PgTextString(userDB.LastName),
|
||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt.Time,
|
||||||
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// books stream through the existing /uploads Range-serving path;
|
// books stream through the existing /uploads Range-serving path;
|
||||||
// multi-file books additionally expose per-track streaming.
|
// multi-file books additionally expose per-track streaming.
|
||||||
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
||||||
|
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
|
||||||
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
||||||
|
|
||||||
// Media rating routes (all authenticated users)
|
// Media rating routes (all authenticated users)
|
||||||
|
|||||||
@@ -4049,6 +4049,18 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
|||||||
if metadata.AlternateInfo != "" {
|
if metadata.AlternateInfo != "" {
|
||||||
alternateInfoBytes = []byte(metadata.AlternateInfo)
|
alternateInfoBytes = []byte(metadata.AlternateInfo)
|
||||||
}
|
}
|
||||||
|
// A re-extraction that produced no audio payload must never wipe
|
||||||
|
// scan-derived audio_metadata (tracks/chapters/duration): the
|
||||||
|
// ev-gated generic pass runs for multi-file books too, where
|
||||||
|
// extractMetadata leaves AudioMeta nil — writing NULL here deleted
|
||||||
|
// every folder book's payload and broke playback (2026-10-21
|
||||||
|
// incident: all multi-file audiobooks showed as 1 second long). The
|
||||||
|
// audio chain (UpdateMediaItemAudioBookPayload) owns this column;
|
||||||
|
// without fresh data from THAT chain, keep what the row has.
|
||||||
|
audioMetadata := marshalAudioMetadata(metadata.AudioMeta)
|
||||||
|
if audioMetadata == nil {
|
||||||
|
audioMetadata = existing.AudioMetadata
|
||||||
|
}
|
||||||
params := database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
@@ -4089,7 +4101,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
|||||||
AlternateInfo: alternateInfoBytes,
|
AlternateInfo: alternateInfoBytes,
|
||||||
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
||||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
||||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
AudioMetadata: audioMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep user-customized fields, and keep the override set itself intact.
|
// Keep user-customized fields, and keep the override set itself intact.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAudioPayloadIsEmpty pins the write-after-success guard on
|
||||||
|
// updateAudioBookItem (2026-10-21 incident: multi-file audiobooks
|
||||||
|
// surfaced as 1 second long because a failed re-extraction overwrote a
|
||||||
|
// populated audio_metadata). An empty payload — nil, or no tracks, no
|
||||||
|
// chapters, no duration — is the signature of a failed extraction and
|
||||||
|
// must never replace existing audio data.
|
||||||
|
func TestAudioPayloadIsEmpty(t *testing.T) {
|
||||||
|
require := func(cond bool, msg string) {
|
||||||
|
if !cond {
|
||||||
|
t.Fatal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require(audioPayloadIsEmpty(nil), "nil payload must read as empty")
|
||||||
|
require(audioPayloadIsEmpty(&AudioMetadataPayload{}), "zero-value payload must read as empty")
|
||||||
|
require(audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 0}), "zero-duration payload must read as empty")
|
||||||
|
|
||||||
|
// A real book always carries at least one of the audio facts.
|
||||||
|
require(!audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 302.6}),
|
||||||
|
"duration-only payload (single-track book) is not empty")
|
||||||
|
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
|
||||||
|
Tracks: []AudioTrack{{Index: 0, Path: "01.mp3"}},
|
||||||
|
}), "track manifest is not empty")
|
||||||
|
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
|
||||||
|
Chapters: []AudioChapter{{Index: 0, Title: "Chapter 1"}},
|
||||||
|
}), "chapters are not empty")
|
||||||
|
}
|
||||||
@@ -1015,6 +1015,16 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
|||||||
// rescan saw the track set change (or the admin forced a rescan). Only
|
// rescan saw the track set change (or the admin forced a rescan). Only
|
||||||
// scan-derived fields move — editorial columns keep admin edits.
|
// scan-derived fields move — editorial columns keep admin edits.
|
||||||
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
|
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
|
||||||
|
// Write-after-success discipline (2026-10-21 incident: multi-file
|
||||||
|
// audiobooks surfaced as 1 second long because a re-extraction wrote
|
||||||
|
// a NULL/empty audio_metadata over a populated one). An empty payload
|
||||||
|
// is a FAILED extraction, not a book with no audio: when the row
|
||||||
|
// already carries audio data, keep it and skip the write — the next
|
||||||
|
// scan retries, and the reader keeps its manifest in the meantime.
|
||||||
|
if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil {
|
||||||
|
fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
|
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
||||||
@@ -1028,6 +1038,14 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// audioPayloadIsEmpty reports whether a payload carries no audio facts
|
||||||
|
// at all — no tracks, no chapters, no duration. Such a payload is the
|
||||||
|
// signature of a failed/absent extraction, never of a real book.
|
||||||
|
func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool {
|
||||||
|
return payload == nil ||
|
||||||
|
(len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0)
|
||||||
|
}
|
||||||
|
|
||||||
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
|
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
|
||||||
// (faceted plan Phase 6): a size-stable multi-file book is changed by
|
// (faceted plan Phase 6): a size-stable multi-file book is changed by
|
||||||
// EDITING its sidecars — the track-count/size fingerprint never saw it,
|
// EDITING its sidecars — the track-count/size fingerprint never saw it,
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,8 @@
|
|||||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
|
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
|
||||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
|
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
|
||||||
"build": "npm run build:ts && npm run build:css:prod",
|
"build": "npm run build:ts && npm run build:css:prod",
|
||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css",
|
||||||
|
"test": "vp test web/src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
|
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"@types/markdown-it": "^14.2.0",
|
"@types/markdown-it": "^14.2.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
|
"jsdom": "^30.1.3",
|
||||||
"playwright": "^1.64.1",
|
"playwright": "^1.64.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
+306
-19
@@ -10,14 +10,26 @@ package templates
|
|||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ AudioReader(user User, metadata ReaderMetadata) {
|
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html/>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
|
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
<script type="module" src="/static/main.js"></script>
|
<script type="module" src="/static/main.js"></script>
|
||||||
|
<style>
|
||||||
|
/* Layered scrubber (plan §1): the native range stays for
|
||||||
|
a11y (focus, arrow keys, aria) but its track is
|
||||||
|
transparent so the tick/buffer/fill layers beneath show
|
||||||
|
through. Static page CSS — templ owns the look. */
|
||||||
|
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
|
||||||
|
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
|
||||||
|
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
|
||||||
|
#audio-scrub::-moz-range-track { background: transparent; border: none; }
|
||||||
|
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
x-data="audioReader"
|
x-data="audioReader"
|
||||||
@@ -32,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||||
>
|
>
|
||||||
|
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||||
|
black across a countdown's final 30s; pointer-events-none
|
||||||
|
keeps the page usable, stop/finish resets it. -->
|
||||||
|
<div class="fixed inset-0 pointer-events-none z-40"
|
||||||
|
:style="'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'"
|
||||||
|
></div>
|
||||||
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
|
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
|
||||||
<!-- Header: back to the detail page (reader-style exit) and the
|
<!-- Header: back to the detail page (reader-style exit) and the
|
||||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||||
@@ -75,6 +93,86 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<path d="m6 9 6 6 6-6"></path>
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="helpOpen = !helpOpen"
|
||||||
|
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||||
|
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||||||
|
aria-label="Shortcuts & interactions"
|
||||||
|
title="Shortcuts & interactions (?)"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||||
|
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="relative" @click.outside="settingsOpen = false">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="settingsOpen = !settingsOpen"
|
||||||
|
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||||
|
:class="settingsOpen ? 'opacity-100' : 'opacity-90'"
|
||||||
|
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||||||
|
aria-label="Player settings"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
x-show="settingsOpen"
|
||||||
|
x-cloak
|
||||||
|
x-transition
|
||||||
|
class="absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50"
|
||||||
|
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
|
<span class="text-sm font-semibold" style="color: var(--text-primary);">Player settings</span>
|
||||||
|
<button type="button" @click="settingsOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close settings">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Option rows: autoplay first, panel structured for more -->
|
||||||
|
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
|
||||||
|
<span class="text-sm" style="color: var(--text-secondary);">Autoplay on open</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="autoplayOn ? 'true' : 'false'"
|
||||||
|
@click="toggleAutoplay()"
|
||||||
|
class="relative h-5 w-9 rounded-full transition-colors shrink-0"
|
||||||
|
:style="autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'"
|
||||||
|
aria-label="Autoplay on open"
|
||||||
|
>
|
||||||
|
<span class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- §12: default speed — always visible (unlike the
|
||||||
|
Speed ▾ footer, which hides when the current rate
|
||||||
|
IS the default), same setDefaultSpeed PUT -->
|
||||||
|
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
|
||||||
|
<span class="text-sm" style="color: var(--text-secondary);">Default playback speed</span>
|
||||||
|
<select
|
||||||
|
class="text-xs rounded-md px-1.5 py-1"
|
||||||
|
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||||||
|
aria-label="Default playback speed"
|
||||||
|
@change="setDefaultSpeed(Number($event.target.value))"
|
||||||
|
>
|
||||||
|
for _, rate := range SpeedOptions {
|
||||||
|
<option
|
||||||
|
value={ fmt.Sprintf("%g", rate) }
|
||||||
|
:selected={ fmt.Sprintf("Number(defaultSpeed) === %g", rate) }
|
||||||
|
>{ fmt.Sprintf("%g×", rate) }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,21 +199,96 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<!-- Scrubber + transports -->
|
<!-- Scrubber + transports -->
|
||||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
<div class="w-full max-w-xl flex flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
|
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
|
||||||
<span x-text="fmtTime(currentTime)">0:00</span>
|
<span class="inline-block min-w-[4.75rem]" x-text="fmtTime(scrubbing ? scrubValue : currentTime)">0:00</span>
|
||||||
<input
|
<div
|
||||||
id="audio-scrub"
|
id="scrub-area"
|
||||||
type="range"
|
class="relative flex-1 h-6 flex items-center cursor-pointer"
|
||||||
min="0"
|
style="touch-action: none;"
|
||||||
step="0.1"
|
@pointerdown="scrubPointerDown($event)"
|
||||||
:max="chaptersDuration() || 1"
|
@pointermove="scrubPointerMove($event)"
|
||||||
:value="scrubbing ? scrubValue : currentTime"
|
@pointerup="scrubPointerUp($event)"
|
||||||
class="flex-1 accent-current"
|
@pointercancel="scrubPointerUp($event)"
|
||||||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
@pointerleave="scrubPointerLeave()"
|
||||||
@pointerup="seekTo(scrubValue); scrubbing = false"
|
@wheel.prevent="onScrubWheel($event)"
|
||||||
@change="seekTo(scrubValue); scrubbing = false"
|
>
|
||||||
style="z-index: 20;"
|
<!-- Base track -->
|
||||||
/>
|
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" style="background-color: var(--surface-hover);"></div>
|
||||||
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
<!-- Elapsed fill -->
|
||||||
|
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'"></div>
|
||||||
|
<!-- Buffered span: the ACTIVE track's element buffer only -->
|
||||||
|
<template x-if="bufferSpan()">
|
||||||
|
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'"></div>
|
||||||
|
</template>
|
||||||
|
<!-- Chapter ticks: density-decimated, current chapter tinted -->
|
||||||
|
<div id="scrub-ticks" class="absolute inset-0 pointer-events-none">
|
||||||
|
<template x-for="idx in tickIndices" :key="'tick' + idx">
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full"
|
||||||
|
:class="currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'"
|
||||||
|
:style="'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- §9 magnet: the line a click would land on (hover
|
||||||
|
inside the pixel radius locks the tooltip here) -->
|
||||||
|
<div
|
||||||
|
x-show="hoverSnapped && !scrubbing"
|
||||||
|
x-cloak
|
||||||
|
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none"
|
||||||
|
:style="'left:' + hoverPct + '%;background-color:var(--accent);'"
|
||||||
|
></div>
|
||||||
|
<input
|
||||||
|
id="audio-scrub"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
:max="chaptersDuration() || 1"
|
||||||
|
:value="scrubbing ? scrubValue : currentTime"
|
||||||
|
class="relative w-full"
|
||||||
|
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||||||
|
@change="seekTo(scrubValue); scrubbing = false"
|
||||||
|
aria-label="Seek position"
|
||||||
|
/>
|
||||||
|
<!-- Hover tooltip: time + chapter under the pointer -->
|
||||||
|
<div
|
||||||
|
x-show="hoverSeconds !== null && !scrubbing"
|
||||||
|
x-cloak
|
||||||
|
class="absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none"
|
||||||
|
:style="'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'"
|
||||||
|
>
|
||||||
|
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
|
||||||
|
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Precision rail (§9): Shift+drag on mouse, or a touch
|
||||||
|
drag pulled off the bar — ±5 min or tighter, free
|
||||||
|
mapping inside. Edge labels self-document the window
|
||||||
|
(floats over the transports, page never shifts) -->
|
||||||
|
<div
|
||||||
|
x-show="railVisible"
|
||||||
|
x-cloak
|
||||||
|
class="absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none"
|
||||||
|
:style="'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')"
|
||||||
|
>
|
||||||
|
<div class="relative h-full">
|
||||||
|
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
|
||||||
|
<template x-for="tick in railTickList" :key="'rtick' + tick.start">
|
||||||
|
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
|
||||||
|
</template>
|
||||||
|
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
|
||||||
|
<div class="absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-primary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
|
||||||
|
<div class="absolute left-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railFrom)"></div>
|
||||||
|
<div class="absolute right-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railTo)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-block min-w-[4.75rem] text-right cursor-pointer"
|
||||||
|
x-text="endTimeText()"
|
||||||
|
:aria-label="endTimeAria()"
|
||||||
|
:title="endTimeAria()"
|
||||||
|
@click="cycleEndTime()"
|
||||||
|
>0:00</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center gap-3 md:gap-5">
|
<div class="flex items-center justify-center gap-3 md:gap-5">
|
||||||
<button
|
<button
|
||||||
@@ -219,7 +392,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
|
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
|
||||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
||||||
>
|
>
|
||||||
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
|
<template x-for="secs in skipChoices" :key="'skip' + secs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="setSkip(secs); skipOpen = false"
|
@click="setSkip(secs); skipOpen = false"
|
||||||
@@ -249,16 +422,26 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
|
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
|
||||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
||||||
>
|
>
|
||||||
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate">
|
<template x-for="rate in speeds" :key="'speed' + rate">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="setSpeed(rate); speedOpen = false"
|
@click="setSpeed(rate); speedOpen = false"
|
||||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||||
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
||||||
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||||
x-text="rate + '×'"
|
x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
|
||||||
></button>
|
></button>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- §8: pin the current rate as the user default —
|
||||||
|
books without their own speed pick it up -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-show="!isDefaultSpeed(speed)"
|
||||||
|
@click="setDefaultSpeed(speed); speedOpen = false"
|
||||||
|
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
|
||||||
|
style="border-color: var(--border); color: var(--text-secondary);"
|
||||||
|
x-text="'Make ' + speed + '× my default'"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -275,6 +458,55 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Bookmark</span>
|
<span>Bookmark</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="relative" @click.outside="sleepOpen = false">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="sleepOpen = !sleepOpen"
|
||||||
|
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
|
||||||
|
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
x-show="sleepOpen"
|
||||||
|
x-cloak
|
||||||
|
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[8.5rem]"
|
||||||
|
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
||||||
|
>
|
||||||
|
<template x-for="opt in sleepChoices" :key="opt.key">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="chooseSleep(opt); sleepOpen = false"
|
||||||
|
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||||
|
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
|
||||||
|
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||||
|
x-text="opt.label"
|
||||||
|
></button>
|
||||||
|
</template>
|
||||||
|
<!-- §17 clock mode: stop at a wall-clock time (its next
|
||||||
|
occurrence). The input drives chooseSleepClock(). -->
|
||||||
|
<div class="flex items-center gap-1.5 px-2 pt-1.5 mt-1 border-t" style="border-color: var(--border);">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
x-model="sleepClockTime"
|
||||||
|
class="text-xs px-1.5 py-1 rounded-md flex-1 min-w-0"
|
||||||
|
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||||||
|
aria-label="Stop at time"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="chooseSleepClock(); sleepOpen = false"
|
||||||
|
class="text-xs px-2.5 py-1 rounded-md font-semibold"
|
||||||
|
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
|
||||||
|
>Arm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="relative" @click.outside="volumeOpen = false">
|
<div class="relative" @click.outside="volumeOpen = false">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -453,10 +685,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
>
|
>
|
||||||
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
|
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="bookmark-note"
|
||||||
x-model="bookmarkNote"
|
x-model="bookmarkNote"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
|
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
|
||||||
class="input mb-2 resize-y"
|
class="input mb-2 resize-y"
|
||||||
|
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
|
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
|
||||||
@@ -464,6 +698,59 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
|
||||||
|
<div
|
||||||
|
x-show="osdVisible"
|
||||||
|
x-cloak
|
||||||
|
x-transition.opacity.duration.200ms
|
||||||
|
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||||
|
x-text="osdText"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Shortcut help (?): rows come from the shared KEYMAP
|
||||||
|
(audio-keys.ts) — the same table drives the keydown handler. -->
|
||||||
|
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
|
||||||
|
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
|
||||||
|
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
|
||||||
|
<div class="flex items-center justify-between mb-3 px-1">
|
||||||
|
<span class="text-sm font-semibold" style="color: var(--text-primary);">Shortcuts & interactions</span>
|
||||||
|
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="spec in visibleKeymap()" :key="spec.action">
|
||||||
|
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
|
||||||
|
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
|
||||||
|
<span class="flex items-center gap-1 shrink-0">
|
||||||
|
<template x-for="k in spec.keys" :key="k.label">
|
||||||
|
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Pointer gestures (audio-keys.ts MOUSE_GUIDE/TOUCH_GUIDE):
|
||||||
|
two sections — the bar has two scrub systems -->
|
||||||
|
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse</div>
|
||||||
|
<template x-for="row in mouseGuide" :key="'m' + row.label">
|
||||||
|
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
|
||||||
|
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
|
||||||
|
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Touch</div>
|
||||||
|
<template x-for="row in touchGuide" :key="'t' + row.label">
|
||||||
|
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
|
||||||
|
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
|
||||||
|
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html/><html lang=\"en\"><head><title>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 27, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -93,7 +93,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 28, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -106,7 +106,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 29, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -119,7 +119,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 30, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -134,67 +134,116 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 33, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if metadata.CoverImagePath != "" {
|
for _, rate := range SpeedOptions {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 86, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 87, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if metadata.CoverImagePath != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 96, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,32 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
|
||||||
|
// already emits that import into the generated file, and a second copy
|
||||||
|
// is a compile error (redeclared block).
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
|
||||||
|
// token (whitespace, quotes, parens, backslash). The stored cover paths
|
||||||
|
// are already percent-encoded URL paths, so this is defensive only — but
|
||||||
|
// it must exist: templ's codegen HTML-escapes style attribute values a
|
||||||
|
// second time, so ANY quote character (SafeCSS included) reaches the DOM
|
||||||
|
// as literal ' text and the browser never parses the url() at all
|
||||||
|
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
|
||||||
|
// Firefox resolved the mangled token relative to the page URL).
|
||||||
|
func cssURL(s string) string {
|
||||||
|
return strings.NewReplacer(
|
||||||
|
" ", "%20",
|
||||||
|
"(", "%28",
|
||||||
|
")", "%29",
|
||||||
|
"'", "%27",
|
||||||
|
"\"", "%22",
|
||||||
|
"\\", "%5C",
|
||||||
|
).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
|
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -30,10 +52,27 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
||||||
>
|
>
|
||||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||||
|
if book.ActiveConflict != nil {
|
||||||
|
<!-- Sync-conflict banner (user request): the progress
|
||||||
|
card's warning sits far down a long page — this
|
||||||
|
strip is unmissable at the very top, and clicking
|
||||||
|
it opens the same resolution modal. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showProgressSyncModal()"
|
||||||
|
class="w-full flex items-center gap-3 px-4 sm:px-6 lg:px-8 py-3 text-left"
|
||||||
|
style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border-bottom: 1px solid var(--status-warning);"
|
||||||
|
>
|
||||||
|
@Icon("alert", "h-5 w-5 shrink-0")
|
||||||
|
<span class="text-sm" style="color: var(--text-primary);">
|
||||||
|
Progress sync conflict detected on this item — click to review and resolve.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<!-- Hero with blurred cover backdrop -->
|
<!-- Hero with blurred cover backdrop -->
|
||||||
<div class="relative overflow-hidden">
|
<div class="relative overflow-hidden">
|
||||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||||
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
|
<div class="hero-backdrop" style={ templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")") }></div>
|
||||||
}
|
}
|
||||||
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
|
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
|
||||||
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
|
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
|
||||||
|
|||||||
+500
-464
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,15 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
|
||||||
|
func effectiveAudioSpeed(user User) float64 {
|
||||||
|
if user.DefaultAudioSpeed == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return user.DefaultAudioSpeed
|
||||||
|
}
|
||||||
|
|
||||||
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
|
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
|
||||||
<form
|
<form
|
||||||
hx-put={ actionURL }
|
hx-put={ actionURL }
|
||||||
@@ -66,6 +76,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
|||||||
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
|
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Default Playback Speed</label>
|
||||||
|
<select
|
||||||
|
name="default_audio_speed"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
for _, rate := range SpeedOptions {
|
||||||
|
<option value={ fmt.Sprintf("%g", rate) } selected?={ effectiveAudioSpeed(user) == rate }>{ fmt.Sprintf("%g", rate) }×</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
||||||
<select
|
<select
|
||||||
@@ -125,7 +146,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
|||||||
type="password"
|
type="password"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
class="input"
|
class="input"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+153
-97
@@ -8,6 +8,16 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
|
||||||
|
func effectiveAudioSpeed(user User) float64 {
|
||||||
|
if user.DefaultAudioSpeed == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return user.DefaultAudioSpeed
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) templ.Component {
|
func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
@@ -36,7 +46,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 15, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -57,7 +67,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 21, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -70,7 +80,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -83,7 +93,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 39, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -96,7 +106,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 59, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -172,277 +182,323 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Default Playback Speed</label> <select name=\"default_audio_speed\" class=\"input\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, rate := range SpeedOptions {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 46}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if effectiveAudioSpeed(user) == rate {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 122}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "×</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "UTC" {
|
if user.Timezone == "UTC" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if user.Timezone == "Pacific/Honolulu" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if user.Timezone == "America/Anchorage" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if user.Timezone == "America/Los_Angeles" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Denver" {
|
if user.Timezone == "Pacific/Honolulu" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Phoenix" {
|
if user.Timezone == "America/Anchorage" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Chicago" {
|
if user.Timezone == "America/Los_Angeles" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/New_York" {
|
if user.Timezone == "America/Denver" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Sao_Paulo" {
|
if user.Timezone == "America/Phoenix" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/London" {
|
if user.Timezone == "America/Chicago" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Paris" {
|
if user.Timezone == "America/New_York" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Helsinki" {
|
if user.Timezone == "America/Sao_Paulo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Moscow" {
|
if user.Timezone == "Europe/London" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Tehran" {
|
if user.Timezone == "Europe/Paris" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Dubai" {
|
if user.Timezone == "Europe/Helsinki" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Karachi" {
|
if user.Timezone == "Europe/Moscow" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Kolkata" {
|
if user.Timezone == "Asia/Tehran" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Dhaka" {
|
if user.Timezone == "Asia/Dubai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Bangkok" {
|
if user.Timezone == "Asia/Karachi" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Shanghai" {
|
if user.Timezone == "Asia/Kolkata" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Tokyo" {
|
if user.Timezone == "Asia/Dhaka" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Australia/Darwin" {
|
if user.Timezone == "Asia/Bangkok" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Australia/Sydney" {
|
if user.Timezone == "Asia/Shanghai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Pacific/Auckland" {
|
if user.Timezone == "Asia/Tokyo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">New Zealand (UTC+12/+13)</option></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if user.Timezone == "Australia/Darwin" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if user.Timezone == "Australia/Sydney" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if user.Timezone == "Pacific/Auckland" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">New Zealand (UTC+12/+13)</option></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if showRoleField {
|
if showRoleField {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "user" {
|
if user.Role == "user" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">User</option> <option value=\"admin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">User</option> <option value=\"admin\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Admin</option></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin</option></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -450,12 +506,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if requireCurrentPassword {
|
if requireCurrentPassword {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -463,25 +519,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 191, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 211, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -489,22 +545,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if showCancelButton {
|
if showCancelButton {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -512,7 +568,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
step="any"
|
step="any"
|
||||||
list="tick-marks"
|
list="tick-marks"
|
||||||
@input="goToFraction($event.target.value)"
|
@input="goToFraction($event.target.value)"
|
||||||
|
@pointerup="$el.blur()"
|
||||||
class="grow"
|
class="grow"
|
||||||
/>
|
/>
|
||||||
<datalist id="tick-marks"></datalist>
|
<datalist id="tick-marks"></datalist>
|
||||||
@@ -392,6 +393,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
max="1"
|
max="1"
|
||||||
step="any"
|
step="any"
|
||||||
@input="goToFraction($event.target.value)"
|
@input="goToFraction($event.target.value)"
|
||||||
|
@pointerup="$el.blur()"
|
||||||
class="grow min-w-[3rem]"
|
class="grow min-w-[3rem]"
|
||||||
/>
|
/>
|
||||||
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
|
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
|
||||||
@@ -482,6 +484,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
max="1"
|
max="1"
|
||||||
step="any"
|
step="any"
|
||||||
@input="goToFraction($event.target.value)"
|
@input="goToFraction($event.target.value)"
|
||||||
|
@pointerup="$el.blur()"
|
||||||
class="grow min-w-[3rem]"
|
class="grow min-w-[3rem]"
|
||||||
/>
|
/>
|
||||||
<button @click="goRight()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
|
<button @click="goRight()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\Line truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-iLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -230,7 +230,7 @@ func drawerHeader(title string) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 580, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 583, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ type User struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Token string
|
Token string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
// User-level default audiobook playback speed (plan §8); 0 means
|
||||||
|
// "unset" and renders as the 1× option.
|
||||||
|
DefaultAudioSpeed float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SpeedOption is one entry of the default-playback-speed select — the
|
||||||
|
// server-side mirror of the web player's SPEEDS list (plan §5/§8).
|
||||||
|
var SpeedOptions = []float64{0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3}
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
User User
|
User User
|
||||||
CurrentPage string
|
CurrentPage string
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const pdfjsAssets = () => ({
|
|||||||
// so hashed chunks from previous builds would otherwise accumulate
|
// so hashed chunks from previous builds would otherwise accumulate
|
||||||
// forever and leak into Docker images via the build context. Remove
|
// forever and leak into Docker images via the build context. Remove
|
||||||
// any *-<hash>.js/.css/.mjs(.map) that this build did not produce.
|
// any *-<hash>.js/.css/.mjs(.map) that this build did not produce.
|
||||||
|
//
|
||||||
|
// The produced.size === 0 guard is load-bearing: this config also drives
|
||||||
|
// `vp test`, and vitest runs closeBundle with generateBundle never having
|
||||||
|
// fired — an empty produced set means "not a build run", and sweeping
|
||||||
|
// then deleted every live chunk (the 2026-10-21 incident where
|
||||||
|
// `npm run test` gutted web/static down to the three entry files and
|
||||||
|
// every page 404'd its imports).
|
||||||
const cleanStaleChunks = () => {
|
const cleanStaleChunks = () => {
|
||||||
const produced = new Set<string>();
|
const produced = new Set<string>();
|
||||||
return {
|
return {
|
||||||
@@ -27,6 +34,7 @@ const cleanStaleChunks = () => {
|
|||||||
for (const fileName of Object.keys(bundle)) produced.add(fileName);
|
for (const fileName of Object.keys(bundle)) produced.add(fileName);
|
||||||
},
|
},
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
|
if (produced.size === 0) return;
|
||||||
const outDir = "web/static";
|
const outDir = "web/static";
|
||||||
const chunkRe = /-[A-Za-z0-9_-]{8}\.(js|css|mjs)(\.map)?$/;
|
const chunkRe = /-[A-Za-z0-9_-]{8}\.(js|css|mjs)(\.map)?$/;
|
||||||
for (const f of readdirSync(outDir)) {
|
for (const f of readdirSync(outDir)) {
|
||||||
@@ -44,6 +52,10 @@ export default defineConfig({
|
|||||||
fmt: {
|
fmt: {
|
||||||
ignorePatterns: ["web/static/**"],
|
ignorePatterns: ["web/static/**"],
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["web/src/**/*.test.ts"],
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": "/web/src",
|
"@": "/web/src",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { getReadingProgress, handleResponse, handleVoidResponse } from "./api";
|
||||||
|
|
||||||
|
// api.ts registers an Alpine component at import time; the toast import it
|
||||||
|
// carries installs a fetch interceptor. Neither interferes with the stubbed
|
||||||
|
// global fetch below, but both are exercised by importing the module.
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleResponse", () => {
|
||||||
|
it("parses ok JSON bodies", async () => {
|
||||||
|
await expect(handleResponse(jsonResponse({ ok: true }))).resolves.toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws the server's error message", async () => {
|
||||||
|
await expect(handleResponse(jsonResponse({ error: "Nope" }, 404))).rejects.toThrow("Nope");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the HTTP status when the body has no message", async () => {
|
||||||
|
await expect(handleResponse(jsonResponse({ error: "" }, 500))).rejects.toThrow("HTTP 500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Unknown error when the error body is not JSON", async () => {
|
||||||
|
await expect(handleResponse(new Response("<html>502</html>", { status: 502 }))).rejects.toThrow(
|
||||||
|
"Unknown error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleVoidResponse", () => {
|
||||||
|
it("resolves on ok regardless of body", async () => {
|
||||||
|
await expect(
|
||||||
|
handleVoidResponse(new Response("ignored", { status: 200 })),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws the server's error message", async () => {
|
||||||
|
await expect(handleVoidResponse(jsonResponse({ error: "Denied" }, 403))).rejects.toThrow(
|
||||||
|
"Denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getReadingProgress", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on 404 (no progress yet)", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => jsonResponse({ error: "none" }, 404)),
|
||||||
|
);
|
||||||
|
await expect(getReadingProgress("m1")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the progress payload otherwise", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
jsonResponse({ current_page: 3, total_pages: 10, last_read_at: "2026-01-01T00:00:00Z" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const progress = await getReadingProgress("m1");
|
||||||
|
expect(progress).toMatchObject({ current_page: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the auth header when a token is stored", async () => {
|
||||||
|
localStorage.setItem("token", "tok-9");
|
||||||
|
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||||
|
jsonResponse({ error: "none" }, 404),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await getReadingProgress("m1");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: "Bearer tok-9" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { KEYMAP, resolveShortcut, visibleKeymap } from "./audio-keys";
|
||||||
|
|
||||||
|
describe("resolveShortcut", () => {
|
||||||
|
it("resolves every in-page binding", () => {
|
||||||
|
expect(resolveShortcut(" ", false)).toBe("toggle-play");
|
||||||
|
expect(resolveShortcut("Enter", false)).toBe("toggle-play");
|
||||||
|
expect(resolveShortcut("h", false)).toBe("skip-back");
|
||||||
|
expect(resolveShortcut("H", false)).toBe("skip-back");
|
||||||
|
expect(resolveShortcut("ArrowLeft", false)).toBe("skip-back");
|
||||||
|
expect(resolveShortcut("l", false)).toBe("skip-forward");
|
||||||
|
expect(resolveShortcut("ArrowRight", false)).toBe("skip-forward");
|
||||||
|
expect(resolveShortcut("k", false)).toBe("prev-chapter");
|
||||||
|
expect(resolveShortcut("PageUp", false)).toBe("prev-chapter");
|
||||||
|
expect(resolveShortcut("j", false)).toBe("next-chapter");
|
||||||
|
expect(resolveShortcut("PageDown", false)).toBe("next-chapter");
|
||||||
|
expect(resolveShortcut("b", false)).toBe("bookmark");
|
||||||
|
expect(resolveShortcut("ArrowUp", false)).toBe("volume-up");
|
||||||
|
expect(resolveShortcut("ArrowDown", false)).toBe("volume-down");
|
||||||
|
expect(resolveShortcut("m", false)).toBe("toggle-mute");
|
||||||
|
expect(resolveShortcut("]", false)).toBe("cycle-skip-up");
|
||||||
|
expect(resolveShortcut("[", false)).toBe("cycle-skip-down");
|
||||||
|
expect(resolveShortcut("?", false)).toBe("toggle-help");
|
||||||
|
expect(resolveShortcut("Escape", false)).toBe("escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Alt bindings distinct from their plain counterparts", () => {
|
||||||
|
expect(resolveShortcut("l", true)).toBe("speed-up");
|
||||||
|
expect(resolveShortcut("l", false)).toBe("skip-forward");
|
||||||
|
expect(resolveShortcut("h", true)).toBe("speed-down");
|
||||||
|
expect(resolveShortcut("ArrowRight", true)).toBe("speed-up");
|
||||||
|
expect(resolveShortcut("ArrowLeft", true)).toBe("speed-down");
|
||||||
|
// Alt on an unbound key claims nothing.
|
||||||
|
expect(resolveShortcut("ArrowUp", true)).toBeNull();
|
||||||
|
expect(resolveShortcut("m", true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves in-page media key events", () => {
|
||||||
|
expect(resolveShortcut("MediaPlayPause", false)).toBe("media-toggle-play");
|
||||||
|
expect(resolveShortcut("MediaPlay", false)).toBe("media-play");
|
||||||
|
expect(resolveShortcut("MediaPause", false)).toBe("media-pause");
|
||||||
|
expect(resolveShortcut("MediaStop", false)).toBe("media-pause");
|
||||||
|
expect(resolveShortcut("MediaTrackNext", false)).toBe("media-next");
|
||||||
|
expect(resolveShortcut("MediaTrackPrevious", false)).toBe("media-prev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown keys", () => {
|
||||||
|
expect(resolveShortcut("x", false)).toBeNull();
|
||||||
|
expect(resolveShortcut("F5", false)).toBeNull();
|
||||||
|
expect(resolveShortcut("", false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KEYMAP integrity", () => {
|
||||||
|
it("has exactly one spec per action", () => {
|
||||||
|
const actions = KEYMAP.map((spec) => spec.action);
|
||||||
|
expect(new Set(actions).size).toBe(actions.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no conflicting (key, alt) combos across actions", () => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const spec of KEYMAP) {
|
||||||
|
for (const k of spec.keys) {
|
||||||
|
const combo = `${k.alt ? "Alt+" : ""}${k.key}`;
|
||||||
|
const owner = seen.get(combo);
|
||||||
|
expect(owner, `${combo} bound by both ${owner} and ${spec.action}`).toBeUndefined();
|
||||||
|
seen.set(combo, spec.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every spec at least one key, a label and a description", () => {
|
||||||
|
for (const spec of KEYMAP) {
|
||||||
|
expect(spec.keys.length, spec.action).toBeGreaterThan(0);
|
||||||
|
expect(spec.label.length, spec.action).toBeGreaterThan(0);
|
||||||
|
expect(spec.description.length, spec.action).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides only media-key rows from the dialog", () => {
|
||||||
|
const visible = new Set(visibleKeymap().map((spec) => spec.action));
|
||||||
|
for (const spec of KEYMAP) {
|
||||||
|
if (spec.action.startsWith("media-")) {
|
||||||
|
expect(visible.has(spec.action), spec.action).toBe(false);
|
||||||
|
} else {
|
||||||
|
expect(visible.has(spec.action), spec.action).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Declarative keyboard map for the audio player (player plan §2).
|
||||||
|
// ONE table drives BOTH the document keydown dispatcher (audio.ts
|
||||||
|
// resolves events through resolveShortcut) and the templ-rendered
|
||||||
|
// shortcut dialog (x-for over visibleKeymap). Never hardcode a key
|
||||||
|
// binding in the handler or a row in the dialog — add it here.
|
||||||
|
//
|
||||||
|
// Hardware media keys are a separate layer: they ride the existing
|
||||||
|
// MediaSession action handlers even when the tab is unfocused. The
|
||||||
|
// Media* entries below cover browsers that ALSO deliver them as
|
||||||
|
// in-page keydown events; they resolve to the same methods the
|
||||||
|
// MediaSession handlers call (togglePlay / stepChapter).
|
||||||
|
|
||||||
|
export type KeyAction =
|
||||||
|
| "toggle-play"
|
||||||
|
| "skip-back"
|
||||||
|
| "skip-forward"
|
||||||
|
| "prev-chapter"
|
||||||
|
| "next-chapter"
|
||||||
|
| "bookmark"
|
||||||
|
| "speed-up"
|
||||||
|
| "speed-down"
|
||||||
|
| "volume-up"
|
||||||
|
| "volume-down"
|
||||||
|
| "toggle-mute"
|
||||||
|
| "cycle-skip-up"
|
||||||
|
| "cycle-skip-down"
|
||||||
|
| "toggle-help"
|
||||||
|
| "escape"
|
||||||
|
| "media-toggle-play"
|
||||||
|
| "media-play"
|
||||||
|
| "media-pause"
|
||||||
|
| "media-next"
|
||||||
|
| "media-prev";
|
||||||
|
|
||||||
|
export interface KeySpec {
|
||||||
|
// e.key value to match. Single characters are matched
|
||||||
|
// case-insensitively by resolveShortcut; multi-character key names
|
||||||
|
// ("Enter", "ArrowLeft", "MediaPlayPause") must match verbatim.
|
||||||
|
key: string;
|
||||||
|
// Requires the Alt modifier. Alt-qualified entries disambiguate the
|
||||||
|
// plain binding (Alt+L = speed up while L = skip forward).
|
||||||
|
alt?: boolean;
|
||||||
|
// Dialog badge label, e.g. "Space", "←", "Alt + →".
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyActionSpec {
|
||||||
|
action: KeyAction;
|
||||||
|
// Dialog row label.
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
keys: readonly KeySpec[];
|
||||||
|
// Media-key entries are resolved but not shown in the dialog —
|
||||||
|
// hardware keys, not something a keyboard user types.
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEYMAP: readonly KeyActionSpec[] = [
|
||||||
|
{
|
||||||
|
action: "toggle-play",
|
||||||
|
label: "Play / pause",
|
||||||
|
description: "Play or pause (only when no control is focused)",
|
||||||
|
keys: [
|
||||||
|
{ key: " ", label: "Space" },
|
||||||
|
{ key: "Enter", label: "Enter" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "skip-back",
|
||||||
|
label: "Skip back",
|
||||||
|
description: "Skip back by the skip duration",
|
||||||
|
keys: [
|
||||||
|
{ key: "h", label: "H" },
|
||||||
|
{ key: "ArrowLeft", label: "←" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "skip-forward",
|
||||||
|
label: "Skip forward",
|
||||||
|
description: "Skip forward by the skip duration",
|
||||||
|
keys: [
|
||||||
|
{ key: "l", label: "L" },
|
||||||
|
{ key: "ArrowRight", label: "→" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "prev-chapter",
|
||||||
|
label: "Previous chapter",
|
||||||
|
description: "Previous chapter",
|
||||||
|
keys: [
|
||||||
|
{ key: "k", label: "K" },
|
||||||
|
{ key: "PageUp", label: "PgUp" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "next-chapter",
|
||||||
|
label: "Next chapter",
|
||||||
|
description: "Next chapter",
|
||||||
|
keys: [
|
||||||
|
{ key: "j", label: "J" },
|
||||||
|
{ key: "PageDown", label: "PgDn" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "bookmark",
|
||||||
|
label: "Bookmark",
|
||||||
|
description: "Bookmark this position — Enter saves, Shift+Enter newline",
|
||||||
|
keys: [{ key: "b", label: "B" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "speed-up",
|
||||||
|
label: "Speed up",
|
||||||
|
description: "Step playback speed up",
|
||||||
|
keys: [
|
||||||
|
{ key: "ArrowRight", alt: true, label: "Alt + →" },
|
||||||
|
{ key: "l", alt: true, label: "Alt + L" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "speed-down",
|
||||||
|
label: "Speed down",
|
||||||
|
description: "Step playback speed down",
|
||||||
|
keys: [
|
||||||
|
{ key: "ArrowLeft", alt: true, label: "Alt + ←" },
|
||||||
|
{ key: "h", alt: true, label: "Alt + H" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "volume-up",
|
||||||
|
label: "Volume up",
|
||||||
|
description: "Volume up",
|
||||||
|
keys: [{ key: "ArrowUp", label: "↑" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "volume-down",
|
||||||
|
label: "Volume down",
|
||||||
|
description: "Volume down",
|
||||||
|
keys: [{ key: "ArrowDown", label: "↓" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "toggle-mute",
|
||||||
|
label: "Mute",
|
||||||
|
description: "Mute or unmute",
|
||||||
|
keys: [{ key: "m", label: "M" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "cycle-skip-up",
|
||||||
|
label: "Longer skip",
|
||||||
|
description: "Cycle the skip duration up",
|
||||||
|
keys: [{ key: "]", label: "]" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "cycle-skip-down",
|
||||||
|
label: "Shorter skip",
|
||||||
|
description: "Cycle the skip duration down",
|
||||||
|
keys: [{ key: "[", label: "[" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "toggle-help",
|
||||||
|
label: "Shortcuts",
|
||||||
|
description: "Show or hide this dialog",
|
||||||
|
keys: [{ key: "?", label: "?" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "escape",
|
||||||
|
label: "Close",
|
||||||
|
description: "Close dialog, cancel bookmark, close panels",
|
||||||
|
keys: [{ key: "Escape", label: "Esc" }],
|
||||||
|
},
|
||||||
|
// Media keys: same actions the MediaSession handlers expose.
|
||||||
|
{
|
||||||
|
action: "media-toggle-play",
|
||||||
|
label: "Media play/pause key",
|
||||||
|
description: "Hardware media play/pause",
|
||||||
|
keys: [{ key: "MediaPlayPause", label: "Media ⏯" }],
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "media-play",
|
||||||
|
label: "Media play key",
|
||||||
|
description: "Hardware media play",
|
||||||
|
keys: [{ key: "MediaPlay", label: "Media ▶" }],
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "media-pause",
|
||||||
|
label: "Media pause/stop key",
|
||||||
|
description: "Hardware media pause or stop",
|
||||||
|
keys: [
|
||||||
|
{ key: "MediaPause", label: "Media ⏸" },
|
||||||
|
{ key: "MediaStop", label: "Media ⏹" },
|
||||||
|
],
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "media-next",
|
||||||
|
label: "Media next key",
|
||||||
|
description: "Hardware media next (chapter step, like the notification)",
|
||||||
|
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "media-prev",
|
||||||
|
label: "Media previous key",
|
||||||
|
description: "Hardware media previous (chapter step, like the notification)",
|
||||||
|
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// visibleKeymap filters out the hardware-media rows for the dialog.
|
||||||
|
export function visibleKeymap(): KeyActionSpec[] {
|
||||||
|
return KEYMAP.filter((spec) => !spec.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer/wheel/touch interactions (plan §9) are not key events — the
|
||||||
|
// dispatcher never sees them — but they belong in the same dialog so
|
||||||
|
// every scrub gesture is discoverable in one place. TWO sections,
|
||||||
|
// because the bar really does have two scrub systems (plan §12): the
|
||||||
|
// desktop-pointer family and the touch family. templ x-fors these
|
||||||
|
// through the component's `mouseGuide`/`touchGuide` properties (same
|
||||||
|
// binding pattern as speeds/skipChoices).
|
||||||
|
export interface PointerGuideRow {
|
||||||
|
// Badge label, e.g. "Shift + drag".
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOUSE_GUIDE: readonly PointerGuideRow[] = [
|
||||||
|
{ label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" },
|
||||||
|
{ label: "Drag", description: "Free scrub across the whole bar" },
|
||||||
|
{ label: "Shift + click", description: "Jump to the nearest chapter line" },
|
||||||
|
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
|
||||||
|
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
|
||||||
|
{
|
||||||
|
label: "End time",
|
||||||
|
description: "Click to cycle the −label: total → book left → chapter left",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOUCH_GUIDE: readonly PointerGuideRow[] = [
|
||||||
|
{ label: "Drag", description: "Free scrub across the whole bar" },
|
||||||
|
{
|
||||||
|
label: "Pull away",
|
||||||
|
description: "Drag off the bar while holding — the further you pull, the finer it gets",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
|
||||||
|
// when nothing claims it. Single characters match case-insensitively;
|
||||||
|
// Alt-qualified bindings take precedence order for free: a plain key
|
||||||
|
// never matches an alt binding and vice versa, so L and Alt+L are
|
||||||
|
// distinct by construction.
|
||||||
|
export function resolveShortcut(key: string, altKey: boolean): KeyAction | null {
|
||||||
|
const normalized = key.length === 1 ? key.toLowerCase() : key;
|
||||||
|
for (const spec of KEYMAP) {
|
||||||
|
for (const k of spec.keys) {
|
||||||
|
if (k.key === normalized && (k.alt ?? false) === altKey) return spec.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyDrag,
|
||||||
|
bufferedAheadEnd,
|
||||||
|
bufferedSpanPct,
|
||||||
|
chapterIndexFor,
|
||||||
|
denseTicks,
|
||||||
|
dragScale,
|
||||||
|
DRAG_REF_PX,
|
||||||
|
DRAG_SCALE_MIN,
|
||||||
|
nearestChapterLine,
|
||||||
|
railLiftPx,
|
||||||
|
railSecondsAtPointer,
|
||||||
|
railTicks,
|
||||||
|
railWindow,
|
||||||
|
RAIL_SPAN,
|
||||||
|
secondsAtPointer,
|
||||||
|
snapToChapterPx,
|
||||||
|
TOUCH_RAIL_LIFT_MAX_PX,
|
||||||
|
TOUCH_RAIL_MIN_DY_PX,
|
||||||
|
touchRailSpan,
|
||||||
|
} from "./audio-scrubber";
|
||||||
|
|
||||||
|
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
|
||||||
|
// single-file book (Gardens-shaped: 34 chapters).
|
||||||
|
const duration86h = 310_320;
|
||||||
|
const chapters1189 = Array.from({ length: 1189 }, (_, i) => ({ start: (i * duration86h) / 1189 }));
|
||||||
|
const duration26h = 93_807;
|
||||||
|
const chapters34 = Array.from({ length: 34 }, (_, i) => ({
|
||||||
|
start: (i * duration26h) / 34,
|
||||||
|
title: `Chapter ${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rect = (width: number): DOMRect =>
|
||||||
|
({
|
||||||
|
left: 100,
|
||||||
|
right: 100 + width,
|
||||||
|
width,
|
||||||
|
top: 0,
|
||||||
|
bottom: 10,
|
||||||
|
height: 10,
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as DOMRect;
|
||||||
|
|
||||||
|
describe("secondsAtPointer", () => {
|
||||||
|
it("maps the bar linearly and clamps outside it", () => {
|
||||||
|
const r = rect(800);
|
||||||
|
expect(secondsAtPointer(100, r, duration86h)).toBe(0);
|
||||||
|
expect(secondsAtPointer(500, r, duration86h)).toBeCloseTo(duration86h / 2, 5);
|
||||||
|
expect(secondsAtPointer(900, r, duration86h)).toBe(duration86h);
|
||||||
|
expect(secondsAtPointer(50, r, duration86h)).toBe(0);
|
||||||
|
expect(secondsAtPointer(1200, r, duration86h)).toBe(duration86h);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe with degenerate rects and durations", () => {
|
||||||
|
expect(secondsAtPointer(150, rect(0), duration86h)).toBe(0);
|
||||||
|
expect(secondsAtPointer(150, rect(800), 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterIndexFor", () => {
|
||||||
|
it("finds the containing chapter in both shapes", () => {
|
||||||
|
expect(chapterIndexFor(chapters1189, 0)).toBe(0);
|
||||||
|
expect(chapterIndexFor(chapters1189, chapters1189[500].start + 1)).toBe(500);
|
||||||
|
expect(chapterIndexFor(chapters34, chapters34[33].start + 60)).toBe(33);
|
||||||
|
expect(chapterIndexFor(chapters34, duration26h + 5000)).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 before the first chapter and for none at all", () => {
|
||||||
|
expect(chapterIndexFor(chapters1189, -1)).toBe(-1);
|
||||||
|
expect(chapterIndexFor([], 100)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nearestChapterLine / snapToChapterPx (§9 pixel magnetism)", () => {
|
||||||
|
// 34-chapter book on an 800px bar: ~23.5px between lines — the
|
||||||
|
// radius discriminates. (On the KJV-shaped bar the 1189 lines sit
|
||||||
|
// ~0.4px apart — magnetism lands on SOME line from anywhere, which
|
||||||
|
// is correct for a book that dense.)
|
||||||
|
const pxSec = 800 / duration26h; // bar px per second
|
||||||
|
|
||||||
|
it("reports the closest line and its distance in bar pixels", () => {
|
||||||
|
const start = chapters34[5].start;
|
||||||
|
const near = nearestChapterLine(start + 5 / pxSec, chapters34, duration26h, 800);
|
||||||
|
expect(near!.index).toBe(5);
|
||||||
|
expect(near!.seconds).toBe(start);
|
||||||
|
expect(near!.distancePx).toBeCloseTo(5, 5);
|
||||||
|
expect(nearestChapterLine(42, [], duration26h, 800)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds sub-pixel lines on a book as dense as the KJV", () => {
|
||||||
|
// 1px ≈ 633s — a seconds threshold could never fire here; a pixel
|
||||||
|
// radius always can. This is the §9 root-cause fix.
|
||||||
|
const dense = nearestChapterLine(duration86h / 2, chapters1189, duration86h, 489);
|
||||||
|
expect(dense).not.toBeNull();
|
||||||
|
expect(dense!.distancePx).toBeLessThan(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps within the pixel radius and not beyond it", () => {
|
||||||
|
const start = chapters34[5].start;
|
||||||
|
expect(snapToChapterPx(start + 9 / pxSec, chapters34, duration26h, 800)).toEqual({
|
||||||
|
seconds: start,
|
||||||
|
snapped: true,
|
||||||
|
});
|
||||||
|
expect(snapToChapterPx(start + 11 / pxSec, chapters34, duration26h, 800).snapped).toBe(false);
|
||||||
|
expect(snapToChapterPx(start - 10 / pxSec, chapters34, duration26h, 800)).toEqual({
|
||||||
|
seconds: start,
|
||||||
|
snapped: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force snaps to the nearest line regardless of radius (Shift+click)", () => {
|
||||||
|
const a = chapters34[5].start;
|
||||||
|
const b = chapters34[6].start;
|
||||||
|
const mid = (a + b) / 2;
|
||||||
|
// Radius 0 + force must land where a wide radius lands: distance
|
||||||
|
// is ignored, nearest wins (mid+1 → the later line).
|
||||||
|
expect(snapToChapterPx(mid + 1, chapters34, duration26h, 800, 0, true)).toEqual({
|
||||||
|
seconds: b,
|
||||||
|
snapped: true,
|
||||||
|
});
|
||||||
|
// Past the book's last line, the nearest line is still reachable.
|
||||||
|
const last = chapters34[33].start;
|
||||||
|
expect(snapToChapterPx(last + 1000 / pxSec, chapters34, duration26h, 800, 0, true)).toEqual({
|
||||||
|
seconds: last,
|
||||||
|
snapped: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the NEAREST line when two are in radius", () => {
|
||||||
|
const a = chapters34[5].start;
|
||||||
|
const b = chapters34[6].start;
|
||||||
|
const mid = (a + b) / 2;
|
||||||
|
// Both lines sit ~11.8px from the midpoint; inside a 13px radius
|
||||||
|
// the closer (later) one must win.
|
||||||
|
const result = snapToChapterPx(mid + 1, chapters34, duration26h, 800, 13);
|
||||||
|
expect(result.snapped).toBe(true);
|
||||||
|
expect(result.seconds).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the position alone with no chapters or a dead bar", () => {
|
||||||
|
expect(snapToChapterPx(42, [], duration26h, 800)).toEqual({ seconds: 42, snapped: false });
|
||||||
|
expect(snapToChapterPx(42, chapters34, duration26h, 0).snapped).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
|
||||||
|
it("is 1:1 at the bar and decays an order per reference distance", () => {
|
||||||
|
expect(dragScale(0)).toBe(1);
|
||||||
|
expect(dragScale(DRAG_REF_PX)).toBeCloseTo(0.1, 5);
|
||||||
|
expect(dragScale(2 * DRAG_REF_PX)).toBeCloseTo(0.01, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is symmetric in pull direction and monotonic in |dy|", () => {
|
||||||
|
expect(dragScale(-60)).toBe(dragScale(60));
|
||||||
|
expect(dragScale(30)).toBeGreaterThan(dragScale(60));
|
||||||
|
expect(dragScale(60)).toBeGreaterThan(dragScale(120));
|
||||||
|
expect(dragScale(10_000)).toBe(DRAG_SCALE_MIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degenerates to plain travel with a dead reference", () => {
|
||||||
|
expect(dragScale(500, 0)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances the cursor 1:1 at dy=0 (px → seconds at bar scale)", () => {
|
||||||
|
// 800px bar over 26h: 100px ≈ 11726s.
|
||||||
|
expect(applyDrag(0, 100, 0, 800 / duration26h, duration26h)).toBeCloseTo(
|
||||||
|
(100 / 800) * duration26h,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks travel by the current zoom (incremental, not absolute)", () => {
|
||||||
|
const pxPerSecond = 800 / duration26h;
|
||||||
|
const onePxSeconds = 1 / pxPerSecond;
|
||||||
|
// One reference-distance of pull = one order of magnitude finer.
|
||||||
|
expect(applyDrag(0, 100, DRAG_REF_PX, pxPerSecond, duration26h)).toBeCloseTo(
|
||||||
|
100 * onePxSeconds * 0.1,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
// Scale changes only affect SUBSEQUENT travel: the cursor never
|
||||||
|
// teleports when the finger pulls away mid-drag.
|
||||||
|
expect(applyDrag(5000, 0, 3 * DRAG_REF_PX, pxPerSecond, duration26h)).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to the book in both directions", () => {
|
||||||
|
const pxPerSecond = 800 / duration26h;
|
||||||
|
expect(applyDrag(duration26h, 100, 0, pxPerSecond, duration26h)).toBe(duration26h);
|
||||||
|
expect(applyDrag(1, -100, 0, pxPerSecond, duration26h)).toBe(0);
|
||||||
|
expect(applyDrag(50, 100, 0, 0, duration26h)).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("touch rail feedback (span shrink + lift)", () => {
|
||||||
|
it("starts at the full ±5-min span and shrinks with the zoom", () => {
|
||||||
|
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
|
||||||
|
expect(touchRailSpan(DRAG_REF_PX)).toBeCloseTo(RAIL_SPAN * 0.1, 5);
|
||||||
|
expect(touchRailSpan(5 * DRAG_REF_PX)).toBe(2); // zoomed past the floor
|
||||||
|
expect(touchRailSpan(60)).toBeLessThan(RAIL_SPAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the rail only past the pull threshold", () => {
|
||||||
|
expect(TOUCH_RAIL_MIN_DY_PX).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts toward the finger, capped", () => {
|
||||||
|
expect(railLiftPx(20)).toBe(-20); // finger above the bar → rail rises
|
||||||
|
expect(railLiftPx(-30)).toBe(30); // finger below → rail follows down
|
||||||
|
expect(railLiftPx(500)).toBe(-TOUCH_RAIL_LIFT_MAX_PX);
|
||||||
|
expect(railLiftPx(-500)).toBe(TOUCH_RAIL_LIFT_MAX_PX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("denseTicks", () => {
|
||||||
|
it("decimates an 1189-chapter book to a readable number of marks", () => {
|
||||||
|
const ticks = denseTicks(chapters1189, duration86h, 800, 4);
|
||||||
|
expect(ticks.length).toBeGreaterThan(50);
|
||||||
|
expect(ticks.length).toBeLessThan(300);
|
||||||
|
expect(ticks[0]).toBe(0);
|
||||||
|
// kept marks are strictly increasing and spaced by the gap rule
|
||||||
|
for (let i = 1; i < ticks.length; i += 1) {
|
||||||
|
const gapPx =
|
||||||
|
((chapters1189[ticks[i]].start - chapters1189[ticks[i - 1]].start) / duration86h) * 800;
|
||||||
|
expect(gapPx).toBeGreaterThanOrEqual(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every chapter for a sparse 34-chapter book", () => {
|
||||||
|
const ticks = denseTicks(chapters34, duration26h, 800, 4);
|
||||||
|
expect(ticks).toHaveLength(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles degenerate inputs", () => {
|
||||||
|
expect(denseTicks([], duration86h, 800)).toEqual([]);
|
||||||
|
expect(denseTicks(chapters34, 0, 800)).toEqual([]);
|
||||||
|
expect(denseTicks(chapters34, duration26h, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bufferedAheadEnd", () => {
|
||||||
|
it("returns the end of the range containing the playhead", () => {
|
||||||
|
const ranges = [
|
||||||
|
{ start: 0, end: 2 },
|
||||||
|
{ start: 92_719, end: 93_808 },
|
||||||
|
];
|
||||||
|
// A huge m4b's moov index at the tail is its own range — it must
|
||||||
|
// NOT read as "the whole book is buffered".
|
||||||
|
expect(bufferedAheadEnd(ranges, 0)).toBe(2);
|
||||||
|
expect(bufferedAheadEnd(ranges, 1.5)).toBe(2);
|
||||||
|
expect(bufferedAheadEnd(ranges, 92_800)).toBe(93_808);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks into the next range when the playhead sits at a boundary", () => {
|
||||||
|
expect(
|
||||||
|
bufferedAheadEnd(
|
||||||
|
[
|
||||||
|
{ start: 0, end: 10 },
|
||||||
|
{ start: 10, end: 30 },
|
||||||
|
],
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the playhead is in a gap or ranges are empty", () => {
|
||||||
|
expect(bufferedAheadEnd([{ start: 50, end: 60 }], 10)).toBeNull();
|
||||||
|
expect(bufferedAheadEnd([], 10)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bufferedSpanPct", () => {
|
||||||
|
it("maps only the active track's segment on a multi-track book", () => {
|
||||||
|
// Track 8 of the KJV shape spans ~2088s..2344s of 310320s.
|
||||||
|
const track = { start: 2088, end: 2344 };
|
||||||
|
const span = bufferedSpanPct(track, 2344, duration86h);
|
||||||
|
expect(span).not.toBeNull();
|
||||||
|
expect(span!.left).toBeCloseTo((2088 / duration86h) * 100, 5);
|
||||||
|
expect(span!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
|
||||||
|
// Element buffer beyond the track end is clamped to the track.
|
||||||
|
const clamped = bufferedSpanPct(track, 99999, duration86h);
|
||||||
|
expect(clamped!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spans the whole bar for a single-file book (start 0, end unknown)", () => {
|
||||||
|
const span = bufferedSpanPct({ start: 0, end: 0 }, 40_000, duration26h);
|
||||||
|
expect(span!.left).toBe(0);
|
||||||
|
expect(span!.width).toBeCloseTo((40_000 / duration26h) * 100, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null with no track or no buffer", () => {
|
||||||
|
expect(bufferedSpanPct(undefined, 100, duration86h)).toBeNull();
|
||||||
|
expect(bufferedSpanPct({ start: 0, end: 0 }, null, duration86h)).toBeNull();
|
||||||
|
expect(bufferedSpanPct({ start: 0, end: 0 }, 0, duration86h)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("railWindow / railSecondsAtPointer / railTicks", () => {
|
||||||
|
it("centers a ±5min window and clamps at the book edges", () => {
|
||||||
|
const mid = railWindow(duration26h / 2, duration26h);
|
||||||
|
expect(mid.from).toBeCloseTo(duration26h / 2 - RAIL_SPAN / 2, 5);
|
||||||
|
expect(mid.to).toBeCloseTo(duration26h / 2 + RAIL_SPAN / 2, 5);
|
||||||
|
const atStart = railWindow(0, duration26h);
|
||||||
|
expect(atStart.from).toBe(0);
|
||||||
|
expect(atStart.to).toBe(RAIL_SPAN);
|
||||||
|
const atEnd = railWindow(duration26h, duration26h);
|
||||||
|
expect(atEnd.to).toBe(duration26h);
|
||||||
|
expect(atEnd.to - atEnd.from).toBe(RAIL_SPAN);
|
||||||
|
// A book shorter than the window clamps to the whole book.
|
||||||
|
expect(railWindow(60, 120)).toEqual({ from: 0, to: 120 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps pointer X at minutes-per-pixel inside the window", () => {
|
||||||
|
const r = rect(600);
|
||||||
|
const window = { from: 3000, to: 3600 };
|
||||||
|
expect(railSecondsAtPointer(100, r, window)).toBe(3000);
|
||||||
|
expect(railSecondsAtPointer(400, r, window)).toBe(3300);
|
||||||
|
expect(railSecondsAtPointer(700, r, window)).toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the chapter starts inside the window with titles", () => {
|
||||||
|
// from is 1s past chapter 4's start, so chapter 4 is outside.
|
||||||
|
const ticks = railTicks(
|
||||||
|
{ from: chapters34[3].start + 1, to: chapters34[6].start + 1 },
|
||||||
|
chapters34,
|
||||||
|
);
|
||||||
|
expect(ticks.map((t) => t.title)).toEqual(["Chapter 5", "Chapter 6", "Chapter 7"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Scrubber math (player plans §1 + §9). Pure and DOM-free except for
|
||||||
|
// passing rects in; the Alpine component owns the element, the pointer
|
||||||
|
// events and the state. Everything here is unit-tested — the pixel
|
||||||
|
// magnetism, the pull-away drag scaling and the precision-rail mapping
|
||||||
|
// are exactly the parts that must hold for an 86-hour, 1189-track book
|
||||||
|
// AND a 26-hour single-file m4b.
|
||||||
|
|
||||||
|
export interface ScrubChapter {
|
||||||
|
start: number;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondsAtPointer maps a pointer X within the scrub bar's rect to a
|
||||||
|
// global book position, clamped to [0, duration].
|
||||||
|
export function secondsAtPointer(clientX: number, rect: DOMRect, duration: number): number {
|
||||||
|
if (rect.width <= 0 || duration <= 0) return 0;
|
||||||
|
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||||
|
return fraction * duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// chapterIndexFor returns the index of the chapter containing seconds —
|
||||||
|
// the last chapter whose start is at or before it. Empty chapters → -1.
|
||||||
|
export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): number {
|
||||||
|
let found = -1;
|
||||||
|
for (let i = 0; i < chapters.length; i += 1) {
|
||||||
|
if (seconds >= chapters[i].start) found = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- §9 magnetism: pixel radius, not a seconds threshold ----
|
||||||
|
// A seconds threshold goes sub-pixel on long books (1px ≈ 633s on the
|
||||||
|
// KJV bar — 5s could never fire); magnetism must be measured in PIXELS.
|
||||||
|
|
||||||
|
// MAGNET_RADIUS_PX is how close a click/hover must be to a chapter line,
|
||||||
|
// in bar pixels, for it to take over the landing position.
|
||||||
|
export const MAGNET_RADIUS_PX = 10;
|
||||||
|
|
||||||
|
// nearestChapterLine finds the chapter start closest to a position and
|
||||||
|
// reports its distance in bar pixels. Empty chapters → null.
|
||||||
|
export function nearestChapterLine(
|
||||||
|
seconds: number,
|
||||||
|
chapters: ScrubChapter[],
|
||||||
|
duration: number,
|
||||||
|
widthPx: number,
|
||||||
|
): { index: number; seconds: number; distancePx: number } | null {
|
||||||
|
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return null;
|
||||||
|
const pxPerSecond = widthPx / duration;
|
||||||
|
let best: { index: number; seconds: number; distancePx: number } | null = null;
|
||||||
|
for (let i = 0; i < chapters.length; i += 1) {
|
||||||
|
const distancePx = Math.abs(chapters[i].start - seconds) * pxPerSecond;
|
||||||
|
if (best === null || distancePx < best.distancePx)
|
||||||
|
best = { index: i, seconds: chapters[i].start, distancePx };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapToChapterPx applies the magnet: within radiusPx of a line the
|
||||||
|
// position lands ON the line; force (Shift+click) extends the pull to
|
||||||
|
// the nearest line regardless of distance. Otherwise unchanged.
|
||||||
|
export function snapToChapterPx(
|
||||||
|
seconds: number,
|
||||||
|
chapters: ScrubChapter[],
|
||||||
|
duration: number,
|
||||||
|
widthPx: number,
|
||||||
|
radiusPx = MAGNET_RADIUS_PX,
|
||||||
|
force = false,
|
||||||
|
): { seconds: number; snapped: boolean } {
|
||||||
|
const near = nearestChapterLine(seconds, chapters, duration, widthPx);
|
||||||
|
if (near === null) return { seconds, snapped: false };
|
||||||
|
if (force || near.distancePx <= radiusPx) return { seconds: near.seconds, snapped: true };
|
||||||
|
return { seconds, snapped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
|
||||||
|
|
||||||
|
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
|
||||||
|
// vertical distance from the bar: 1:1 at the bar, ~0.1× one screen-gap
|
||||||
|
// away, ~0.01× further. Exponential keeps the zoom continuous — any
|
||||||
|
// precision level is one gesture (plan §9 option C).
|
||||||
|
export const DRAG_REF_PX = 80;
|
||||||
|
export const DRAG_SCALE_MIN = 0.001;
|
||||||
|
|
||||||
|
export function dragScale(dyPx: number, refPx: number = DRAG_REF_PX): number {
|
||||||
|
if (refPx <= 0) return 1;
|
||||||
|
return Math.max(DRAG_SCALE_MIN, Math.pow(10, -Math.abs(dyPx) / refPx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDrag advances an INCREMENTAL drag cursor by one pointermove:
|
||||||
|
// horizontal travel (px) converts at the bar's scale, then shrinks by
|
||||||
|
// the current vertical offset. Incremental, not absolute — changing the
|
||||||
|
// scale mid-drag only affects subsequent travel, so pulling away never
|
||||||
|
// teleports the position. Clamped to the book.
|
||||||
|
export function applyDrag(
|
||||||
|
position: number,
|
||||||
|
dxPx: number,
|
||||||
|
dyPx: number,
|
||||||
|
pxPerSecond: number,
|
||||||
|
duration: number,
|
||||||
|
refPx: number = DRAG_REF_PX,
|
||||||
|
): number {
|
||||||
|
const clamped = Math.min(duration, Math.max(0, position));
|
||||||
|
if (pxPerSecond <= 0 || duration <= 0) return clamped;
|
||||||
|
return Math.min(duration, Math.max(0, clamped + (dxPx / pxPerSecond) * dragScale(dyPx, refPx)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch feedback thresholds: the rail appears once the finger is at
|
||||||
|
// least TOUCH_RAIL_MIN_DY_PX off the bar, and lifts toward the finger up
|
||||||
|
// to TOUCH_RAIL_LIFT_MAX_PX while the window shrinks with the zoom.
|
||||||
|
export const TOUCH_RAIL_MIN_DY_PX = 12;
|
||||||
|
export const TOUCH_RAIL_LIFT_MAX_PX = 40;
|
||||||
|
|
||||||
|
// touchRailSpan shrinks the rail window with the zoom (full ±5-min rail
|
||||||
|
// at 1:1, an order tighter per DRAG_REF_PX of pull) with a floor that
|
||||||
|
// keeps the edge labels readable.
|
||||||
|
export function touchRailSpan(dyPx: number, refPx: number = DRAG_REF_PX): number {
|
||||||
|
return Math.max(2, Math.min(RAIL_SPAN, RAIL_SPAN * dragScale(dyPx, refPx)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// railLiftPx converts the vertical offset into the rail's translateY:
|
||||||
|
// toward the finger (negative = up), capped so the rail never chases
|
||||||
|
// the finger off the player.
|
||||||
|
export function railLiftPx(dyPx: number): number {
|
||||||
|
return -Math.max(-TOUCH_RAIL_LIFT_MAX_PX, Math.min(TOUCH_RAIL_LIFT_MAX_PX, dyPx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// denseTicks decides which chapter starts get a rendered tick: a
|
||||||
|
// chapter earns a mark only when it is at least minGapPx from the last
|
||||||
|
// kept one — an 1189-chapter book would otherwise paint the bar solid.
|
||||||
|
// Returns chapter INDICES (into chapters), always including the first.
|
||||||
|
export function denseTicks(
|
||||||
|
chapters: ScrubChapter[],
|
||||||
|
duration: number,
|
||||||
|
widthPx: number,
|
||||||
|
minGapPx = 4,
|
||||||
|
): number[] {
|
||||||
|
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return [];
|
||||||
|
const kept: number[] = [0];
|
||||||
|
let lastPx = (chapters[0].start / duration) * widthPx;
|
||||||
|
for (let i = 1; i < chapters.length; i += 1) {
|
||||||
|
const px = (chapters[i].start / duration) * widthPx;
|
||||||
|
if (px - lastPx >= minGapPx) {
|
||||||
|
kept.push(i);
|
||||||
|
lastPx = px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferedAheadEnd returns the end of the buffered range containing
|
||||||
|
// the playhead — how much audio is actually fetched AHEAD of where you
|
||||||
|
// are. A huge m4b's moov index at the file tail shows up as its own
|
||||||
|
// buffered range; counting it would claim the whole book is buffered.
|
||||||
|
export function bufferedAheadEnd(
|
||||||
|
ranges: Array<{ start: number; end: number }>,
|
||||||
|
position: number,
|
||||||
|
): number | null {
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (position >= r.start && position < r.end) return r.end;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferedSpanPct maps the ACTIVE track's buffered-ahead end into the
|
||||||
|
// global bar. Buffers are per-track: the span is drawn only across the
|
||||||
|
// active track's segment of the bar, never the whole bar. Returns null
|
||||||
|
// when there is nothing buffered (or the track is unknown).
|
||||||
|
export function bufferedSpanPct(
|
||||||
|
track: { start: number; end: number } | undefined,
|
||||||
|
elementBufferedEnd: number | null,
|
||||||
|
duration: number,
|
||||||
|
): { left: number; width: number } | null {
|
||||||
|
if (!track || elementBufferedEnd === null || elementBufferedEnd <= 0 || duration <= 0)
|
||||||
|
return null;
|
||||||
|
const bufferedEnd = track.end > 0 ? Math.min(elementBufferedEnd, track.end) : elementBufferedEnd;
|
||||||
|
const span = Math.max(0, bufferedEnd - track.start);
|
||||||
|
const left = Math.min(100, Math.max(0, (track.start / duration) * 100));
|
||||||
|
const width = Math.min(100 - left, Math.max(0, (span / duration) * 100));
|
||||||
|
return { left, width };
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAIL_SPAN is the precision rail's total width in seconds: ±5 minutes
|
||||||
|
// around the drag anchor.
|
||||||
|
export const RAIL_SPAN = 600;
|
||||||
|
|
||||||
|
// railWindow clamps a ±span/2 window around the anchor to the book.
|
||||||
|
export function railWindow(
|
||||||
|
anchorSeconds: number,
|
||||||
|
duration: number,
|
||||||
|
spanSeconds = RAIL_SPAN,
|
||||||
|
): { from: number; to: number } {
|
||||||
|
const from = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(anchorSeconds - spanSeconds / 2, Math.max(0, duration - spanSeconds)),
|
||||||
|
);
|
||||||
|
const to = Math.min(duration, from + spanSeconds);
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
// railSecondsAtPointer maps a pointer X to a position INSIDE the rail
|
||||||
|
// window — minutes-per-pixel instead of hours-per-pixel.
|
||||||
|
export function railSecondsAtPointer(
|
||||||
|
clientX: number,
|
||||||
|
rect: DOMRect,
|
||||||
|
window: { from: number; to: number },
|
||||||
|
): number {
|
||||||
|
if (rect.width <= 0) return window.from;
|
||||||
|
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||||
|
return window.from + fraction * (window.to - window.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
// railTicks returns the chapter starts (with titles) inside a rail
|
||||||
|
// window — readable density is the rail's whole point.
|
||||||
|
export function railTicks(
|
||||||
|
window: { from: number; to: number },
|
||||||
|
chapters: ScrubChapter[],
|
||||||
|
): Array<{ start: number; title: string }> {
|
||||||
|
const out: Array<{ start: number; title: string }> = [];
|
||||||
|
for (let i = 0; i < chapters.length; i += 1) {
|
||||||
|
const start = chapters[i].start;
|
||||||
|
if (start < window.from) continue;
|
||||||
|
if (start > window.to) break;
|
||||||
|
out.push({ start, title: chapters[i].title || `Chapter ${i + 1}` });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SLEEP_CHOICES,
|
||||||
|
SLEEP_OFF,
|
||||||
|
armClockTime,
|
||||||
|
armEndOfChapter,
|
||||||
|
armMinutes,
|
||||||
|
fadeVolume,
|
||||||
|
formatRemaining,
|
||||||
|
sleepChapterFired,
|
||||||
|
sleepDue,
|
||||||
|
sleepLabel,
|
||||||
|
sleepRemainingMs,
|
||||||
|
} from "./audio-sleep";
|
||||||
|
|
||||||
|
describe("armMinutes / sleepDue / sleepRemainingMs", () => {
|
||||||
|
it("arms a wall-clock deadline", () => {
|
||||||
|
const state = armMinutes(1_000_000, 5);
|
||||||
|
expect(state.mode).toBe("minutes");
|
||||||
|
expect(state.endsAtMs).toBe(1_000_000 + 5 * 60_000);
|
||||||
|
expect(sleepDue(state, 1_000_000 + 5 * 60_000 - 1)).toBe(false);
|
||||||
|
expect(sleepDue(state, 1_000_000 + 5 * 60_000)).toBe(true);
|
||||||
|
expect(sleepDue(state, 1_000_000 + 9 * 60_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports remaining time clamped at zero", () => {
|
||||||
|
const state = armMinutes(0, 10);
|
||||||
|
expect(sleepRemainingMs(state, 0)).toBe(600_000);
|
||||||
|
expect(sleepRemainingMs(state, 599_000)).toBe(1000);
|
||||||
|
expect(sleepRemainingMs(state, 700_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never fires in non-minutes modes", () => {
|
||||||
|
expect(sleepDue(SLEEP_OFF, Date.now())).toBe(false);
|
||||||
|
expect(sleepDue(armEndOfChapter(3), Date.now())).toBe(false);
|
||||||
|
expect(sleepRemainingMs(SLEEP_OFF, Date.now())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("armEndOfChapter / sleepChapterFired", () => {
|
||||||
|
it("fires when the armed chapter is left, in either direction", () => {
|
||||||
|
const state = armEndOfChapter(7);
|
||||||
|
expect(sleepChapterFired(state, 7)).toBe(false);
|
||||||
|
expect(sleepChapterFired(state, 8)).toBe(true);
|
||||||
|
expect(sleepChapterFired(state, 6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing in other modes", () => {
|
||||||
|
expect(sleepChapterFired(SLEEP_OFF, 8)).toBe(false);
|
||||||
|
expect(sleepChapterFired(armMinutes(0, 5), 8)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fadeVolume", () => {
|
||||||
|
it("holds the base at start, ramps linearly, reaches zero at fade end", () => {
|
||||||
|
expect(fadeVolume(0.5, 0)).toBe(0.5);
|
||||||
|
expect(fadeVolume(0.5, 2500)).toBeCloseTo(0.25, 10);
|
||||||
|
expect(fadeVolume(0.5, 5000)).toBe(0);
|
||||||
|
expect(fadeVolume(0.5, 9000)).toBe(0);
|
||||||
|
expect(fadeVolume(1, 1000)).toBeCloseTo(0.8, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps negative elapsed to the base and out-of-range volumes", () => {
|
||||||
|
expect(fadeVolume(0.5, -5)).toBe(0.5);
|
||||||
|
expect(fadeVolume(2, 1000)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRemaining / sleepLabel", () => {
|
||||||
|
it("formats mm:ss, rounding up partial seconds", () => {
|
||||||
|
expect(formatRemaining(45 * 60_000)).toBe("45:00");
|
||||||
|
expect(formatRemaining(1000)).toBe("0:01");
|
||||||
|
expect(formatRemaining(59_500)).toBe("1:00");
|
||||||
|
expect(formatRemaining(0)).toBe("0:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels every mode", () => {
|
||||||
|
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
|
||||||
|
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
|
||||||
|
expect(
|
||||||
|
sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null, followUpMinutes: null }, 0),
|
||||||
|
).toBe("Fading");
|
||||||
|
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("armClockTime (§17 stop-at-clock)", () => {
|
||||||
|
it("arms a minutes-state deadline at the next occurrence of HH:MM", () => {
|
||||||
|
// 10:00 local → 23:00 same day.
|
||||||
|
const tenAm = new Date();
|
||||||
|
tenAm.setHours(10, 0, 0, 0);
|
||||||
|
const state = armClockTime(tenAm.getTime(), "23:00");
|
||||||
|
expect(state?.mode).toBe("minutes");
|
||||||
|
const expected = new Date(tenAm);
|
||||||
|
expected.setHours(23, 0, 0, 0);
|
||||||
|
expect(state?.endsAtMs).toBe(expected.getTime());
|
||||||
|
// Label is the ordinary countdown machinery (12h59m → 779:00).
|
||||||
|
expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps to tomorrow when the time already passed", () => {
|
||||||
|
const noon = new Date();
|
||||||
|
noon.setHours(12, 0, 0, 0);
|
||||||
|
const state = armClockTime(noon.getTime(), "09:15");
|
||||||
|
const expected = new Date(noon);
|
||||||
|
expected.setDate(expected.getDate() + 1);
|
||||||
|
expected.setHours(9, 15, 0, 0);
|
||||||
|
expect(state?.endsAtMs).toBe(expected.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unparseable and out-of-range input", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(armClockTime(now, "nope")).toBeNull();
|
||||||
|
expect(armClockTime(now, "24:00")).toBeNull();
|
||||||
|
expect(armClockTime(now, "10:61")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("§17 chain: end-of-chapter with a follow-up countdown", () => {
|
||||||
|
it("arms the follow-up on the state; the chapter fire contract is unchanged", () => {
|
||||||
|
const state = armEndOfChapter(2, 15);
|
||||||
|
expect(state.followUpMinutes).toBe(15);
|
||||||
|
// The sync contract (any chapter change fires) is the same — the
|
||||||
|
// component converts to armMinutes instead of fading.
|
||||||
|
expect(sleepChapterFired(state, 3)).toBe(true);
|
||||||
|
expect(sleepChapterFired(state, 1)).toBe(true);
|
||||||
|
expect(sleepChapterFired(state, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the chapter phase with the follow-up length", () => {
|
||||||
|
expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m");
|
||||||
|
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the chain row in the dropdown vocabulary", () => {
|
||||||
|
const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15");
|
||||||
|
expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SLEEP_CHOICES", () => {
|
||||||
|
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => {
|
||||||
|
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
|
||||||
|
"off",
|
||||||
|
"5",
|
||||||
|
"10",
|
||||||
|
"15",
|
||||||
|
"30",
|
||||||
|
"45",
|
||||||
|
"60",
|
||||||
|
"eoch",
|
||||||
|
"eoch15",
|
||||||
|
]);
|
||||||
|
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
|
||||||
|
5, 10, 15, 30, 45, 60,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has unique keys and labels", () => {
|
||||||
|
expect(new Set(SLEEP_CHOICES.map((c) => c.key)).size).toBe(SLEEP_CHOICES.length);
|
||||||
|
expect(new Set(SLEEP_CHOICES.map((c) => c.label)).size).toBe(SLEEP_CHOICES.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Sleep timer state machine (player plan §3). Pure and DOM-free — the
|
||||||
|
// Alpine component owns the timers/intervals and the audio element;
|
||||||
|
// this module owns the STATE rules. Per-session only: never persisted
|
||||||
|
// (the Android counterpart's persisted sleep setting is exactly the
|
||||||
|
// mistake plan §7 removes).
|
||||||
|
|
||||||
|
export type SleepMode = "off" | "minutes" | "end-of-chapter" | "fading";
|
||||||
|
|
||||||
|
export interface SleepState {
|
||||||
|
mode: SleepMode;
|
||||||
|
// Epoch ms deadline for "minutes" mode; null otherwise.
|
||||||
|
endsAtMs: number | null;
|
||||||
|
// Chapter index the end-of-chapter timer was armed at; null otherwise.
|
||||||
|
chapterIndex: number | null;
|
||||||
|
// §17 chain: when set on an end-of-chapter arm, leaving the chapter
|
||||||
|
// does NOT fade — it starts a minutes countdown of this length
|
||||||
|
// ("end of chapter, then 15 min").
|
||||||
|
followUpMinutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLEEP_OFF: SleepState = {
|
||||||
|
mode: "off",
|
||||||
|
endsAtMs: null,
|
||||||
|
chapterIndex: null,
|
||||||
|
followUpMinutes: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function armMinutes(nowMs: number, minutes: number): SleepState {
|
||||||
|
return {
|
||||||
|
mode: "minutes",
|
||||||
|
endsAtMs: nowMs + minutes * 60_000,
|
||||||
|
chapterIndex: null,
|
||||||
|
followUpMinutes: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function armEndOfChapter(
|
||||||
|
chapterIndex: number,
|
||||||
|
followUpMinutes: number | null = null,
|
||||||
|
): SleepState {
|
||||||
|
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex, followUpMinutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// armClockTime (§17): "stop at 23:00" — resolves HH:MM to its NEXT
|
||||||
|
// occurrence (today if still ahead, otherwise tomorrow) and arms the
|
||||||
|
// ordinary minutes machinery against that epoch deadline. Returns null
|
||||||
|
// for unparseable strings; the caller keeps the old state then.
|
||||||
|
export function armClockTime(nowMs: number, hhmm: string): SleepState | null {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const h = Number(m[1]);
|
||||||
|
const min = Number(m[2]);
|
||||||
|
if (h > 23 || min > 59) return null;
|
||||||
|
const target = new Date(nowMs);
|
||||||
|
target.setHours(h, min, 0, 0);
|
||||||
|
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
|
||||||
|
return { mode: "minutes", endsAtMs: target.getTime(), chapterIndex: null, followUpMinutes: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepDue is true when a minutes-mode deadline has passed.
|
||||||
|
export function sleepDue(state: SleepState, nowMs: number): boolean {
|
||||||
|
return state.mode === "minutes" && state.endsAtMs !== null && nowMs >= state.endsAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepChapterFired is true when an end-of-chapter timer's chapter has
|
||||||
|
// been left — ANY change from the armed index fires it (forward into
|
||||||
|
// the next chapter naturally, or a manual jump away from it).
|
||||||
|
export function sleepChapterFired(state: SleepState, currentIndex: number): boolean {
|
||||||
|
return (
|
||||||
|
state.mode === "end-of-chapter" &&
|
||||||
|
state.chapterIndex !== null &&
|
||||||
|
currentIndex !== state.chapterIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleepRemainingMs(state: SleepState, nowMs: number): number | null {
|
||||||
|
if (state.mode !== "minutes" || state.endsAtMs === null) return null;
|
||||||
|
return Math.max(0, state.endsAtMs - nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fadeVolume ramps linearly base → 0 across fadeMs and clamps beyond
|
||||||
|
// both ends.
|
||||||
|
export function fadeVolume(baseVolume: number, elapsedMs: number, fadeMs = 5000): number {
|
||||||
|
const base = Math.min(1, Math.max(0, baseVolume));
|
||||||
|
if (elapsedMs <= 0) return base;
|
||||||
|
if (elapsedMs >= fadeMs) return 0;
|
||||||
|
return Math.max(0, base * (1 - elapsedMs / fadeMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRemaining(ms: number): string {
|
||||||
|
const totalSeconds = Math.ceil(Math.max(0, ms) / 1000);
|
||||||
|
const m = Math.floor(totalSeconds / 60);
|
||||||
|
const s = totalSeconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleepLabel(state: SleepState, nowMs: number): string {
|
||||||
|
switch (state.mode) {
|
||||||
|
case "off":
|
||||||
|
return "Off";
|
||||||
|
case "end-of-chapter":
|
||||||
|
return state.followUpMinutes !== null ? `Ch + ${state.followUpMinutes}m` : "End of chapter";
|
||||||
|
case "fading":
|
||||||
|
return "Fading";
|
||||||
|
case "minutes": {
|
||||||
|
const remaining = sleepRemainingMs(state, nowMs);
|
||||||
|
return remaining === null ? "Off" : formatRemaining(remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown vocabulary — the templ sleep dropdown iterates this (the
|
||||||
|
// component exposes it; templ cannot import TS) and chooseSleep()
|
||||||
|
// consumes it. One source for both the rows and their behavior.
|
||||||
|
export interface SleepChoice {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
mode: "off" | "minutes" | "end-of-chapter";
|
||||||
|
minutes?: number;
|
||||||
|
// chain: this end-of-chapter row arms a follow-up minutes countdown
|
||||||
|
// ("end of chapter, then N min") instead of fading straight away.
|
||||||
|
chain?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLEEP_CHOICES: readonly SleepChoice[] = [
|
||||||
|
{ key: "off", label: "Off", mode: "off" },
|
||||||
|
{ key: "5", label: "5 minutes", mode: "minutes", minutes: 5 },
|
||||||
|
{ key: "10", label: "10 minutes", mode: "minutes", minutes: 10 },
|
||||||
|
{ key: "15", label: "15 minutes", mode: "minutes", minutes: 15 },
|
||||||
|
{ key: "30", label: "30 minutes", mode: "minutes", minutes: 30 },
|
||||||
|
{ key: "45", label: "45 minutes", mode: "minutes", minutes: 45 },
|
||||||
|
{ key: "60", label: "60 minutes", mode: "minutes", minutes: 60 },
|
||||||
|
{ key: "eoch", label: "End of chapter", mode: "end-of-chapter" },
|
||||||
|
{
|
||||||
|
key: "eoch15",
|
||||||
|
label: "End of chapter, then 15 min",
|
||||||
|
mode: "end-of-chapter",
|
||||||
|
minutes: 15,
|
||||||
|
chain: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SPEEDS,
|
||||||
|
SKIP_SECONDS,
|
||||||
|
TIME_DISPLAY_MODES,
|
||||||
|
cycleSkip,
|
||||||
|
cycleTimeDisplay,
|
||||||
|
endTimeLabel,
|
||||||
|
normalizeAutoplay,
|
||||||
|
normalizeSpeed,
|
||||||
|
normalizeTimeDisplay,
|
||||||
|
stepSpeed,
|
||||||
|
} from "./audio";
|
||||||
|
|
||||||
|
describe("SPEEDS", () => {
|
||||||
|
it("is the agreed list from the player plan (0.5 through 3)", () => {
|
||||||
|
expect(SPEEDS).toEqual([0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains 1× exactly once and stays ascending", () => {
|
||||||
|
expect(SPEEDS.filter((r) => r === 1)).toHaveLength(1);
|
||||||
|
expect([...SPEEDS].sort((a, b) => a - b)).toEqual([...SPEEDS]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeSpeed", () => {
|
||||||
|
it("passes through every listed rate", () => {
|
||||||
|
for (const rate of SPEEDS) {
|
||||||
|
expect(normalizeSpeed(String(rate))).toBe(rate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 1× for null, garbage and out-of-list values", () => {
|
||||||
|
expect(normalizeSpeed(null)).toBe(1);
|
||||||
|
expect(normalizeSpeed("")).toBe(1);
|
||||||
|
expect(normalizeSpeed("not-a-number")).toBe(1);
|
||||||
|
expect(normalizeSpeed("0")).toBe(1);
|
||||||
|
expect(normalizeSpeed("2.2")).toBe(1);
|
||||||
|
expect(normalizeSpeed("10")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a stale pre-plan entry of 0.75 or 2", () => {
|
||||||
|
expect(normalizeSpeed("0.75")).toBe(0.75);
|
||||||
|
expect(normalizeSpeed("2")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepSpeed", () => {
|
||||||
|
it("steps forward and backward through the list", () => {
|
||||||
|
expect(stepSpeed(1, 1)).toBe(1.25);
|
||||||
|
expect(stepSpeed(1, -1)).toBe(0.75);
|
||||||
|
expect(stepSpeed(0.5, 1)).toBe(0.75);
|
||||||
|
expect(stepSpeed(2, 1)).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps at both ends instead of wrapping", () => {
|
||||||
|
expect(stepSpeed(0.5, -1)).toBe(0.5);
|
||||||
|
expect(stepSpeed(3, 1)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchors unknown currents at 1× before stepping", () => {
|
||||||
|
expect(stepSpeed(2.2, 1)).toBe(1.25);
|
||||||
|
expect(stepSpeed(2.2, -1)).toBe(0.75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SKIP_SECONDS + cycleSkip", () => {
|
||||||
|
it("is the agreed skip list", () => {
|
||||||
|
expect(SKIP_SECONDS).toEqual([1, 5, 10, 15, 30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cycles forward and backward, clamping at the ends", () => {
|
||||||
|
expect(cycleSkip(10, 1)).toBe(15);
|
||||||
|
expect(cycleSkip(10, -1)).toBe(5);
|
||||||
|
expect(cycleSkip(1, -1)).toBe(1);
|
||||||
|
expect(cycleSkip(30, 1)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchors unknown currents at 10s before stepping", () => {
|
||||||
|
expect(cycleSkip(7, 1)).toBe(15);
|
||||||
|
expect(cycleSkip(7, -1)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeAutoplay", () => {
|
||||||
|
it("is on only for explicitly-on values", () => {
|
||||||
|
expect(normalizeAutoplay("1")).toBe(true);
|
||||||
|
expect(normalizeAutoplay("true")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to off for null, off, and garbage", () => {
|
||||||
|
expect(normalizeAutoplay(null)).toBe(false);
|
||||||
|
expect(normalizeAutoplay("0")).toBe(false);
|
||||||
|
expect(normalizeAutoplay("")).toBe(false);
|
||||||
|
expect(normalizeAutoplay("on")).toBe(false);
|
||||||
|
expect(normalizeAutoplay("2")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("time display mode (§10 end-label toggle)", () => {
|
||||||
|
it("cycles total → book remaining → chapter remaining → total", () => {
|
||||||
|
expect(TIME_DISPLAY_MODES).toEqual(["total", "book-left", "chapter-left"]);
|
||||||
|
expect(cycleTimeDisplay("total")).toBe("book-left");
|
||||||
|
expect(cycleTimeDisplay("book-left")).toBe("chapter-left");
|
||||||
|
expect(cycleTimeDisplay("chapter-left")).toBe("total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes persisted values, defaulting to total", () => {
|
||||||
|
expect(normalizeTimeDisplay("total")).toBe("total");
|
||||||
|
expect(normalizeTimeDisplay("book-left")).toBe("book-left");
|
||||||
|
expect(normalizeTimeDisplay("chapter-left")).toBe("chapter-left");
|
||||||
|
expect(normalizeTimeDisplay(null)).toBe("total");
|
||||||
|
expect(normalizeTimeDisplay("")).toBe("total");
|
||||||
|
expect(normalizeTimeDisplay("remaining")).toBe("total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders total as the book duration", () => {
|
||||||
|
// 93807s = 26:03:27 (Gardens-shaped)
|
||||||
|
expect(endTimeLabel("total", 0, 93_807, null)).toBe("26:03:27");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders book remaining, clamped at zero", () => {
|
||||||
|
expect(endTimeLabel("book-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02");
|
||||||
|
expect(endTimeLabel("book-left", 93_807, 93_807, null)).toBe("−0:00");
|
||||||
|
expect(endTimeLabel("book-left", 99_999, 93_807, null)).toBe("−0:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders chapter remaining with minutes:seconds formatting", () => {
|
||||||
|
// 842s left in the chapter → −14:02
|
||||||
|
expect(endTimeLabel("chapter-left", 5_000, 93_807, 5_842)).toBe("−14:02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to book remaining when the book has no chapters", () => {
|
||||||
|
expect(endTimeLabel("chapter-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a chapter end in the past as zero remaining", () => {
|
||||||
|
expect(endTimeLabel("chapter-left", 6_000, 93_807, 5_842)).toBe("−0:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
+792
-20
@@ -15,6 +15,44 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { renderNoteHtml } from "./markdown";
|
import { renderNoteHtml } from "./markdown";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
import {
|
||||||
|
MOUSE_GUIDE,
|
||||||
|
resolveShortcut,
|
||||||
|
TOUCH_GUIDE,
|
||||||
|
visibleKeymap,
|
||||||
|
type KeyAction,
|
||||||
|
} from "./audio-keys";
|
||||||
|
import {
|
||||||
|
SLEEP_CHOICES,
|
||||||
|
SLEEP_OFF,
|
||||||
|
armClockTime,
|
||||||
|
armEndOfChapter,
|
||||||
|
armMinutes,
|
||||||
|
fadeVolume,
|
||||||
|
sleepChapterFired,
|
||||||
|
sleepDue,
|
||||||
|
sleepLabel,
|
||||||
|
sleepRemainingMs,
|
||||||
|
type SleepChoice,
|
||||||
|
type SleepState,
|
||||||
|
} from "./audio-sleep";
|
||||||
|
import {
|
||||||
|
applyDrag,
|
||||||
|
bufferedAheadEnd,
|
||||||
|
bufferedSpanPct,
|
||||||
|
chapterIndexFor,
|
||||||
|
denseTicks,
|
||||||
|
MAGNET_RADIUS_PX,
|
||||||
|
nearestChapterLine,
|
||||||
|
railLiftPx,
|
||||||
|
railSecondsAtPointer,
|
||||||
|
railTicks,
|
||||||
|
railWindow,
|
||||||
|
secondsAtPointer,
|
||||||
|
snapToChapterPx,
|
||||||
|
TOUCH_RAIL_MIN_DY_PX,
|
||||||
|
touchRailSpan,
|
||||||
|
} from "./audio-scrubber";
|
||||||
|
|
||||||
type AudioChapter = { index: number; title: string; start: number; end: number };
|
type AudioChapter = { index: number; title: string; start: number; end: number };
|
||||||
|
|
||||||
@@ -52,6 +90,86 @@ export function globalSecondsFor(
|
|||||||
return (track ? track.start : 0) + Math.max(0, localSeconds);
|
return (track ? track.start : 0) + Math.max(0, localSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playback speeds — ONE source. The templ dropdown iterates it through
|
||||||
|
// the component's `speeds` property, and keyboard stepping (audio-keys,
|
||||||
|
// player plan §2) walks the same array through stepSpeed. Never hardcode
|
||||||
|
// a rate list elsewhere.
|
||||||
|
export const SPEEDS: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
|
||||||
|
|
||||||
|
// normalizeSpeed maps a persisted speed onto the SPEEDS list: missing,
|
||||||
|
// malformed or out-of-list values (e.g. a stale localStorage entry from
|
||||||
|
// before the list changed) fall back to 1×.
|
||||||
|
export function normalizeSpeed(value: string | null): number {
|
||||||
|
const n = Number(value);
|
||||||
|
return SPEEDS.includes(n) ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepSpeed moves through SPEEDS by delta, clamping at both ends; an
|
||||||
|
// unknown current anchors at 1× before stepping.
|
||||||
|
export function stepSpeed(current: number, delta: 1 | -1): number {
|
||||||
|
const idx = SPEEDS.indexOf(current);
|
||||||
|
const base = idx === -1 ? SPEEDS.indexOf(1) : idx;
|
||||||
|
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip durations — ONE source (same pattern as SPEEDS). The templ
|
||||||
|
// dropdown iterates it through the component's `skipChoices` property,
|
||||||
|
// and [ / ] keyboard cycling walks it through cycleSkip.
|
||||||
|
export const SKIP_SECONDS: readonly number[] = [1, 5, 10, 15, 30];
|
||||||
|
|
||||||
|
// cycleSkip moves through SKIP_SECONDS by delta, clamping at both
|
||||||
|
// ends; an unknown current anchors at 10s (the historical default).
|
||||||
|
export function cycleSkip(current: number, delta: 1 | -1): number {
|
||||||
|
const idx = SKIP_SECONDS.indexOf(current);
|
||||||
|
const base = idx === -1 ? SKIP_SECONDS.indexOf(10) : idx;
|
||||||
|
return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoplay-on-open setting — persisted as "1"/"0" (default off). The
|
||||||
|
// browser's autoplay policy decides whether the attempt succeeds; a
|
||||||
|
// rejection is swallowed silently (maybeAutoplay).
|
||||||
|
export function normalizeAutoplay(value: string | null): boolean {
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- End-time display mode (plan §10) ----
|
||||||
|
// Clicking the END time label cycles total → book remaining → chapter
|
||||||
|
// remaining, persisted per device (localStorage audio_time_display).
|
||||||
|
|
||||||
|
export type TimeDisplayMode = "total" | "book-left" | "chapter-left";
|
||||||
|
|
||||||
|
export const TIME_DISPLAY_MODES: readonly TimeDisplayMode[] = [
|
||||||
|
"total",
|
||||||
|
"book-left",
|
||||||
|
"chapter-left",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeTimeDisplay(value: string | null): TimeDisplayMode {
|
||||||
|
return value === "book-left" || value === "chapter-left" ? value : "total";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleTimeDisplay(current: TimeDisplayMode): TimeDisplayMode {
|
||||||
|
const idx = TIME_DISPLAY_MODES.indexOf(current);
|
||||||
|
return TIME_DISPLAY_MODES[(idx + 1) % TIME_DISPLAY_MODES.length] ?? "total";
|
||||||
|
}
|
||||||
|
|
||||||
|
// endTimeLabel renders the END label for a mode. Position is the
|
||||||
|
// playhead (or the scrub cursor mid-drag) so remaining values track
|
||||||
|
// what you'd land on. chapterEnd is the current chapter's end — null
|
||||||
|
// when the book has no chapters, in which case chapter mode falls back
|
||||||
|
// to book remaining. Remainders clamp at 0.
|
||||||
|
export function endTimeLabel(
|
||||||
|
mode: TimeDisplayMode,
|
||||||
|
position: number,
|
||||||
|
duration: number,
|
||||||
|
chapterEnd: number | null,
|
||||||
|
): string {
|
||||||
|
const remain = (to: number) => `−${fmtTime(Math.max(0, to - position))}`;
|
||||||
|
if (mode === "book-left") return remain(duration);
|
||||||
|
if (mode === "chapter-left") return remain(chapterEnd === null ? duration : chapterEnd);
|
||||||
|
return fmtTime(duration);
|
||||||
|
}
|
||||||
|
|
||||||
const PROGRESS_INTERVAL_MS = 30_000;
|
const PROGRESS_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
interface AudioConfig {
|
interface AudioConfig {
|
||||||
@@ -85,12 +203,45 @@ Alpine.data("audioReader", () => ({
|
|||||||
chaptersOpen: false,
|
chaptersOpen: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
playing: false,
|
playing: false,
|
||||||
speed: Number(localStorage.getItem("audio_speed") || 1),
|
speed: normalizeSpeed(localStorage.getItem("audio_speed")),
|
||||||
|
// User-level default speed (plan §8): powers the dropdown's
|
||||||
|
// "(default)" marker; hydrated from the chapters payload.
|
||||||
|
defaultSpeed: 1,
|
||||||
|
// Exposed for the templ dropdown's x-for — templ cannot import the
|
||||||
|
// const directly; this property is the binding.
|
||||||
|
speeds: SPEEDS,
|
||||||
|
// Same binding pattern, for the dialog's two scrub-system sections.
|
||||||
|
mouseGuide: MOUSE_GUIDE,
|
||||||
|
touchGuide: TOUCH_GUIDE,
|
||||||
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
|
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
|
||||||
|
// End-label display mode (§10): total → book remaining → chapter
|
||||||
|
// remaining, persisted per device.
|
||||||
|
timeDisplay: normalizeTimeDisplay(localStorage.getItem("audio_time_display")),
|
||||||
// Scrub guard: while dragging, timeupdate must not fight the handle
|
// Scrub guard: while dragging, timeupdate must not fight the handle
|
||||||
scrubbing: false,
|
scrubbing: false,
|
||||||
scrubValue: 0,
|
scrubValue: 0,
|
||||||
|
|
||||||
|
// Progress-bar overhaul (plan §1): chapter ticks, per-track buffer
|
||||||
|
// span, hover tooltip and the precision rail. Math lives in
|
||||||
|
// audio-scrubber.ts (pure, unit-tested).
|
||||||
|
tickIndices: [] as number[],
|
||||||
|
bufferedGlobal: null as number | null,
|
||||||
|
hoverSeconds: null as number | null,
|
||||||
|
hoverPct: 0,
|
||||||
|
// §9: hover locks to a chapter line inside the magnet radius (the
|
||||||
|
// highlight line + tooltip land where the click will).
|
||||||
|
hoverSnapped: false,
|
||||||
|
railFrom: 0,
|
||||||
|
railTo: 0,
|
||||||
|
railTickList: [] as Array<{ start: number; title: string }>,
|
||||||
|
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
|
||||||
|
// Shift+drag precision window, "touch" = incremental cursor with
|
||||||
|
// pull-away precision. The rail is only visible for the last two.
|
||||||
|
dragMode: null as null | "free" | "rail" | "touch",
|
||||||
|
railVisible: false,
|
||||||
|
railLiftY: 0,
|
||||||
|
touchLastX: 0,
|
||||||
|
|
||||||
// Track manifest: one AudioTrack per playable file. Single-file books
|
// Track manifest: one AudioTrack per playable file. Single-file books
|
||||||
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
|
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
|
||||||
// every code path below can treat playback as (track, local seconds)
|
// every code path below can treat playback as (track, local seconds)
|
||||||
@@ -116,6 +267,35 @@ Alpine.data("audioReader", () => ({
|
|||||||
skipOpen: false,
|
skipOpen: false,
|
||||||
speedOpen: false,
|
speedOpen: false,
|
||||||
volumeOpen: false,
|
volumeOpen: false,
|
||||||
|
skipChoices: SKIP_SECONDS,
|
||||||
|
|
||||||
|
// Keyboard shortcuts (audio-keys.ts keymap): the ? dialog and the
|
||||||
|
// brief OSD confirming keyboard-driven volume/mute changes.
|
||||||
|
helpOpen: false,
|
||||||
|
osdVisible: false,
|
||||||
|
osdText: "",
|
||||||
|
osdTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
|
||||||
|
// Sleep timer (plan §3): session-scoped, NEVER persisted. The pure
|
||||||
|
// state machine lives in audio-sleep.ts; this holds the state, the
|
||||||
|
// 1s countdown ticker and the 5s fade interval.
|
||||||
|
sleep: SLEEP_OFF as SleepState,
|
||||||
|
sleepChoices: SLEEP_CHOICES,
|
||||||
|
sleepOpen: false,
|
||||||
|
sleepLabelStr: "Off",
|
||||||
|
sleepArmedChoiceKey: "off",
|
||||||
|
sleepTicker: null as ReturnType<typeof setInterval> | null,
|
||||||
|
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
|
||||||
|
// §17 clock mode: the popover's time input (armed via chooseSleepClock).
|
||||||
|
sleepClockTime: "23:00",
|
||||||
|
// §17 wind-down dim: 0..0.35 black overlay opacity across the final
|
||||||
|
// 30s of a countdown. Reset by stop/finish.
|
||||||
|
sleepDim: 0,
|
||||||
|
|
||||||
|
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
|
||||||
|
// is the first option; the panel is structured for more.
|
||||||
|
settingsOpen: false,
|
||||||
|
autoplayOn: normalizeAutoplay(localStorage.getItem("audio_autoplay")),
|
||||||
|
|
||||||
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
|
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
|
||||||
// on unmute (spec: icon shows level arcs, crossed when muted).
|
// on unmute (spec: icon shows level arcs, crossed when muted).
|
||||||
@@ -143,6 +323,12 @@ Alpine.data("audioReader", () => ({
|
|||||||
init() {
|
init() {
|
||||||
if (this.initialized) return; // Alpine can re-init the body tree
|
if (this.initialized) return; // Alpine can re-init the body tree
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
// Heal a stale/out-of-list persisted speed so the storage matches
|
||||||
|
// what the player actually uses (normalizeSpeed already fixed the
|
||||||
|
// in-memory value at state creation).
|
||||||
|
if (localStorage.getItem("audio_speed") !== String(this.speed)) {
|
||||||
|
localStorage.setItem("audio_speed", String(this.speed));
|
||||||
|
}
|
||||||
window.addEventListener("error", (e) => {
|
window.addEventListener("error", (e) => {
|
||||||
document.title = "ERR: " + (e.message || "unknown");
|
document.title = "ERR: " + (e.message || "unknown");
|
||||||
});
|
});
|
||||||
@@ -163,14 +349,17 @@ Alpine.data("audioReader", () => ({
|
|||||||
// src is set by restorePosition once the track manifest is known —
|
// src is set by restorePosition once the track manifest is known —
|
||||||
// for multi-file books data-file-url (a directory or a zip root) is
|
// for multi-file books data-file-url (a directory or a zip root) is
|
||||||
// NOT directly playable. The catch branch below falls back to it.
|
// NOT directly playable. The catch branch below falls back to it.
|
||||||
audio.playbackRate = Number(this.speed) || 1;
|
audio.playbackRate = this.speed;
|
||||||
audio.preload = "auto";
|
audio.preload = "auto";
|
||||||
// Restored volume/mute from the settings strip's persisted state.
|
// Restored volume/mute from the settings strip's persisted state.
|
||||||
audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
|
audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
|
||||||
audio.muted = !!this.muted;
|
audio.muted = !!this.muted;
|
||||||
|
|
||||||
// Restore position: chapters + progress from the server.
|
// Restore position: chapters + progress from the server. Autoplay
|
||||||
this.restorePosition();
|
// (if enabled) starts AFTER the position is restored — the ?loc=
|
||||||
|
// deep link wins on position; autoplay only decides whether
|
||||||
|
// playback starts.
|
||||||
|
this.restorePosition().then(() => this.maybeAutoplay());
|
||||||
// Periodic progress push (playing-only; pause/hide push too)
|
// Periodic progress push (playing-only; pause/hide push too)
|
||||||
this.startPushTimer();
|
this.startPushTimer();
|
||||||
// Bookmarks list (time-located; notes optional)
|
// Bookmarks list (time-located; notes optional)
|
||||||
@@ -224,6 +413,9 @@ Alpine.data("audioReader", () => ({
|
|||||||
audio.addEventListener("play", () => {
|
audio.addEventListener("play", () => {
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
this.prevTick = performance.now();
|
this.prevTick = performance.now();
|
||||||
|
// Manual play during a sleep fade aborts the expiry (the user
|
||||||
|
// is awake and asked for audio); stopSleep restores the volume.
|
||||||
|
if (this.sleep.mode === "fading") this.stopSleep();
|
||||||
this.updateMediaSessionState();
|
this.updateMediaSessionState();
|
||||||
});
|
});
|
||||||
audio.addEventListener("pause", () => {
|
audio.addEventListener("pause", () => {
|
||||||
@@ -237,25 +429,30 @@ Alpine.data("audioReader", () => ({
|
|||||||
this.updateMediaSessionState();
|
this.updateMediaSessionState();
|
||||||
});
|
});
|
||||||
window.addEventListener("pagehide", () => this.pushProgress(true));
|
window.addEventListener("pagehide", () => this.pushProgress(true));
|
||||||
|
window.addEventListener("resize", () => this.recomputeTicks());
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.visibilityState === "hidden") this.pushProgress(true);
|
if (document.visibilityState === "hidden") this.pushProgress(true);
|
||||||
});
|
});
|
||||||
|
// Buffer readout for the scrubber's per-track buffer span.
|
||||||
|
// 'progress' alone does not fire reliably for Range-served files
|
||||||
|
// that go network-idle while paused — ride the events that
|
||||||
|
// demonstrably do, and re-read the (idempotent) ranges on each.
|
||||||
|
for (const ev of [
|
||||||
|
"progress",
|
||||||
|
"loadedmetadata",
|
||||||
|
"loadeddata",
|
||||||
|
"canplay",
|
||||||
|
"playing",
|
||||||
|
"timeupdate",
|
||||||
|
]) {
|
||||||
|
audio.addEventListener(ev, () => this.updateBuffered());
|
||||||
|
}
|
||||||
|
|
||||||
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
|
// Keyboard shortcuts (plan §2): one dispatcher resolving through
|
||||||
document.addEventListener("keydown", (e) => {
|
// the declarative audio-keys.ts keymap. Hardware media keys ride
|
||||||
const target = e.target as HTMLElement | null;
|
// the MediaSession handlers above; the Media* keydown codes land
|
||||||
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
|
// here and call the SAME methods.
|
||||||
if (e.code === "Space") {
|
document.addEventListener("keydown", (e) => this.handleKeydown(e));
|
||||||
e.preventDefault();
|
|
||||||
this.togglePlay();
|
|
||||||
} else if (e.code === "ArrowLeft") {
|
|
||||||
e.preventDefault();
|
|
||||||
this.skip(-1);
|
|
||||||
} else if (e.code === "ArrowRight") {
|
|
||||||
e.preventDefault();
|
|
||||||
this.skip(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
audio(): HTMLAudioElement | null {
|
audio(): HTMLAudioElement | null {
|
||||||
@@ -297,13 +494,15 @@ Alpine.data("audioReader", () => ({
|
|||||||
if (!audio || index < 0 || index >= this.tracks.length) return;
|
if (!audio || index < 0 || index >= this.tracks.length) return;
|
||||||
const track = this.tracks[index];
|
const track = this.tracks[index];
|
||||||
this.activeTrack = index;
|
this.activeTrack = index;
|
||||||
|
this.bufferedGlobal = null; // buffers are per-track; the next
|
||||||
|
// 'progress' event re-reads the freshly loading element's ranges.
|
||||||
if (audio.getAttribute("src") === track.url) {
|
if (audio.getAttribute("src") === track.url) {
|
||||||
this.pendingSeek = null;
|
this.pendingSeek = null;
|
||||||
audio.currentTime = Math.max(0, localSeconds);
|
audio.currentTime = Math.max(0, localSeconds);
|
||||||
} else {
|
} else {
|
||||||
this.pendingSeek = Math.max(0, localSeconds);
|
this.pendingSeek = Math.max(0, localSeconds);
|
||||||
audio.setAttribute("src", track.url);
|
audio.setAttribute("src", track.url);
|
||||||
audio.playbackRate = Number(this.speed) || 1;
|
audio.playbackRate = this.speed;
|
||||||
audio.load();
|
audio.load();
|
||||||
}
|
}
|
||||||
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
|
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
|
||||||
@@ -319,10 +518,27 @@ Alpine.data("audioReader", () => ({
|
|||||||
const data = await chaptersResp.json();
|
const data = await chaptersResp.json();
|
||||||
this.chapters = (data.chapters || []) as AudioChapter[];
|
this.chapters = (data.chapters || []) as AudioChapter[];
|
||||||
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
|
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
|
||||||
|
// §8: the server owns speed — per-book row, else the user's
|
||||||
|
// default. localStorage stays only as a first-paint cache; the
|
||||||
|
// payload value wins over it BEFORE the rate is (re)applied to
|
||||||
|
// the element below.
|
||||||
|
const serverSpeed = Number(data.playback_speed);
|
||||||
|
if (SPEEDS.includes(serverSpeed)) {
|
||||||
|
this.speed = serverSpeed;
|
||||||
|
localStorage.setItem("audio_speed", String(serverSpeed));
|
||||||
|
}
|
||||||
|
const serverDefault = Number(data.default_playback_speed);
|
||||||
|
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
|
||||||
this.buildTracks(data);
|
this.buildTracks(data);
|
||||||
|
this.recomputeTicks();
|
||||||
const audio = this.audio();
|
const audio = this.audio();
|
||||||
if (audio && !audio.getAttribute("src") && this.tracks[0]) {
|
if (audio && !audio.getAttribute("src") && this.tracks[0]) {
|
||||||
audio.setAttribute("src", this.tracks[0].url);
|
audio.setAttribute("src", this.tracks[0].url);
|
||||||
|
// Setting src resets playbackRate to the element default —
|
||||||
|
// re-apply, mirroring the seekTo() src-swap path. Without this
|
||||||
|
// the pill said 1.5× while audio actually played at 1× after
|
||||||
|
// every reload (surfaced by the speed-list browser check).
|
||||||
|
audio.playbackRate = this.speed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (progress.ok) {
|
if (progress.ok) {
|
||||||
@@ -351,6 +567,7 @@ Alpine.data("audioReader", () => ({
|
|||||||
if (audio && !audio.getAttribute("src") && this.fileUrl) {
|
if (audio && !audio.getAttribute("src") && this.fileUrl) {
|
||||||
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
|
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
|
||||||
audio.setAttribute("src", this.fileUrl);
|
audio.setAttribute("src", this.fileUrl);
|
||||||
|
audio.playbackRate = this.speed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -368,6 +585,8 @@ Alpine.data("audioReader", () => ({
|
|||||||
if (this.currentChapterIdx !== i) {
|
if (this.currentChapterIdx !== i) {
|
||||||
this.currentChapterIdx = i;
|
this.currentChapterIdx = i;
|
||||||
this.updateMediaSessionMetadata();
|
this.updateMediaSessionMetadata();
|
||||||
|
// Natural chapter crossing: fire an end-of-chapter sleep.
|
||||||
|
if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -423,6 +642,9 @@ Alpine.data("audioReader", () => ({
|
|||||||
const ch = this.chapters[index];
|
const ch = this.chapters[index];
|
||||||
if (ch) this.seekTo(ch.start);
|
if (ch) this.seekTo(ch.start);
|
||||||
this.currentChapterIdx = index;
|
this.currentChapterIdx = index;
|
||||||
|
// Manual jump: leaving the armed chapter fires an end-of-chapter
|
||||||
|
// sleep the same as a natural crossing.
|
||||||
|
this.onChapterCrossing(index);
|
||||||
},
|
},
|
||||||
|
|
||||||
setSpeed(rate: number) {
|
setSpeed(rate: number) {
|
||||||
@@ -430,6 +652,39 @@ Alpine.data("audioReader", () => ({
|
|||||||
localStorage.setItem("audio_speed", String(rate));
|
localStorage.setItem("audio_speed", String(rate));
|
||||||
const audio = this.audio();
|
const audio = this.audio();
|
||||||
if (audio) audio.playbackRate = rate;
|
if (audio) audio.playbackRate = rate;
|
||||||
|
// §8: the per-book speed lives server-side (localStorage is only a
|
||||||
|
// first-paint cache). Fire-and-forget; a failure toasts — the rate
|
||||||
|
// still applies to this session.
|
||||||
|
fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...getAuthHeaders(), "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ speed: rate }),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
})
|
||||||
|
.catch(() => showToast("Speed saved for this session only — server rejected it", "error"));
|
||||||
|
},
|
||||||
|
|
||||||
|
// setDefaultSpeed pins a user-level default (plan §8): books without
|
||||||
|
// their own per-book speed pick it up. Rides the profile endpoint,
|
||||||
|
// same as the profile-page select.
|
||||||
|
setDefaultSpeed(rate: number) {
|
||||||
|
fetch("/api/auth/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...getAuthHeaders(), "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ default_audio_speed: rate }),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
this.defaultSpeed = rate;
|
||||||
|
showToast(`Default playback speed set to ${rate}×`, "success");
|
||||||
|
})
|
||||||
|
.catch(() => showToast("Default speed not saved", "error"));
|
||||||
|
},
|
||||||
|
|
||||||
|
isDefaultSpeed(rate: number): boolean {
|
||||||
|
return Number(this.defaultSpeed) === rate;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSkip(seconds: number) {
|
setSkip(seconds: number) {
|
||||||
@@ -514,6 +769,210 @@ Alpine.data("audioReader", () => ({
|
|||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- progress bar (audio-scrubber.ts math) ----
|
||||||
|
|
||||||
|
recomputeTicks() {
|
||||||
|
const area = document.getElementById("scrub-area");
|
||||||
|
const width = area ? area.getBoundingClientRect().width : 0;
|
||||||
|
this.tickIndices = denseTicks(
|
||||||
|
this.chapters,
|
||||||
|
this.chaptersDuration() || this.bookDuration || 1,
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBuffered() {
|
||||||
|
const audio = this.audio();
|
||||||
|
if (!audio || audio.buffered.length === 0) {
|
||||||
|
this.bufferedGlobal = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Buffered-ahead of the playhead (element-local): the end of the
|
||||||
|
// range containing currentTime. The moov-index range at a huge
|
||||||
|
// m4b's tail must not read as "the whole book is buffered".
|
||||||
|
const ranges = Array.from({ length: audio.buffered.length }, (_, i) => ({
|
||||||
|
start: audio.buffered.start(i),
|
||||||
|
end: audio.buffered.end(i),
|
||||||
|
}));
|
||||||
|
this.bufferedGlobal = bufferedAheadEnd(ranges, audio.currentTime);
|
||||||
|
},
|
||||||
|
|
||||||
|
chapterTitleAt(seconds: number): string {
|
||||||
|
const idx = chapterIndexFor(this.chapters, seconds);
|
||||||
|
if (idx === -1) return this.title;
|
||||||
|
const ch = this.chapters[idx];
|
||||||
|
return ch.title || `Chapter ${idx + 1}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- end-time label (§10): total → book left → chapter left ----
|
||||||
|
|
||||||
|
endTimeText(): string {
|
||||||
|
const duration = this.chaptersDuration() || 1;
|
||||||
|
const pos = this.scrubbing ? this.scrubValue : Number(this.currentTime);
|
||||||
|
const idx = chapterIndexFor(this.chapters, pos);
|
||||||
|
const ch = this.chapters[idx === -1 ? 0 : idx];
|
||||||
|
return endTimeLabel(this.timeDisplay, pos, duration, ch ? ch.end : null);
|
||||||
|
},
|
||||||
|
|
||||||
|
endTimeAria(): string {
|
||||||
|
if (this.timeDisplay === "book-left")
|
||||||
|
return "Book time remaining — click to show chapter remaining";
|
||||||
|
if (this.timeDisplay === "chapter-left")
|
||||||
|
return "Chapter time remaining — click to show total duration";
|
||||||
|
return "Total duration — click to show book remaining";
|
||||||
|
},
|
||||||
|
|
||||||
|
cycleEndTime() {
|
||||||
|
this.timeDisplay = cycleTimeDisplay(this.timeDisplay);
|
||||||
|
localStorage.setItem("audio_time_display", this.timeDisplay);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrubFillPct(): number {
|
||||||
|
const value = this.scrubbing ? this.scrubValue : Number(this.currentTime);
|
||||||
|
return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100));
|
||||||
|
},
|
||||||
|
|
||||||
|
bufferSpan(): { left: number; width: number } | null {
|
||||||
|
return bufferedSpanPct(
|
||||||
|
this.tracks[this.activeTrack],
|
||||||
|
this.bufferedGlobal,
|
||||||
|
this.chaptersDuration() || 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
railMarkerPct(): number {
|
||||||
|
const span = Math.max(1, this.railTo - this.railFrom);
|
||||||
|
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no
|
||||||
|
// rail, never sticks at chapter lines); clicks land with pixel
|
||||||
|
// magnetism on nearby chapter lines; Shift+click/drag opens the
|
||||||
|
// precision rail (±5 min, free mapping inside); touch drags are an
|
||||||
|
// incremental cursor whose scale tightens as the finger pulls off the
|
||||||
|
// bar (progressive precision — the rail is its feedback). The native
|
||||||
|
// range stays underneath for keyboard/aria — its drag default is
|
||||||
|
// prevented so the mappings never fight.
|
||||||
|
scrubPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0 || this.scrubbing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const area = e.currentTarget as HTMLElement;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
try {
|
||||||
|
area.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* synthetic or already-released pointer — drag still works via
|
||||||
|
the element-level listeners */
|
||||||
|
}
|
||||||
|
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
|
||||||
|
const duration = this.chaptersDuration() || 1;
|
||||||
|
const at = secondsAtPointer(e.clientX, rect, duration);
|
||||||
|
if (e.pointerType === "touch") {
|
||||||
|
// Tap lands magnetically; the drag starts 1:1 and tightens as
|
||||||
|
// the finger pulls away (scrubPointerMove's incremental cursor).
|
||||||
|
this.dragMode = "touch";
|
||||||
|
this.touchLastX = e.clientX;
|
||||||
|
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
||||||
|
} else if (e.shiftKey) {
|
||||||
|
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
||||||
|
// chapter line (Shift+click = snap regardless of radius).
|
||||||
|
this.dragMode = "rail";
|
||||||
|
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
|
||||||
|
this.scrubValue = near ? near.seconds : at;
|
||||||
|
const window = railWindow(this.scrubValue, duration);
|
||||||
|
this.railFrom = window.from;
|
||||||
|
this.railTo = window.to;
|
||||||
|
this.railTickList = railTicks(window, this.chapters);
|
||||||
|
this.railVisible = true;
|
||||||
|
this.railLiftY = 0;
|
||||||
|
} else {
|
||||||
|
// Mouse click/drag: the CLICK's landing is magnetic; drags stay
|
||||||
|
// free-flow (scrubPointerMove overwrites with the raw position).
|
||||||
|
this.dragMode = "free";
|
||||||
|
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
||||||
|
}
|
||||||
|
this.scrubbing = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
scrubPointerMove(e: PointerEvent) {
|
||||||
|
const area = e.currentTarget as HTMLElement;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const duration = this.chaptersDuration() || 1;
|
||||||
|
if (!this.scrubbing) {
|
||||||
|
// Hover: time + chapter, locking to a chapter line inside the
|
||||||
|
// magnet radius so the tooltip shows the click's true landing.
|
||||||
|
const at = secondsAtPointer(e.clientX, rect, duration);
|
||||||
|
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
|
||||||
|
const locked = near !== null && near.distancePx <= MAGNET_RADIUS_PX;
|
||||||
|
const hoverAt = locked && near ? near.seconds : at;
|
||||||
|
this.hoverSeconds = hoverAt;
|
||||||
|
this.hoverPct = rect.width > 0 ? Math.min(100, Math.max(0, (hoverAt / duration) * 100)) : 0;
|
||||||
|
this.hoverSnapped = locked;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.dragMode === "rail") {
|
||||||
|
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
|
||||||
|
from: this.railFrom,
|
||||||
|
to: this.railTo,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.dragMode === "touch") {
|
||||||
|
const dx = e.clientX - this.touchLastX;
|
||||||
|
this.touchLastX = e.clientX;
|
||||||
|
const dy = rect.top + rect.height / 2 - e.clientY;
|
||||||
|
this.scrubValue = applyDrag(this.scrubValue, dx, dy, rect.width / duration, duration);
|
||||||
|
if (Math.abs(dy) >= TOUCH_RAIL_MIN_DY_PX) {
|
||||||
|
// Feedback: the rail follows the cursor, its window shrinking
|
||||||
|
// with the zoom, lifting toward the finger.
|
||||||
|
const window = railWindow(this.scrubValue, duration, touchRailSpan(dy));
|
||||||
|
this.railFrom = window.from;
|
||||||
|
this.railTo = window.to;
|
||||||
|
this.railTickList = railTicks(window, this.chapters);
|
||||||
|
this.railVisible = true;
|
||||||
|
this.railLiftY = railLiftPx(dy);
|
||||||
|
} else {
|
||||||
|
this.railVisible = false;
|
||||||
|
this.railLiftY = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Free flow: direct 1:1 mapping — dragging across chapter lines
|
||||||
|
// never sticks.
|
||||||
|
this.scrubValue = secondsAtPointer(e.clientX, rect, duration);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrubPointerLeave() {
|
||||||
|
if (!this.scrubbing) {
|
||||||
|
this.hoverSeconds = null;
|
||||||
|
this.hoverSnapped = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrubPointerUp(e: PointerEvent) {
|
||||||
|
if (!this.scrubbing) return;
|
||||||
|
const area = e.currentTarget as HTMLElement;
|
||||||
|
if (area.hasPointerCapture && area.hasPointerCapture(e.pointerId))
|
||||||
|
area.releasePointerCapture(e.pointerId);
|
||||||
|
this.seekTo(this.scrubValue);
|
||||||
|
this.scrubbing = false;
|
||||||
|
this.dragMode = null;
|
||||||
|
this.railVisible = false;
|
||||||
|
this.railLiftY = 0;
|
||||||
|
this.hoverSeconds = null;
|
||||||
|
this.hoverSnapped = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
|
||||||
|
onScrubWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.shiftKey ? 60 : 5;
|
||||||
|
const direction = e.deltaY > 0 ? -1 : 1;
|
||||||
|
const duration = this.chaptersDuration() || 1;
|
||||||
|
const target = Math.min(duration, Math.max(0, this.globalPosition() + direction * step));
|
||||||
|
this.seekTo(target);
|
||||||
|
},
|
||||||
|
|
||||||
toggleBookmarkCapture() {
|
toggleBookmarkCapture() {
|
||||||
if (this.bookmarkCaptureOpen) {
|
if (this.bookmarkCaptureOpen) {
|
||||||
this.bookmarkCaptureOpen = false;
|
this.bookmarkCaptureOpen = false;
|
||||||
@@ -523,6 +982,319 @@ Alpine.data("audioReader", () => ({
|
|||||||
this.bookmarkCaptureOpen = true;
|
this.bookmarkCaptureOpen = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Keyboard B: open the capture pill and put the cursor in the note
|
||||||
|
// box (user's explicit flow — Enter then saves, even when blank).
|
||||||
|
openBookmarkCapture() {
|
||||||
|
if (!this.bookmarkCaptureOpen) this.toggleBookmarkCapture();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
(document.getElementById("bookmark-note") as HTMLTextAreaElement | null)?.focus();
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- keyboard dispatcher (audio-keys.ts) ----
|
||||||
|
|
||||||
|
visibleKeymap() {
|
||||||
|
return visibleKeymap();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Escape closes the top-most open surface — even while typing, since
|
||||||
|
// canceling the bookmark note mid-sentence is the expected Esc.
|
||||||
|
// Returns true when something actually closed.
|
||||||
|
closeTopSurface(): boolean {
|
||||||
|
if (this.helpOpen) {
|
||||||
|
this.helpOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.bookmarkCaptureOpen) {
|
||||||
|
this.bookmarkCaptureOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.editingBookmarkId !== "") {
|
||||||
|
this.editingBookmarkId = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.settingsOpen) {
|
||||||
|
this.settingsOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.chaptersOpen) {
|
||||||
|
this.chaptersOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.bookmarksOpen) {
|
||||||
|
this.bookmarksOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.skipOpen || this.speedOpen || this.volumeOpen || this.sleepOpen) {
|
||||||
|
this.skipOpen = false;
|
||||||
|
this.speedOpen = false;
|
||||||
|
this.volumeOpen = false;
|
||||||
|
this.sleepOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
showOsd(text: string) {
|
||||||
|
this.osdText = text;
|
||||||
|
this.osdVisible = true;
|
||||||
|
if (this.osdTimer) clearTimeout(this.osdTimer);
|
||||||
|
this.osdTimer = setTimeout(() => {
|
||||||
|
this.osdVisible = false;
|
||||||
|
}, 1200);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleKeydown(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
const tag = target?.tagName;
|
||||||
|
const typing = tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable === true;
|
||||||
|
|
||||||
|
// Escape first: cancel the top-most surface even while typing.
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (this.closeTopSurface()) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing in any input (bookmark notes!) never triggers shortcuts.
|
||||||
|
if (typing) return;
|
||||||
|
|
||||||
|
const action: KeyAction | null = resolveShortcut(e.key, e.altKey);
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
// Space/Enter activate a focused control — never hijack them for
|
||||||
|
// play/pause when the user is keyboard-driving a button.
|
||||||
|
if (action === "toggle-play" && (tag === "BUTTON" || tag === "SELECT" || tag === "A")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
switch (action) {
|
||||||
|
case "toggle-play":
|
||||||
|
case "media-toggle-play":
|
||||||
|
this.togglePlay();
|
||||||
|
break;
|
||||||
|
case "media-play":
|
||||||
|
if (!this.playing) this.togglePlay();
|
||||||
|
break;
|
||||||
|
case "media-pause":
|
||||||
|
if (this.playing) this.togglePlay();
|
||||||
|
break;
|
||||||
|
case "skip-back":
|
||||||
|
this.skip(-1);
|
||||||
|
break;
|
||||||
|
case "skip-forward":
|
||||||
|
this.skip(1);
|
||||||
|
break;
|
||||||
|
case "prev-chapter":
|
||||||
|
case "media-prev":
|
||||||
|
this.stepChapter(-1);
|
||||||
|
break;
|
||||||
|
case "next-chapter":
|
||||||
|
case "media-next":
|
||||||
|
this.stepChapter(1);
|
||||||
|
break;
|
||||||
|
case "bookmark":
|
||||||
|
this.openBookmarkCapture();
|
||||||
|
break;
|
||||||
|
case "speed-up":
|
||||||
|
this.setSpeed(stepSpeed(this.speed, 1));
|
||||||
|
break;
|
||||||
|
case "speed-down":
|
||||||
|
this.setSpeed(stepSpeed(this.speed, -1));
|
||||||
|
break;
|
||||||
|
case "volume-up":
|
||||||
|
case "volume-down": {
|
||||||
|
const step = action === "volume-up" ? 0.05 : -0.05;
|
||||||
|
const next = Math.round((Number(this.volume) + step) * 100) / 100;
|
||||||
|
this.setVolume(next);
|
||||||
|
this.showOsd(`Volume ${Math.round(Number(this.volume) * 100)}%`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toggle-mute":
|
||||||
|
this.toggleMute();
|
||||||
|
this.showOsd(this.muted ? "Muted" : "Unmuted");
|
||||||
|
break;
|
||||||
|
case "cycle-skip-up":
|
||||||
|
this.setSkip(cycleSkip(this.skipSeconds, 1));
|
||||||
|
break;
|
||||||
|
case "cycle-skip-down":
|
||||||
|
this.setSkip(cycleSkip(this.skipSeconds, -1));
|
||||||
|
break;
|
||||||
|
case "toggle-help":
|
||||||
|
this.helpOpen = !this.helpOpen;
|
||||||
|
break;
|
||||||
|
case "escape":
|
||||||
|
this.closeTopSurface();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- sleep timer (audio-sleep.ts state machine) ----
|
||||||
|
|
||||||
|
isSleepActive(key: string): boolean {
|
||||||
|
return this.sleepArmedChoiceKey === key;
|
||||||
|
},
|
||||||
|
|
||||||
|
chooseSleep(choice: SleepChoice) {
|
||||||
|
this.sleepArmedChoiceKey = choice.key;
|
||||||
|
if (choice.mode === "off") {
|
||||||
|
this.stopSleep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice.mode === "end-of-chapter") {
|
||||||
|
this.stopSleepTicker();
|
||||||
|
// §17 chain: "end of chapter, then N min" — leaving the chapter
|
||||||
|
// starts the countdown instead of fading straight away.
|
||||||
|
this.sleep = armEndOfChapter(
|
||||||
|
this.currentChapterIdx,
|
||||||
|
choice.chain ? (choice.minutes ?? 15) : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.stopSleepTicker();
|
||||||
|
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
|
||||||
|
this.startSleepTicker();
|
||||||
|
}
|
||||||
|
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||||
|
},
|
||||||
|
|
||||||
|
// §17 clock mode: arm against the next occurrence of the popover's
|
||||||
|
// HH:MM input. Unparseable input keeps the current state.
|
||||||
|
chooseSleepClock() {
|
||||||
|
const armed = armClockTime(Date.now(), this.sleepClockTime);
|
||||||
|
if (!armed) return;
|
||||||
|
this.stopSleepTicker();
|
||||||
|
this.sleep = armed;
|
||||||
|
this.sleepArmedChoiceKey = "clock";
|
||||||
|
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||||
|
this.startSleepTicker();
|
||||||
|
},
|
||||||
|
|
||||||
|
// A chapter crossing fires an end-of-chapter sleep: straight to the
|
||||||
|
// fade — or (§17 chain arm) convert to the follow-up countdown.
|
||||||
|
onChapterCrossing(index: number) {
|
||||||
|
if (!sleepChapterFired(this.sleep, index)) return;
|
||||||
|
const follow = this.sleep.followUpMinutes;
|
||||||
|
if (follow !== null) {
|
||||||
|
this.sleep = armMinutes(Date.now(), follow);
|
||||||
|
this.startSleepTicker();
|
||||||
|
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||||
|
} else {
|
||||||
|
this.beginSleepFade();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startSleepTicker() {
|
||||||
|
this.stopSleepTicker();
|
||||||
|
this.sleepTicker = setInterval(() => {
|
||||||
|
if (this.sleep.mode !== "minutes") return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (sleepDue(this.sleep, now)) {
|
||||||
|
this.stopSleepTicker();
|
||||||
|
this.beginSleepFade();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sleepLabelStr = sleepLabel(this.sleep, now);
|
||||||
|
// §17 wind-down dim: ease toward 35% black across the final 30s.
|
||||||
|
const remaining = sleepRemainingMs(this.sleep, now);
|
||||||
|
this.sleepDim =
|
||||||
|
remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0;
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopSleepTicker() {
|
||||||
|
if (this.sleepTicker) {
|
||||||
|
clearInterval(this.sleepTicker);
|
||||||
|
this.sleepTicker = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expiry: fade the element volume over ~5s, then pause. The pause
|
||||||
|
// fires the SAME listener a manual pause does (progress push + media
|
||||||
|
// session state); the user's configured volume is restored right
|
||||||
|
// after, so the next play starts at full level.
|
||||||
|
beginSleepFade() {
|
||||||
|
if (this.sleep.mode === "fading") return;
|
||||||
|
const audio = this.audio();
|
||||||
|
if (!audio) {
|
||||||
|
this.stopSleep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sleep = { mode: "fading", endsAtMs: null, chapterIndex: null };
|
||||||
|
this.sleepLabelStr = "Fading";
|
||||||
|
if (this.muted || Number(this.volume) <= 0) {
|
||||||
|
// Inaudible anyway: skip the pantomime, just pause.
|
||||||
|
this.finishSleepPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = Number(this.volume) || 0.5;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this.sleepFadeTimer = setInterval(() => {
|
||||||
|
const v = fadeVolume(base, Date.now() - startedAt);
|
||||||
|
audio.volume = v;
|
||||||
|
if (v <= 0) this.finishSleepPause();
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
finishSleepPause() {
|
||||||
|
if (this.sleepFadeTimer) {
|
||||||
|
clearInterval(this.sleepFadeTimer);
|
||||||
|
this.sleepFadeTimer = null;
|
||||||
|
}
|
||||||
|
this.stopSleepTicker();
|
||||||
|
const audio = this.audio();
|
||||||
|
if (audio && !audio.paused) audio.pause();
|
||||||
|
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
|
||||||
|
this.sleep = SLEEP_OFF;
|
||||||
|
this.sleepArmedChoiceKey = "off";
|
||||||
|
this.sleepLabelStr = "Off";
|
||||||
|
this.sleepDim = 0;
|
||||||
|
// §17: a genuine fire can catch you still awake — one-click undo.
|
||||||
|
showToast("Sleep timer: playback paused", "info", 8000, {
|
||||||
|
label: "Resume",
|
||||||
|
onClick: () => {
|
||||||
|
const a = this.audio();
|
||||||
|
if (a) a.play().catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manual off / play-abort: undo any partial fade.
|
||||||
|
stopSleep() {
|
||||||
|
if (this.sleepFadeTimer) {
|
||||||
|
clearInterval(this.sleepFadeTimer);
|
||||||
|
this.sleepFadeTimer = null;
|
||||||
|
}
|
||||||
|
this.stopSleepTicker();
|
||||||
|
const audio = this.audio();
|
||||||
|
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
|
||||||
|
this.sleep = SLEEP_OFF;
|
||||||
|
this.sleepArmedChoiceKey = "off";
|
||||||
|
this.sleepLabelStr = "Off";
|
||||||
|
this.sleepDim = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- settings popover (plan §4) ----
|
||||||
|
|
||||||
|
toggleAutoplay() {
|
||||||
|
this.autoplayOn = !this.autoplayOn;
|
||||||
|
localStorage.setItem("audio_autoplay", this.autoplayOn ? "1" : "0");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Attempt playback after restorePosition when the user opted in. A
|
||||||
|
// browser autoplay-policy block (NotAllowedError) — or any transient
|
||||||
|
// play rejection — is swallowed silently; the user just taps play.
|
||||||
|
maybeAutoplay() {
|
||||||
|
if (!this.autoplayOn) return;
|
||||||
|
const audio = this.audio();
|
||||||
|
if (!audio || !audio.paused) return;
|
||||||
|
audio.play().catch(() => {
|
||||||
|
/* autoplay blocked — stay silent */
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async saveBookmark() {
|
async saveBookmark() {
|
||||||
const audio = this.audio();
|
const audio = this.audio();
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isFlatable,
|
||||||
|
isRule,
|
||||||
|
pruneIncomplete,
|
||||||
|
ruleToFlatParam,
|
||||||
|
type FilterGroupState,
|
||||||
|
type FilterRuleState,
|
||||||
|
} from "./filter_builder";
|
||||||
|
|
||||||
|
// ruleToFlatParam/isFlatable encode the PINNED deep-link wire contract:
|
||||||
|
// these params are byte-compatible with bookmarked URLs already in the
|
||||||
|
// wild, so assertions here are contract pins, not implementation details.
|
||||||
|
|
||||||
|
const rule = (field: string, op: string, values: string[] = []): FilterRuleState => ({
|
||||||
|
field,
|
||||||
|
op,
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = (
|
||||||
|
mode: "all" | "any",
|
||||||
|
children: (FilterRuleState | FilterGroupState)[],
|
||||||
|
implicit = false,
|
||||||
|
): FilterGroupState => ({ mode, children, ...(implicit ? { implicit: true } : {}) });
|
||||||
|
|
||||||
|
describe("ruleToFlatParam", () => {
|
||||||
|
it("maps contains on flat fields to the pinned param", () => {
|
||||||
|
expect(ruleToFlatParam(rule("author", "contains", ["king"]))).toEqual({
|
||||||
|
author_filter: "king",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple values with commas", () => {
|
||||||
|
expect(ruleToFlatParam(rule("tags", "contains", ["a", "b"]))).toEqual({
|
||||||
|
tags_filter: "a,b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps is on enum fields", () => {
|
||||||
|
expect(ruleToFlatParam(rule("manga_type", "is", ["manga"]))).toEqual({
|
||||||
|
manga_type_filter: "manga",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps published_year at_least/at_most to year_min/year_max", () => {
|
||||||
|
expect(ruleToFlatParam(rule("published_year", "at_least", ["2000"]))).toEqual({
|
||||||
|
year_min: "2000",
|
||||||
|
});
|
||||||
|
expect(ruleToFlatParam(rule("published_year", "at_most", ["2010"]))).toEqual({
|
||||||
|
year_max: "2010",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps published_year between to both bounds", () => {
|
||||||
|
expect(ruleToFlatParam(rule("published_year", "between", ["2000", "2010"]))).toEqual({
|
||||||
|
year_min: "2000",
|
||||||
|
year_max: "2010",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty values", () => {
|
||||||
|
expect(ruleToFlatParam(rule("author", "contains", []))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-flat-expressible combinations", () => {
|
||||||
|
// contains on a numeric field has no flat param.
|
||||||
|
expect(ruleToFlatParam(rule("published_year", "contains", ["2000"]))).toBeNull();
|
||||||
|
// not_contains is deliberately excluded from the flat contract.
|
||||||
|
expect(ruleToFlatParam(rule("author", "not_contains", ["king"]))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isFlatable", () => {
|
||||||
|
it("accepts a single ALL group of unique flat rules", () => {
|
||||||
|
const tree = group("all", [
|
||||||
|
rule("author", "contains", ["king"]),
|
||||||
|
rule("published_year", "at_least", ["2000"]),
|
||||||
|
]);
|
||||||
|
expect(isFlatable(tree)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ANY mode", () => {
|
||||||
|
expect(isFlatable(group("any", [rule("author", "contains", ["king"])]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects nested groups", () => {
|
||||||
|
expect(isFlatable(group("all", [group("all", [rule("author", "contains", ["king"])])]))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate fields", () => {
|
||||||
|
expect(
|
||||||
|
isFlatable(
|
||||||
|
group("all", [rule("author", "contains", ["a"]), rule("author", "contains", ["b"])]),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-flat rules", () => {
|
||||||
|
expect(isFlatable(group("all", [rule("author", "not_contains", ["king"])]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneIncomplete", () => {
|
||||||
|
it("keeps complete rules untouched", () => {
|
||||||
|
const tree = group("all", [rule("author", "contains", ["king"])]);
|
||||||
|
expect(pruneIncomplete(tree, null)).toEqual(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops rules with empty values", () => {
|
||||||
|
const tree = group("all", [
|
||||||
|
rule("author", "contains", [""]),
|
||||||
|
rule("series", "contains", ["x"]),
|
||||||
|
]);
|
||||||
|
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires both values for two-arity operators", () => {
|
||||||
|
const tree = group("all", [rule("published_year", "between", ["2000"])]);
|
||||||
|
expect(pruneIncomplete(tree, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps none-arity rules without values", () => {
|
||||||
|
const tree = group("all", [rule("cover", "has")]);
|
||||||
|
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("cover", "has", [])]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops groups left empty after pruning", () => {
|
||||||
|
const tree = group("all", [
|
||||||
|
group("any", [rule("author", "contains", [""])]),
|
||||||
|
rule("series", "contains", ["x"]),
|
||||||
|
]);
|
||||||
|
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing complete remains", () => {
|
||||||
|
expect(pruneIncomplete(group("all", [rule("author", "contains", [""])]), null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes the rule at the given path", () => {
|
||||||
|
const tree = group("all", [
|
||||||
|
rule("author", "contains", ["a"]),
|
||||||
|
rule("series", "contains", ["b"]),
|
||||||
|
]);
|
||||||
|
expect(pruneIncomplete(tree, [0])).toEqual(group("all", [rule("series", "contains", ["b"])]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the implicit marker", () => {
|
||||||
|
const tree = group("all", [rule("author", "contains", ["a"])], true);
|
||||||
|
expect(pruneIncomplete(tree, null)).toEqual(
|
||||||
|
group("all", [rule("author", "contains", ["a"])], true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deep-clones: pruning does not mutate the input tree", () => {
|
||||||
|
const tree = group("all", [rule("author", "contains", ["a"])]);
|
||||||
|
const pruned = pruneIncomplete(tree, null);
|
||||||
|
expect(pruned).not.toBe(tree);
|
||||||
|
expect(pruned!.children[0]).not.toBe(tree.children[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isRule", () => {
|
||||||
|
it("distinguishes rules from groups", () => {
|
||||||
|
expect(isRule(rule("author", "contains", ["a"]))).toBe(true);
|
||||||
|
expect(isRule(group("all", []))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -345,7 +345,7 @@ export const FLAT_WIRE_NAMES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
|
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
|
||||||
function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
|
export function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
|
||||||
const joined = rule.values.join(",");
|
const joined = rule.values.join(",");
|
||||||
if (!joined) return null;
|
if (!joined) return null;
|
||||||
const flat = FLAT_PARAMS[rule.field];
|
const flat = FLAT_PARAMS[rule.field];
|
||||||
@@ -378,7 +378,7 @@ function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
|
|||||||
|
|
||||||
// isFlatable — the smart-serialization rule: a single ALL group of
|
// isFlatable — the smart-serialization rule: a single ALL group of
|
||||||
// rules, one rule per field, every rule flat-expressible.
|
// rules, one rule per field, every rule flat-expressible.
|
||||||
function isFlatable(tree: FilterGroupState): boolean {
|
export function isFlatable(tree: FilterGroupState): boolean {
|
||||||
if (tree.mode !== "all") return false;
|
if (tree.mode !== "all") return false;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const child of tree.children) {
|
for (const child of tree.children) {
|
||||||
@@ -395,7 +395,7 @@ function isFlatable(tree: FilterGroupState): boolean {
|
|||||||
// groups left empty, and can exclude the rule being edited (suggestion
|
// groups left empty, and can exclude the rule being edited (suggestion
|
||||||
// symmetry). Returns null when nothing complete remains — callers then
|
// symmetry). Returns null when nothing complete remains — callers then
|
||||||
// send NO tree at all (an empty group is a server-side 400).
|
// send NO tree at all (an empty group is a server-side 400).
|
||||||
function pruneIncomplete(
|
export function pruneIncomplete(
|
||||||
group: FilterGroupState,
|
group: FilterGroupState,
|
||||||
excludePath: number[] | null,
|
excludePath: number[] | null,
|
||||||
path: number[] = [],
|
path: number[] = [],
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { htmlToMarkdown, renderNoteHtml, stripNote } from "./markdown";
|
||||||
|
|
||||||
|
// The sanitizer pipeline is the notes feature's XSS defense: markdown-it
|
||||||
|
// with html disabled, then DOMPurify with a restrictive allow-list. These
|
||||||
|
// tests pin the security-relevant behavior — loosening any of them should
|
||||||
|
// require a deliberate decision, never an accident.
|
||||||
|
describe("renderNoteHtml", () => {
|
||||||
|
it("renders basic markdown to html", () => {
|
||||||
|
const out = renderNoteHtml("**bold** and _ital_");
|
||||||
|
expect(out).toContain("<strong>bold</strong>");
|
||||||
|
expect(out).toContain("<em>ital</em>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty source", () => {
|
||||||
|
expect(renderNoteHtml("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips raw script tags (parser-level html disabled)", () => {
|
||||||
|
const out = renderNoteHtml("<script>alert(1)</script>hello");
|
||||||
|
expect(out).not.toContain("<script");
|
||||||
|
expect(out).toContain("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes img/input/style/form tags", () => {
|
||||||
|
const out = renderNoteHtml(
|
||||||
|
'\n\n<form action="/steal"></form>\n\n<style>.x{}</style>\n\n<input type="text">',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("<img");
|
||||||
|
expect(out).not.toContain("<form");
|
||||||
|
expect(out).not.toContain("<style");
|
||||||
|
expect(out).not.toContain("<input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes raw html to inert literal text (html disabled at the parser)", () => {
|
||||||
|
// html:false makes markdown-it ESCAPE raw html — the output string
|
||||||
|
// contains the tags as visible text, which is inert wherever the
|
||||||
|
// compact rows render it.
|
||||||
|
const out = renderNoteHtml('<span onerror="alert(1)">text</span>');
|
||||||
|
expect(out).not.toContain("<span");
|
||||||
|
expect(out).toContain("onerror");
|
||||||
|
expect(out).toContain("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes javascript: links at the markdown layer (no anchor emitted)", () => {
|
||||||
|
// markdown-it's validateLink rejects the URL entirely — the raw syntax
|
||||||
|
// stays as inert literal text and no <a> element ever reaches the
|
||||||
|
// sanitizer (which would then scheme-check it a second time).
|
||||||
|
const out = renderNoteHtml("[click](javascript:alert(1))");
|
||||||
|
expect(out).not.toContain("<a");
|
||||||
|
expect(out).toContain("[click](javascript:alert(1))");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes markdown-syntax images (FORBID_TAGS)", () => {
|
||||||
|
const out = renderNoteHtml("");
|
||||||
|
expect(out).not.toContain("<img");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps safe https links", () => {
|
||||||
|
const out = renderNoteHtml("[site](https://example.com)");
|
||||||
|
expect(out).toContain('href="https://example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders footnotes", () => {
|
||||||
|
const out = renderNoteHtml("note[^1]\n\n[^1]: the footnote");
|
||||||
|
expect(out).toContain("footnote-ref");
|
||||||
|
expect(out).toContain("the footnote");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stripNote", () => {
|
||||||
|
it("extracts plain text from markdown", () => {
|
||||||
|
expect(stripNote("**bold** plain")).toBe("bold plain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses whitespace and trims", () => {
|
||||||
|
expect(stripNote("a\n\n b ")).toBe("a b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces raw html to literal text (safe: html disabled, so tags never become elements)", () => {
|
||||||
|
// markdown-it escapes the tags and DOMPurify leaves escaped entities
|
||||||
|
// alone — the result is inert visible text, not executable markup.
|
||||||
|
expect(stripNote("<script>x</script>safe")).toBe("<script>x</script>safe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty source", () => {
|
||||||
|
expect(stripNote("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("htmlToMarkdown", () => {
|
||||||
|
it("converts emphasis", () => {
|
||||||
|
expect(htmlToMarkdown("<strong>b</strong>")).toBe("**b**");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts atx headings", () => {
|
||||||
|
expect(htmlToMarkdown("<h2>Title</h2>")).toBe("## Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blanks images entirely", () => {
|
||||||
|
expect(htmlToMarkdown('<p>a<img src="x.png">b</p>')).toBe("ab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts tables with heading rows to pipe syntax", () => {
|
||||||
|
const md = htmlToMarkdown(
|
||||||
|
"<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
|
||||||
|
);
|
||||||
|
expect(md).toContain("| A | B |");
|
||||||
|
expect(md).toContain("| 1 | 2 |");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts headingless tables to blank-header pipes (never raw html)", () => {
|
||||||
|
const md = htmlToMarkdown("<table><tr><td>x</td><td>y</td></tr></table>");
|
||||||
|
expect(md).toContain("| x | y |");
|
||||||
|
expect(md).not.toContain("<table");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
|
||||||
|
|
||||||
|
// The checklist is display-only (the server enforces), but it gates the
|
||||||
|
// submit button and mirrors the ACTIVE server rules — these tests pin the
|
||||||
|
// rules matrix, the fetch fallback behavior, and the submit gating.
|
||||||
|
|
||||||
|
function buildDom(): void {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<input id="password" />
|
||||||
|
<input id="confirm-password" />
|
||||||
|
<input id="username" />
|
||||||
|
<input id="email" />
|
||||||
|
<div id="password-requirements"></div>
|
||||||
|
<button id="register-btn">Register</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPassword(value: string): void {
|
||||||
|
(document.getElementById("password") as HTMLInputElement).value = value;
|
||||||
|
(document.getElementById("password") as HTMLInputElement).dispatchEvent(new Event("input"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitButton = (): HTMLButtonElement =>
|
||||||
|
document.getElementById("register-btn") as HTMLButtonElement;
|
||||||
|
|
||||||
|
describe("loadPasswordRules", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts fetched rules", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ min_length: 12, require_upper: false }), { status: 200 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rules = await loadPasswordRules();
|
||||||
|
expect(rules).toEqual({
|
||||||
|
min_length: 12,
|
||||||
|
require_upper: false,
|
||||||
|
require_lower: true,
|
||||||
|
require_number: true,
|
||||||
|
require_special: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults when the endpoint fails", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("nope", { status: 500 })),
|
||||||
|
);
|
||||||
|
const rules = await loadPasswordRules();
|
||||||
|
expect(rules).toEqual({
|
||||||
|
min_length: 8,
|
||||||
|
require_upper: true,
|
||||||
|
require_lower: true,
|
||||||
|
require_number: true,
|
||||||
|
require_special: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults on invalid payloads", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ min_length: -5 }), { status: 200 })),
|
||||||
|
);
|
||||||
|
const rules = await loadPasswordRules();
|
||||||
|
expect(rules.min_length).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initPasswordValidation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Deterministic rules: fetch failure lands on the historical defaults.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("nope", { status: 500 })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// initPasswordValidation debounces through window.setTimeout, which under
|
||||||
|
// jsdom is a distinct timer from the faked global — so these tests wait
|
||||||
|
// out the real 100ms debounce instead of advancing fake clocks.
|
||||||
|
const settleDebounce = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
it("generates one checklist row per active rule plus the match row", async () => {
|
||||||
|
buildDom();
|
||||||
|
await loadPasswordRules();
|
||||||
|
initPasswordValidation({});
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll("#password-requirements li");
|
||||||
|
expect(rows.length).toBe(6); // length, upper, lower, number, special, match
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with the submit button disabled for an empty password", async () => {
|
||||||
|
buildDom();
|
||||||
|
await loadPasswordRules();
|
||||||
|
initPasswordValidation({});
|
||||||
|
|
||||||
|
expect(submitButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables submit when all requirements pass", async () => {
|
||||||
|
buildDom();
|
||||||
|
await loadPasswordRules();
|
||||||
|
initPasswordValidation({});
|
||||||
|
|
||||||
|
setPassword("Abcdef1!");
|
||||||
|
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
|
||||||
|
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
|
||||||
|
new Event("input"),
|
||||||
|
);
|
||||||
|
(document.getElementById("username") as HTMLInputElement).value = "user";
|
||||||
|
(document.getElementById("username") as HTMLInputElement).dispatchEvent(new Event("input"));
|
||||||
|
(document.getElementById("email") as HTMLInputElement).value = "user@example.com";
|
||||||
|
(document.getElementById("email") as HTMLInputElement).dispatchEvent(new Event("input"));
|
||||||
|
await settleDebounce();
|
||||||
|
|
||||||
|
expect(submitButton().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps submit disabled on a confirmation mismatch", async () => {
|
||||||
|
buildDom();
|
||||||
|
await loadPasswordRules();
|
||||||
|
initPasswordValidation({});
|
||||||
|
|
||||||
|
setPassword("Abcdef1!");
|
||||||
|
(document.getElementById("confirm-password") as HTMLInputElement).value = "Different1!";
|
||||||
|
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
|
||||||
|
new Event("input"),
|
||||||
|
);
|
||||||
|
await settleDebounce();
|
||||||
|
|
||||||
|
expect(submitButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require username/email when requireUsernameEmail is false", async () => {
|
||||||
|
buildDom();
|
||||||
|
await loadPasswordRules();
|
||||||
|
initPasswordValidation({ requireUsernameEmail: false });
|
||||||
|
|
||||||
|
setPassword("Abcdef1!");
|
||||||
|
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
|
||||||
|
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
|
||||||
|
new Event("input"),
|
||||||
|
);
|
||||||
|
await settleDebounce();
|
||||||
|
|
||||||
|
expect(submitButton().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,7 @@ function hasNumber(password: string): boolean {
|
|||||||
return /[0-9]/.test(password);
|
return /[0-9]/.test(password);
|
||||||
}
|
}
|
||||||
function hasSpecialChar(password: string): boolean {
|
function hasSpecialChar(password: string): boolean {
|
||||||
return /[!@#$%^&*()_+\-=[]{};':"\\|,.<>?]/.test(password);
|
return /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>?]/.test(password);
|
||||||
}
|
}
|
||||||
function passwordsMatch(password: string, confirm: string): boolean {
|
function passwordsMatch(password: string, confirm: string): boolean {
|
||||||
return password.length > 0 && password === confirm;
|
return password.length > 0 && password === confirm;
|
||||||
|
|||||||
@@ -3993,6 +3993,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// Escape stays live so popovers/drawers can still be dismissed from
|
// Escape stays live so popovers/drawers can still be dismissed from
|
||||||
// the keyboard even mid-note.
|
// the keyboard even mid-note.
|
||||||
const t = event.target as HTMLElement | null;
|
const t = event.target as HTMLElement | null;
|
||||||
|
const isProgressSlider =
|
||||||
|
t instanceof HTMLInputElement && t.type === "range" && t.id.startsWith("progress-slider");
|
||||||
const typing =
|
const typing =
|
||||||
t?.tagName === "INPUT" ||
|
t?.tagName === "INPUT" ||
|
||||||
t?.tagName === "SELECT" ||
|
t?.tagName === "SELECT" ||
|
||||||
@@ -4000,7 +4002,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
!!t?.isContentEditable;
|
!!t?.isContentEditable;
|
||||||
// NOTE: no chrome poke here — keyboard page turns are reading
|
// NOTE: no chrome poke here — keyboard page turns are reading
|
||||||
// actions and must leave the chrome alone (handoff item 7).
|
// actions and must leave the chrome alone (handoff item 7).
|
||||||
if (typing && k !== "Escape") return;
|
if (typing && k !== "Escape") {
|
||||||
|
// A focused progress range swallows arrows as ~1% book-jumps —
|
||||||
|
// with key repeat, holding an arrow crosses the entire book in
|
||||||
|
// seconds and the debounced saves persist every wild landing
|
||||||
|
// (2026-10-21 incident). Arrows are page turns everywhere in the
|
||||||
|
// reader: release the slider and fall through to the turn
|
||||||
|
// handlers. Every other key still belongs to the control.
|
||||||
|
if (!(isProgressSlider && (k === "ArrowLeft" || k === "ArrowRight"))) return;
|
||||||
|
event.preventDefault();
|
||||||
|
t.blur();
|
||||||
|
}
|
||||||
if (k === "ArrowLeft" || k === "h") {
|
if (k === "ArrowLeft" || k === "h") {
|
||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAll,
|
||||||
|
getRefreshToken,
|
||||||
|
getSelectedLibrary,
|
||||||
|
getTheme,
|
||||||
|
getToken,
|
||||||
|
removeRefreshToken,
|
||||||
|
removeToken,
|
||||||
|
setRefreshToken,
|
||||||
|
setSelectedLibrary,
|
||||||
|
setTheme,
|
||||||
|
setToken,
|
||||||
|
} from "./storage";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token storage", () => {
|
||||||
|
it("round-trips and removes the access token", () => {
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
setToken("tok-1");
|
||||||
|
expect(getToken()).toBe("tok-1");
|
||||||
|
removeToken();
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips and removes the refresh token", () => {
|
||||||
|
setRefreshToken("r-1");
|
||||||
|
expect(getRefreshToken()).toBe("r-1");
|
||||||
|
removeRefreshToken();
|
||||||
|
expect(getRefreshToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("theme storage", () => {
|
||||||
|
it("defaults to tokyo-night when unset", () => {
|
||||||
|
expect(getTheme()).toBe("tokyo-night");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a chosen theme", () => {
|
||||||
|
setTheme("dracula");
|
||||||
|
expect(getTheme()).toBe("dracula");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("library selection", () => {
|
||||||
|
it("round-trips a concrete library id", () => {
|
||||||
|
setSelectedLibrary("abc-123");
|
||||||
|
expect(getSelectedLibrary()).toBe("abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the __all__ sentinel for an empty selection and reads it back as empty", () => {
|
||||||
|
setSelectedLibrary("");
|
||||||
|
expect(getSelectedLibrary()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the selection cookie for SSR", () => {
|
||||||
|
setSelectedLibrary("abc-123");
|
||||||
|
expect(document.cookie).toContain("selectedLibrary=abc-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearAll", () => {
|
||||||
|
it("wipes everything", () => {
|
||||||
|
setToken("t");
|
||||||
|
setTheme("dracula");
|
||||||
|
clearAll();
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
expect(getTheme()).toBe("tokyo-night");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getToastConfig, showToast, type ToastType } from "./toast";
|
||||||
|
|
||||||
|
// getToastConfig drives every toast's styling; a missing entry for a
|
||||||
|
// ToastType member crashes showToast at runtime (that bug shipped once —
|
||||||
|
// the 'warning' variant — which is why this suite exists).
|
||||||
|
|
||||||
|
const ALL_TYPES: ToastType[] = ["error", "success", "info", "warning"];
|
||||||
|
|
||||||
|
describe("getToastConfig", () => {
|
||||||
|
it("has a complete config for every ToastType", () => {
|
||||||
|
for (const type of ALL_TYPES) {
|
||||||
|
const config = getToastConfig(type);
|
||||||
|
expect(config, `missing config for "${type}"`).toBeDefined();
|
||||||
|
expect(config.bgClass, `missing bgClass for "${type}"`).toBeTruthy();
|
||||||
|
expect(config.icon, `missing icon for "${type}"`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("styles the warning variant with the /90 opacity used by siblings", () => {
|
||||||
|
expect(getToastConfig("warning").bgClass).toBe("bg-yellow-500/90");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps distinct visuals per type", () => {
|
||||||
|
const classes = ALL_TYPES.map((type) => getToastConfig(type).bgClass);
|
||||||
|
expect(new Set(classes).size).toBe(ALL_TYPES.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showToast", () => {
|
||||||
|
it("creates the container and a styled toast", () => {
|
||||||
|
showToast("Saved", "success");
|
||||||
|
const container = document.getElementById("toast-container");
|
||||||
|
expect(container).not.toBeNull();
|
||||||
|
expect(container?.innerHTML).toContain("Saved");
|
||||||
|
expect(container?.innerHTML).toContain("bg-green-500/90");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the warning variant without throwing", () => {
|
||||||
|
// Regression: 'warning' had no config entry and crashed on render.
|
||||||
|
expect(() => showToast("Careful", "warning")).not.toThrow();
|
||||||
|
expect(document.getElementById("toast-container")?.innerHTML).toContain("bg-yellow-500/90");
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-4
@@ -48,15 +48,23 @@ const getToastConfig = (type: ToastType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create a toast element
|
// Create a toast element
|
||||||
const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
const createToastElement = (
|
||||||
|
message: string,
|
||||||
|
type: ToastType,
|
||||||
|
action?: { label: string; onClick: () => void },
|
||||||
|
): HTMLElement => {
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
const config = getToastConfig(type);
|
const config = getToastConfig(type);
|
||||||
|
|
||||||
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
||||||
|
|
||||||
|
const actionHtml = action
|
||||||
|
? `<button class="toast-action bg-transparent border border-white/40 text-white cursor-pointer text-xs font-semibold px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 hover:bg-white/10 transition-colors">${toastEscapeHtml(action.label)}</button>`
|
||||||
|
: "";
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<span class="text-xl flex-shrink-0">${config.icon}</span>
|
<span class="text-xl flex-shrink-0">${config.icon}</span>
|
||||||
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
|
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
|
||||||
|
${actionHtml}
|
||||||
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
|
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -68,6 +76,16 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
|||||||
closeBtn.onclick = () => removeToast(toast);
|
closeBtn.onclick = () => removeToast(toast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action button: run the handler and dismiss (an action consumes the
|
||||||
|
// toast — leaving it up after acting invites double-fires).
|
||||||
|
const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null;
|
||||||
|
if (actionBtn && action) {
|
||||||
|
actionBtn.onclick = () => {
|
||||||
|
removeToast(toast);
|
||||||
|
action.onClick();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return toast;
|
return toast;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,14 +108,17 @@ const removeToast = (toast: HTMLElement): void => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show toast notification
|
// Show toast notification. `action` (optional): a labeled button for
|
||||||
|
// one-click follow-ups (e.g. the sleep timer's "Resume"); acting
|
||||||
|
// dismisses the toast.
|
||||||
const showToast = (
|
const showToast = (
|
||||||
message: string,
|
message: string,
|
||||||
type: ToastType,
|
type: ToastType,
|
||||||
duration: number = TOAST_DEFAULT_DURATION,
|
duration: number = TOAST_DEFAULT_DURATION,
|
||||||
|
action?: { label: string; onClick: () => void },
|
||||||
): void => {
|
): void => {
|
||||||
const container = createToastContainer();
|
const container = createToastContainer();
|
||||||
const toast = createToastElement(message, type);
|
const toast = createToastElement(message, type, action);
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
animateToastIn(toast);
|
animateToastIn(toast);
|
||||||
|
|
||||||
@@ -262,5 +283,5 @@ const initializeToastSystem = (): void => {
|
|||||||
|
|
||||||
initializeToastSystem();
|
initializeToastSystem();
|
||||||
|
|
||||||
export { showToast };
|
export { showToast, getToastConfig };
|
||||||
export type { ToastType };
|
export type { ToastType };
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
Reference in new issue
Block a user