From f2e5ca1927d97592dec11abd851e3c29930c477f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Fri, 1 May 2026 11:23:06 +0200 Subject: [PATCH] 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 --- .../controller/PlaylistApiController.java | 20 +++- .../api/response/ActivePlaylistState.java | 20 ++++ .../imgfloat/model/db/imgfloat/Channel.java | 44 +++++++ .../imgfloat/service/PlaylistService.java | 110 +++++++++++++++--- .../V19__playlist_playback_state.sql | 4 + .../resources/static/js/broadcast/renderer.js | 103 +++++++++++++--- src/main/resources/static/js/playlist.js | 12 +- 7 files changed, 283 insertions(+), 30 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/api/response/ActivePlaylistState.java create mode 100644 src/main/resources/db/migration/V19__playlist_playback_state.sql diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java index 502ad88..31a1172 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java @@ -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 getActive( + public ResponseEntity 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 reportPosition( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("playlistId") String playlistId, + @RequestBody java.util.Map 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(); + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/ActivePlaylistState.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/ActivePlaylistState.java new file mode 100644 index 0000000..60e4bc1 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/ActivePlaylistState.java @@ -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 tracks, + String currentTrackId, + boolean isPlaying, + boolean isPaused, + double trackPosition +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java index c25c795..65ef0f1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java @@ -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; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java b/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java index 8abceb2..7ddf839 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java @@ -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 getActivePlaylist(String broadcaster) { + public Optional 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 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 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 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 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")); diff --git a/src/main/resources/db/migration/V19__playlist_playback_state.sql b/src/main/resources/db/migration/V19__playlist_playback_state.sql new file mode 100644 index 0000000..ac42704 --- /dev/null +++ b/src/main/resources/db/migration/V19__playlist_playback_state.sql @@ -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; diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index d910cc6..9d43570 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -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 }), } diff --git a/src/main/resources/static/js/playlist.js b/src/main/resources/static/js/playlist.js index b084640..09a00d1 100644 --- a/src/main/resources/static/js/playlist.js +++ b/src/main/resources/static/js/playlist.js @@ -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 */ } }