Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ it needs to read like release notes rather than a commit log.

## [Unreleased]

### Added

- Per-text audio. Attach a narration to any text and it plays in a transport
below the reader, with a scrubber, ten-second skips, five speeds and lock
screen and Control Centre controls. The playhead is remembered, so the next
launch resumes mid-sentence rather than at the top. Tapping a word pauses
the narration while the word is spoken and resumes it afterwards — the
attached audio is usually the same narration, so hearing both is confusing
— and audio paused deliberately stays paused. Unplugging headphones pauses
rather than playing to the room.
- Libraries from the prototype are migrated on first launch: texts, revealed
words split out of the newline-joined string they were packed into, and
which text was open last. The old file is kept under a new name rather than
deleted.

### Fixed

- Readings for heteronyms now take the surrounding words into account. 重 in
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ framework imports. Details in [`Docs/ARCHITECTURE.md`](Docs/ARCHITECTURE.md).
- [x] **M4** — word segmentation and the line-breaking core
- [x] **M5** — **the macOS reader: first usable version**
- [x] **M6** — **BKRS DSL import**
- [ ] **M7** — audio playback
- [x] **M7** — **audio playback**
- [ ] **M8** — iOS and iPadOS
- [ ] **M9** — accessibility and Russian localization
- [ ] **M10** — 1.0
Expand Down
105 changes: 68 additions & 37 deletions Sources/HanReaderPersistence/LibraryRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,31 @@ public struct LibraryRepository: Sendable {
}
}

/// Stores a playhead without disturbing the reading position.
///
/// The mirror of `save(_:)`, which writes a position and leaves the
/// playhead alone: the transport moves while the page does not, and the
/// page scrolls while the audio does not, so each writes only its own
/// column. A row is created if the reader has only ever played audio and
/// never scrolled — otherwise the first playhead of a session would be
/// dropped silently by an UPDATE that matched nothing.
public func saveAudioTime(
_ time: Double,
for id: TextID,
at date: Date = .now,
) async throws {
try await dbQueue.write { db in
try db.execute(sql: """
INSERT INTO readingPosition
(textId, blockIndex, tokenIndex, characterOffset, audioTime, updatedAt)
VALUES (?, 0, 0, 0, ?, ?)
ON CONFLICT(textId) DO UPDATE SET
audioTime = excluded.audioTime,
updatedAt = excluded.updatedAt
""", arguments: [id.rawValue, time, date])
}
}

/// Clears a stored playhead. Separate from `save(_:)`, which deliberately
/// preserves one it was not given.
public func clearAudioTime(of id: TextID) async throws {
Expand Down Expand Up @@ -257,6 +282,49 @@ public struct LibraryRepository: Sendable {
}
}

// MARK: - Helpers

/// The stored preview. Taken by character so a multi-byte script is not
/// cut mid-character, and collapsed so newlines do not render as gaps.
/// Scans only as far as it needs to.
///
/// The obvious implementation -- trim, split, join, then take a prefix --
/// allocates intermediates proportional to the *whole document* in order
/// to produce 120 characters. For a novel that is tens of megabytes of
/// garbage per import.
static func preview(of content: String, limit: Int = 120) -> String {
var out = ""
out.reserveCapacity(limit)
var pendingSpace = false

for character in content {
if character.isWhitespace {
// Leading whitespace is dropped; interior runs collapse to one
// space, emitted only once something follows.
pendingSpace = !out.isEmpty
continue
}
if pendingSpace {
out.append(" ")
pendingSpace = false
if out.count == limit {
return out
}
}
out.append(character)
if out.count == limit {
return out
}
}
return out
}
}

/// Attached narration.
///
/// Split out so the repository's own body stays readable; these are one
/// coherent concern and they arrived together in M7.
extension LibraryRepository {
// MARK: - Audio

public func attachAudio(_ track: AudioTrack) async throws {
Expand Down Expand Up @@ -295,43 +363,6 @@ public struct LibraryRepository: Sendable {
)
}
}

// MARK: - Helpers

/// The stored preview. Taken by character so a multi-byte script is not
/// cut mid-character, and collapsed so newlines do not render as gaps.
/// Scans only as far as it needs to.
///
/// The obvious implementation -- trim, split, join, then take a prefix --
/// allocates intermediates proportional to the *whole document* in order
/// to produce 120 characters. For a novel that is tens of megabytes of
/// garbage per import.
static func preview(of content: String, limit: Int = 120) -> String {
var out = ""
out.reserveCapacity(limit)
var pendingSpace = false

for character in content {
if character.isWhitespace {
// Leading whitespace is dropped; interior runs collapse to one
// space, emitted only once something follows.
pendingSpace = !out.isEmpty
continue
}
if pendingSpace {
out.append(" ")
pendingSpace = false
if out.count == limit {
return out
}
}
out.append(character)
if out.count == limit {
return out
}
}
return out
}
}

/// Things the library can refuse to do.
Expand Down
Loading
Loading