feat: persist and restore playlist playback state across refreshes

- V19 migration: add playlist_current_track_id, playlist_is_playing,
  playlist_is_paused, playlist_track_position to channels table
- Channel.java: 4 new fields + getters/setters
- ActivePlaylistState.java: new response DTO with full playback state
- PlaylistService: all command methods now @Transactional and persist state;
  new reportPosition() method; getActivePlaylistState() returns the enriched DTO
- PlaylistApiController: GET /active returns ActivePlaylistState;
  new POST /{playlistId}/position endpoint for position heartbeats
- renderer.js: on reconnect reads isPlaying/isPaused/currentTrackId/position
  and calls _resumeTrack() to seek and optionally pause; 5-second interval
  reports currentTime to /position while playing; _xsrfToken() helper
  extracted and reused across all playlist POSTs
- playlist.js: loadActivePlaylist seeds playbackState from ActivePlaylistState
  so the admin panel shows the correct playing/paused indicator on load
This commit is contained in:
2026-05-01 11:23:06 +02:00
parent 3d53c60c0d
commit f2e5ca1927
7 changed files with 283 additions and 30 deletions
@@ -5,6 +5,7 @@ import dev.kruhlmann.imgfloat.model.api.request.ActivePlaylistRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaylistRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackOrderRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackRequest;
import dev.kruhlmann.imgfloat.model.api.response.ActivePlaylistState;
import dev.kruhlmann.imgfloat.model.api.response.PlaylistView;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.PlaylistService;
@@ -139,13 +140,13 @@ public class PlaylistApiController {
// ── Active playlist ───────────────────────────────────────────────────
@GetMapping("/active")
public ResponseEntity<PlaylistView> getActive(
public ResponseEntity<ActivePlaylistState> getActive(
@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
return playlistService.getActivePlaylist(broadcaster)
return playlistService.getActivePlaylistState(broadcaster)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
@@ -231,4 +232,19 @@ public class PlaylistApiController {
playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId"));
return ResponseEntity.ok().build();
}
@PostMapping("/{playlistId}/position")
public ResponseEntity<Void> reportPosition(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("playlistId") String playlistId,
@RequestBody java.util.Map<String, Object> body,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
String trackId = (String) body.get("trackId");
double position = body.get("position") instanceof Number n ? n.doubleValue() : 0.0;
playlistService.reportPosition(broadcaster, playlistId, trackId, position);
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,20 @@
package dev.kruhlmann.imgfloat.model.api.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/**
* Returned by GET /api/channels/{broadcaster}/playlists/active.
* Extends the basic PlaylistView with persisted playback state so that
* reconnecting clients (broadcast view, admin view) can resume correctly.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ActivePlaylistState(
String id,
String name,
List<PlaylistTrackView> tracks,
String currentTrackId,
boolean isPlaying,
boolean isPaused,
double trackPosition
) {}
@@ -50,6 +50,18 @@ public class Channel {
@Column(name = "active_playlist_id")
private String activePlaylistId;
@Column(name = "playlist_current_track_id")
private String playlistCurrentTrackId;
@Column(name = "playlist_is_playing", nullable = false)
private boolean playlistIsPlaying = false;
@Column(name = "playlist_is_paused", nullable = false)
private boolean playlistIsPaused = false;
@Column(name = "playlist_track_position", nullable = false)
private double playlistTrackPosition = 0.0;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@@ -142,6 +154,38 @@ public class Channel {
this.activePlaylistId = activePlaylistId;
}
public String getPlaylistCurrentTrackId() {
return playlistCurrentTrackId;
}
public void setPlaylistCurrentTrackId(String playlistCurrentTrackId) {
this.playlistCurrentTrackId = playlistCurrentTrackId;
}
public boolean isPlaylistIsPlaying() {
return playlistIsPlaying;
}
public void setPlaylistIsPlaying(boolean playlistIsPlaying) {
this.playlistIsPlaying = playlistIsPlaying;
}
public boolean isPlaylistIsPaused() {
return playlistIsPaused;
}
public void setPlaylistIsPaused(boolean playlistIsPaused) {
this.playlistIsPaused = playlistIsPaused;
}
public double getPlaylistTrackPosition() {
return playlistTrackPosition;
}
public void setPlaylistTrackPosition(double playlistTrackPosition) {
this.playlistTrackPosition = playlistTrackPosition;
}
public Instant getCreatedAt() {
return createdAt;
}
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.service;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import dev.kruhlmann.imgfloat.model.api.response.ActivePlaylistState;
import dev.kruhlmann.imgfloat.model.api.response.PlaylistEvent;
import dev.kruhlmann.imgfloat.model.api.response.PlaylistTrackView;
import dev.kruhlmann.imgfloat.model.api.response.PlaylistView;
@@ -14,7 +15,6 @@ import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import dev.kruhlmann.imgfloat.repository.PlaylistRepository;
import dev.kruhlmann.imgfloat.repository.PlaylistTrackRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -83,12 +83,17 @@ public class PlaylistService {
@Transactional
public void deletePlaylist(String broadcaster, String playlistId) {
Playlist playlist = requirePlaylist(broadcaster, playlistId);
// Clear active_playlist_id on channel if it pointed to this playlist
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
if (playlistId.equals(channel.getActivePlaylistId())) {
channel.setActivePlaylistId(null);
channelRepository.save(channel);
}
// Clear playback state if this playlist was playing
if (playlistId.equals(channel.getActivePlaylistId())
|| (channel.getPlaylistCurrentTrackId() != null
&& channel.isPlaylistIsPlaying())) {
clearPlaybackState(channel);
}
channelRepository.save(channel);
});
playlistTrackRepository.deleteAllByPlaylistId(playlistId);
playlistRepository.delete(playlist);
@@ -100,7 +105,7 @@ public class PlaylistService {
@Transactional
public PlaylistView addTrack(String broadcaster, String playlistId, String audioAssetId) {
requirePlaylist(broadcaster, playlistId);
AudioAsset audio = audioAssetRepository.findById(audioAssetId)
audioAssetRepository.findById(audioAssetId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Audio asset not found"));
int nextOrder = playlistTrackRepository.countByPlaylistId(playlistId);
PlaylistTrack track = new PlaylistTrack(playlistId, audioAssetId, nextOrder);
@@ -121,6 +126,13 @@ public class PlaylistService {
remaining.get(i).setTrackOrder(i);
}
playlistTrackRepository.saveAll(remaining);
// Clear playback state if the removed track was the current one
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
if (trackId.equals(channel.getPlaylistCurrentTrackId())) {
clearPlaybackState(channel);
channelRepository.save(channel);
}
});
return refreshAndPublish(broadcaster, playlistId);
}
@@ -142,13 +154,24 @@ public class PlaylistService {
// ── Active playlist ───────────────────────────────────────────────────
@Transactional(readOnly = true)
public Optional<PlaylistView> getActivePlaylist(String broadcaster) {
public Optional<ActivePlaylistState> getActivePlaylistState(String broadcaster) {
Channel channel = channelRepository.findById(normalize(broadcaster)).orElse(null);
if (channel == null || channel.getActivePlaylistId() == null) {
return Optional.empty();
}
return playlistRepository.findByIdAndBroadcaster(channel.getActivePlaylistId(), normalize(broadcaster))
.map(p -> toView(p, loadTracks(p.getId())));
.map(p -> {
List<PlaylistTrackView> tracks = loadTracks(p.getId());
return new ActivePlaylistState(
p.getId(),
p.getName(),
tracks,
channel.getPlaylistCurrentTrackId(),
channel.isPlaylistIsPlaying(),
channel.isPlaylistIsPaused(),
channel.getPlaylistTrackPosition()
);
});
}
@Transactional
@@ -162,6 +185,7 @@ public class PlaylistService {
view = toView(playlist, loadTracks(playlistId));
}
channel.setActivePlaylistId(playlistId);
clearPlaybackState(channel);
channelRepository.save(channel);
publish(broadcaster, PlaylistEvent.selected(normalize(broadcaster), view));
return Optional.ofNullable(view);
@@ -169,19 +193,36 @@ public class PlaylistService {
// ── Playback commands ─────────────────────────────────────────────────
@Transactional(readOnly = true)
@Transactional
public void commandPlay(String broadcaster, String playlistId, String trackId) {
requirePlaylist(broadcaster, playlistId);
publish(broadcaster, PlaylistEvent.play(normalize(broadcaster), playlistId, trackId));
// If no trackId specified, resolve the first track
String resolvedTrackId = trackId;
if (resolvedTrackId == null) {
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
if (!tracks.isEmpty()) resolvedTrackId = tracks.get(0).getId();
}
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
channel.setPlaylistCurrentTrackId(resolvedTrackId);
channel.setPlaylistIsPlaying(true);
channel.setPlaylistIsPaused(false);
channel.setPlaylistTrackPosition(0.0);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.play(normalize(broadcaster), playlistId, resolvedTrackId));
}
@Transactional(readOnly = true)
@Transactional
public void commandPause(String broadcaster, String playlistId) {
requirePlaylist(broadcaster, playlistId);
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
channel.setPlaylistIsPaused(true);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.pause(normalize(broadcaster), playlistId));
}
@Transactional(readOnly = true)
@Transactional
public void commandNext(String broadcaster, String playlistId, String currentTrackId) {
requirePlaylist(broadcaster, playlistId);
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
@@ -194,14 +235,25 @@ public class PlaylistService {
}
}
if (nextTrackId != null) {
final String resolvedNext = nextTrackId;
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
channel.setPlaylistCurrentTrackId(resolvedNext);
channel.setPlaylistIsPlaying(true);
channel.setPlaylistIsPaused(false);
channel.setPlaylistTrackPosition(0.0);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.next(normalize(broadcaster), playlistId, nextTrackId));
} else {
// End of playlist
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
clearPlaybackState(channel);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.ended(normalize(broadcaster), playlistId));
}
}
@Transactional(readOnly = true)
@Transactional
public void commandPrev(String broadcaster, String playlistId, String currentTrackId) {
requirePlaylist(broadcaster, playlistId);
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
@@ -213,16 +265,48 @@ public class PlaylistService {
break;
}
}
// If null, there is no previous track — the client restarts the current track
// null means restart current — persist the same track, reset position
final String resolvedTrackId = prevTrackId != null ? prevTrackId : currentTrackId;
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
channel.setPlaylistCurrentTrackId(resolvedTrackId);
channel.setPlaylistIsPlaying(true);
channel.setPlaylistIsPaused(false);
channel.setPlaylistTrackPosition(0.0);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.prev(normalize(broadcaster), playlistId, prevTrackId));
}
@Transactional
public void commandTrackEnded(String broadcaster, String playlistId, String finishedTrackId) {
commandNext(broadcaster, playlistId, finishedTrackId);
}
// ── Position reporting ────────────────────────────────────────────────
@Transactional
public void reportPosition(String broadcaster, String playlistId, String trackId, double position) {
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
// Only persist if this is still the active playlist and we're playing
if (playlistId.equals(channel.getActivePlaylistId())
&& channel.isPlaylistIsPlaying()
&& !channel.isPlaylistIsPaused()
&& trackId.equals(channel.getPlaylistCurrentTrackId())) {
channel.setPlaylistTrackPosition(position);
channelRepository.save(channel);
}
});
}
// ── Helpers ───────────────────────────────────────────────────────────
private void clearPlaybackState(Channel channel) {
channel.setPlaylistCurrentTrackId(null);
channel.setPlaylistIsPlaying(false);
channel.setPlaylistIsPaused(false);
channel.setPlaylistTrackPosition(0.0);
}
private Playlist requirePlaylist(String broadcaster, String playlistId) {
return playlistRepository.findByIdAndBroadcaster(playlistId, normalize(broadcaster))
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Playlist not found"));
@@ -0,0 +1,4 @@
ALTER TABLE channels ADD COLUMN playlist_current_track_id TEXT;
ALTER TABLE channels ADD COLUMN playlist_is_playing INTEGER NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN playlist_is_paused INTEGER NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN playlist_track_position REAL NOT NULL DEFAULT 0;
@@ -104,16 +104,41 @@ export class BroadcastRenderer {
.catch(() => this.showToast("Unable to load overlay assets. Retrying may help.", "error"));
fetch(`/api/channels/${this.broadcaster}/playlists/active`)
.then((r) => r.ok ? r.json() : null)
.then((playlist) => {
if (playlist) {
this.playlistState.playlistId = playlist.id;
this.playlistState.playlistName = playlist.name;
this.playlistState.tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
this.playlistState.trackCount = this.playlistState.tracks.length;
this.updateScriptWorkerPlaylist();
.then((state) => {
if (!state) return;
this.playlistState.playlistId = state.id;
this.playlistState.playlistName = state.name;
this.playlistState.tracks = Array.isArray(state.tracks) ? state.tracks : [];
this.playlistState.trackCount = this.playlistState.tracks.length;
if (state.isPlaying && state.currentTrackId) {
this.playlistState.active = true;
this.playlistState.paused = state.isPaused;
this._resumeTrack(state.currentTrackId, state.trackPosition, state.isPaused);
}
this.updateScriptWorkerPlaylist();
})
.catch(() => {});
// Periodically persist playback position so reconnects can resume accurately
this._positionReporterInterval = setInterval(() => {
if (!this.playlistCurrentElement || this.playlistCurrentElement.paused) return;
if (!this.playlistState.playlistId || !this.playlistState.trackId) return;
const xsrf = this._xsrfToken();
fetch(
`/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/position`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...(xsrf ? { "X-XSRF-TOKEN": xsrf } : {}),
},
body: JSON.stringify({
trackId: this.playlistState.trackId,
position: this.playlistCurrentElement.currentTime,
}),
}
).catch(() => {});
}, 5000);
});
}
@@ -862,9 +887,7 @@ export class BroadcastRenderer {
el.preload = "auto";
el.controls = false;
this.audioManager.releaseMediaElement && this.audioManager.releaseMediaElement(el);
// Wire through the channel limiter
this.audioManager.ensureAudioController(asset); // ensures limiter is set up
// Use a fresh Audio element independent of the per-asset loop controller
this.audioManager.ensureAudioController(asset);
el.volume = Math.min(1, Math.max(0, asset.audioVolume ?? 1));
el.playbackRate = Math.max(0.25, (asset.audioSpeed ?? 1) * (asset.audioPitch ?? 1));
el.onended = () => {
@@ -877,6 +900,61 @@ export class BroadcastRenderer {
el.play().catch(() => {});
}
/**
* Resume a track at a specific position, optionally paused.
* Used on reconnect to restore state from the server.
*/
_resumeTrack(trackId, position, startPaused) {
this._stopPlaylistAudio();
if (!trackId) return;
const track = this.playlistState.tracks.find(t => t.id === trackId);
if (!track) return;
const asset = this.state.assets.get(track.audioAssetId);
if (!asset) return;
const idx = this.playlistState.tracks.indexOf(track);
this.playlistState.trackId = trackId;
this.playlistState.trackName = track.assetName;
this.playlistState.trackIndex = idx;
const el = new Audio(asset.url);
el.preload = "auto";
el.controls = false;
this.audioManager.releaseMediaElement && this.audioManager.releaseMediaElement(el);
this.audioManager.ensureAudioController(asset);
el.volume = Math.min(1, Math.max(0, asset.audioVolume ?? 1));
el.playbackRate = Math.max(0.25, (asset.audioSpeed ?? 1) * (asset.audioPitch ?? 1));
el.onended = () => {
if (this.playlistCurrentElement === el) {
this.playlistCurrentElement = null;
this._onPlaylistTrackEnded();
}
};
this.playlistCurrentElement = el;
if (position > 0 || startPaused) {
el.addEventListener("canplay", () => {
if (position > 0) el.currentTime = position;
if (startPaused) {
el.pause();
} else {
el.play().catch(() => {});
}
}, { once: true });
// Load without playing so canplay fires
el.load();
} else {
el.play().catch(() => {});
}
}
_xsrfToken() {
const cookie = document.cookie.split("; ").find(c => c.startsWith("XSRF-TOKEN="));
return cookie ? decodeURIComponent(cookie.split("=")[1]) : null;
}
_stopPlaylistAudio() {
if (this.playlistCurrentElement) {
this.playlistCurrentElement.onended = null;
@@ -888,15 +966,14 @@ export class BroadcastRenderer {
_onPlaylistTrackEnded() {
if (!this.playlistState.playlistId || !this.playlistState.trackId) return;
// POST to server so it emits the appropriate NEXT or ENDED event
const xsrfToken = document.cookie.split("; ").find(c => c.startsWith("XSRF-TOKEN="))?.split("=")[1];
const xsrf = this._xsrfToken();
fetch(
`/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...(xsrfToken ? { "X-XSRF-TOKEN": decodeURIComponent(xsrfToken) } : {}),
...(xsrf ? { "X-XSRF-TOKEN": xsrf } : {}),
},
body: JSON.stringify({ trackId: this.playlistState.trackId }),
}
+10 -2
View File
@@ -84,8 +84,16 @@
async function loadActivePlaylist() {
try {
const active = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null);
activePlaylistId = active?.id ?? null;
const state = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null);
if (!state) return;
activePlaylistId = state.id ?? null;
if (state.isPlaying && state.currentTrackId) {
playbackState = {
playing: true,
paused: state.isPaused ?? false,
trackId: state.currentTrackId,
};
}
renderPlaylistList();
} catch { /* silently ignore */ }
}