Skip to content

A finished book reopens at the end, and Play starts it over - #114

Merged
StaticHumStudio merged 4 commits into
masterfrom
feat/220h-finished-from-end
Nov 5, 2026
Merged

StaticHumStudio merged 4 commits into
masterfrom
feat/220h-finished-from-end

Conversation

@StaticHumStudio

Copy link
Copy Markdown
Owner

What changes for the listener

A finished book reopens at the end. Before, coming back to a finished book started it over at 0:00 and played. Now it opens paused at the end (scrubber full, last chapter selected), with its finished flag, progress and listening time untouched. Press Play to hear it from the top. Drag the scrubber back, tap a chapter or skip back to play from there. Skip forward and next chapter do nothing at the end. A book stopped within its last second counts as finished, same as one played out.

Play from anywhere (app, mini player, both widgets, notification, headset, Android Auto) starts a parked book over in one tap. Book Detail's Continue opens it parked, which is the "come back to it" case.

A sleep timer that ends while you're paused leaves your place alone. It used to rewind 15 seconds even on a paused book, which on a finished book would quietly mark it unfinished.

How it works

Preparing the player at the end of its audio fires an immediate end and re-finishes the book (why ee82a42 reset finished books to 0:00). So the player sits paused at 0:00 while the screen shows the end ("parked"). Every play path goes through startPlaybackWork, which clears parked and drops the shown position to 0:00 before any poll, save or sync runs. A parked book writes nothing on stop or switch. Finished is judged against the shorter of the server length and the player's track total.

Known behavior

  • Android Auto sends Play on its own after you pick a book, so in the car a finished book starts over at once.
  • Multi file books with no chapters show 0:00 of the first file in the notification while parked.

Evidence

  • JVM: 2,040 tests, 0 failures. Changed expectations are marked "Jeff decided 2026-11-02: finished books reopen from the end".
  • Opus review, three rounds. Round one found widget Play doing nothing on a finished book and the sleep timer un-finishing a parked book, both fixed. Later rounds clean.
  • Emulator against a mock server, 11 of 11 scenarios pass at 4986825 (reopen parked, Play restarts, resume after restart, seek back three ways, skip forward ignored, last second stop, force stop and relaunch, notification Play, widget Play, sleep timer, switch away). Master shown starting over and writing unfinished. The final commit 33bbde8 (buffering excluded from the timer rewind, keep a saved finish) is unit tested and reviewed, and is on Jeff's phone for hands on testing.

Hold merge: Jeff is testing this build on his phone first.

🤖 Generated with Claude Code

https://claude.ai/code/session_014XfNSbF1fe9aiDwGMCNLmj

StaticHumStudio and others added 3 commits November 2, 2026 18:38
A finished book used to start over at 0:00 the moment you opened it again.
That reset came in with v0.95 so the player would not hit its end and
finish the book a second time on load. Now a finished book, or one stopped
in its last second, reopens parked at its end and paused. The scrubber,
the chapter list, the mini player, the notification and Android Auto all
show the end of the last chapter.

Only the screen shows the end. The player waits paused at 0:00, so it can
never reach its end on load, fire another finish, add listening time or
send progress to the server. That also makes Play start the book over from
anywhere: the app, the mini player, the widget, a headset or the car. The
restart clears the finished flag through the usual position saves, as a
restart always has.

A seek back from the end (scrubber, chapter tap, skip back) resumes the
book there and plays, the way a seek on a book that just finished does,
and its save marks the book unfinished by the usual rule. Skip forward or
next chapter from the end leaves it parked. The load now saves a parked
book as still finished instead of unfinished, and a load with no server
session open no longer counts the missing session as a 0:00 position that
hid the book's end.

A restarted book still resumes its saved spot (#106), and an end position
held in memory still never beats a saved mid book one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbF1fe9aiDwGMCNLmj
… parked book alone

A Play tap on either widget for a book that was not loaded used to just
load it. A finished book now loads parked at its end, so that tap played
nothing. Widget Play, like the mini player's reload, now plays a book that
loaded parked, which starts it over from 0:00.

The sleep timer rewinds when it runs out. It did that even on a paused
book, and on a parked book the rewind resumed it and saved it as
unfinished 15 seconds before the end. It now rewinds only a book that is
playing.

A controller seek that moves a parked book off its end now re-arms its
stop write, a failed load clears the parked state, and a load judges the
end against the shorter of the server's length and the player's tracks, so
a short track total cannot prepare the player at its end and finish the
book again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbF1fe9aiDwGMCNLmj
…nish

A parked book that just got Play on a stream is still parked while it
buffers. A sleep timer that ran out in that moment rewound it, which
resumed it and saved it as unfinished 15 seconds before the end. The timer
now rewinds only a book that is playing.

The load judges the end against the server's length before the tracks are
known. A book whose tracks run short and that finished at their end parked
correctly but was saved unfinished. Saving the same position as the saved
row now keeps that row's finished flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbF1fe9aiDwGMCNLmj
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T00:44:00.303682Z d34d13c Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33bbde838c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

val handler = seekHandler
// Parked, the player sits on its first item, so this position is
// also the book's.
if (parkedAtEnd && handler != null) handler(positionMs) else super.seekTo(positionMs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply parked-seek protection to the indexed seek overload

For a parked book without chapters, a controller calling seekTo(mediaItemIndex, positionMs) still goes directly to the underlying player: the indexed overload only delegates here when currentChapter != null. Seeking to the end through that overload therefore bypasses parkedSeek, clears the parked state in onSeekApplied, and can trigger STATE_ENDED and another completion write. Route indexed seeks through the parked-book guard too, translating track-relative positions when necessary; protecting only the single-argument overload leaves this controller path unprotected.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d34d13c, with tests.

if (parkedAtEnd) {
// Skip forward or next chapter from the end stays parked, so the
// player never seeks into its end and finishes the book again.
if (parkedSeek(position, _duration.value) == ParkedSeek.STAY_PARKED) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the same end threshold for parking and parked seeks

When the track total exceeds the server duration by more than one second, loading judges completion against bookEndLength, but this guard uses _duration, which publishLoadedBook sets to the track total. If the final chapter ends at the server duration, the notification/Android Auto Next action passes that chapter end through ChapterAwareForwardingPlayer.seekToNext; this guard treats it as an earlier position, un-parks the book, and starts playing the remaining tail instead of doing nothing. Use the same effective end length here as during placement so a target already considered finished cannot resume a parked book.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d34d13c, with tests.

A controller can seek by track and position. For a parked book without
chapters that seek went straight to the player, which could land in the
last track's end and finish the book again. It now goes through the book's
own seek, with the track position turned into a book position, so the
parked rules apply.

A parked book judged its seeks against the player's track total, while
parking judged the end against the shorter of that and the server's
length. With tracks more than a second longer than the server says, Next
to the last chapter's end counted as an earlier spot, so the book left its
end and played the tail. Seeks now use the same end the book parked at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSbF1fe9aiDwGMCNLmj
@StaticHumStudio

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: d34d13cda5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@StaticHumStudio
StaticHumStudio merged commit 4127bde into master Nov 5, 2026
1 check passed
StaticHumStudio added a commit that referenced this pull request Nov 5, 2026
After rebasing onto #114: a book parked at its end has the player at 0:00,
so Previous reads the shown end position there instead of the player.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg
StaticHumStudio added a commit that referenced this pull request Nov 5, 2026
… start (#115)

* Previous Chapter restarts the chapter you're in, unless you're at its start

The player screen's Previous Chapter button always jumped to the chapter
before. It now restarts the current chapter when you are more than 3 seconds
in, and only goes back one inside that window. It also stays enabled in
chapter 1 so you can restart it.

The rule lives in one place (previousChapterTarget) and the MediaSession
(Android Auto, notification, lock screen) uses it too, so the two cannot
drift apart again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg

* Previous reads the live position, and every Previous runs one code path

Codex review: the button read the 500 ms position poll, which freezes on
pause, so right at the 3 second line it could pick the wrong chapter. It
now reads the player directly and works out the chapter from that.

The notification, media keys and Android Auto now call the same
PlaybackManager method as the button (books without chapters keep the
plain player Previous).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg

* Previous keeps the retained position when the player is empty

Codex review: after a failed load of another book the player has no media
items and reads 0, which would send the shown book to chapter 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg

* A parked finished book's Previous restarts its last chapter

After rebasing onto #114: a book parked at its end has the player at 0:00,
so Previous reads the shown end position there instead of the player.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XfNSe18pmZggK4TswCohg

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant