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.PlaylistRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackOrderRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackRequest; 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.model.api.response.PlaylistView;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.PlaylistService; import dev.kruhlmann.imgfloat.service.PlaylistService;
@@ -139,13 +140,13 @@ public class PlaylistApiController {
// ── Active playlist ─────────────────────────────────────────────────── // ── Active playlist ───────────────────────────────────────────────────
@GetMapping("/active") @GetMapping("/active")
public ResponseEntity<PlaylistView> getActive( public ResponseEntity<ActivePlaylistState> getActive(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken OAuth2AuthenticationToken oauthToken
) { ) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
return playlistService.getActivePlaylist(broadcaster) return playlistService.getActivePlaylistState(broadcaster)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build()); .orElse(ResponseEntity.noContent().build());
} }
@@ -231,4 +232,19 @@ public class PlaylistApiController {
playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId")); playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId"));
return ResponseEntity.ok().build(); 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") @Column(name = "active_playlist_id")
private String activePlaylistId; 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) @Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt; private Instant createdAt;
@@ -142,6 +154,38 @@ public class Channel {
this.activePlaylistId = activePlaylistId; 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() { public Instant getCreatedAt() {
return createdAt; 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.NOT_FOUND;
import static org.springframework.http.HttpStatus.BAD_REQUEST; 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.PlaylistEvent;
import dev.kruhlmann.imgfloat.model.api.response.PlaylistTrackView; import dev.kruhlmann.imgfloat.model.api.response.PlaylistTrackView;
import dev.kruhlmann.imgfloat.model.api.response.PlaylistView; 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.ChannelRepository;
import dev.kruhlmann.imgfloat.repository.PlaylistRepository; import dev.kruhlmann.imgfloat.repository.PlaylistRepository;
import dev.kruhlmann.imgfloat.repository.PlaylistTrackRepository; import dev.kruhlmann.imgfloat.repository.PlaylistTrackRepository;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@@ -83,12 +83,17 @@ public class PlaylistService {
@Transactional @Transactional
public void deletePlaylist(String broadcaster, String playlistId) { public void deletePlaylist(String broadcaster, String playlistId) {
Playlist playlist = requirePlaylist(broadcaster, playlistId); Playlist playlist = requirePlaylist(broadcaster, playlistId);
// Clear active_playlist_id on channel if it pointed to this playlist
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> { channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
if (playlistId.equals(channel.getActivePlaylistId())) { if (playlistId.equals(channel.getActivePlaylistId())) {
channel.setActivePlaylistId(null); 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); playlistTrackRepository.deleteAllByPlaylistId(playlistId);
playlistRepository.delete(playlist); playlistRepository.delete(playlist);
@@ -100,7 +105,7 @@ public class PlaylistService {
@Transactional @Transactional
public PlaylistView addTrack(String broadcaster, String playlistId, String audioAssetId) { public PlaylistView addTrack(String broadcaster, String playlistId, String audioAssetId) {
requirePlaylist(broadcaster, playlistId); requirePlaylist(broadcaster, playlistId);
AudioAsset audio = audioAssetRepository.findById(audioAssetId) audioAssetRepository.findById(audioAssetId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Audio asset not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Audio asset not found"));
int nextOrder = playlistTrackRepository.countByPlaylistId(playlistId); int nextOrder = playlistTrackRepository.countByPlaylistId(playlistId);
PlaylistTrack track = new PlaylistTrack(playlistId, audioAssetId, nextOrder); PlaylistTrack track = new PlaylistTrack(playlistId, audioAssetId, nextOrder);
@@ -121,6 +126,13 @@ public class PlaylistService {
remaining.get(i).setTrackOrder(i); remaining.get(i).setTrackOrder(i);
} }
playlistTrackRepository.saveAll(remaining); 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); return refreshAndPublish(broadcaster, playlistId);
} }
@@ -142,13 +154,24 @@ public class PlaylistService {
// ── Active playlist ─────────────────────────────────────────────────── // ── Active playlist ───────────────────────────────────────────────────
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Optional<PlaylistView> getActivePlaylist(String broadcaster) { public Optional<ActivePlaylistState> getActivePlaylistState(String broadcaster) {
Channel channel = channelRepository.findById(normalize(broadcaster)).orElse(null); Channel channel = channelRepository.findById(normalize(broadcaster)).orElse(null);
if (channel == null || channel.getActivePlaylistId() == null) { if (channel == null || channel.getActivePlaylistId() == null) {
return Optional.empty(); return Optional.empty();
} }
return playlistRepository.findByIdAndBroadcaster(channel.getActivePlaylistId(), normalize(broadcaster)) 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 @Transactional
@@ -162,6 +185,7 @@ public class PlaylistService {
view = toView(playlist, loadTracks(playlistId)); view = toView(playlist, loadTracks(playlistId));
} }
channel.setActivePlaylistId(playlistId); channel.setActivePlaylistId(playlistId);
clearPlaybackState(channel);
channelRepository.save(channel); channelRepository.save(channel);
publish(broadcaster, PlaylistEvent.selected(normalize(broadcaster), view)); publish(broadcaster, PlaylistEvent.selected(normalize(broadcaster), view));
return Optional.ofNullable(view); return Optional.ofNullable(view);
@@ -169,19 +193,36 @@ public class PlaylistService {
// ── Playback commands ───────────────────────────────────────────────── // ── Playback commands ─────────────────────────────────────────────────
@Transactional(readOnly = true) @Transactional
public void commandPlay(String broadcaster, String playlistId, String trackId) { public void commandPlay(String broadcaster, String playlistId, String trackId) {
requirePlaylist(broadcaster, playlistId); 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) { public void commandPause(String broadcaster, String playlistId) {
requirePlaylist(broadcaster, playlistId); requirePlaylist(broadcaster, playlistId);
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
channel.setPlaylistIsPaused(true);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.pause(normalize(broadcaster), playlistId)); publish(broadcaster, PlaylistEvent.pause(normalize(broadcaster), playlistId));
} }
@Transactional(readOnly = true) @Transactional
public void commandNext(String broadcaster, String playlistId, String currentTrackId) { public void commandNext(String broadcaster, String playlistId, String currentTrackId) {
requirePlaylist(broadcaster, playlistId); requirePlaylist(broadcaster, playlistId);
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId); List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
@@ -194,14 +235,25 @@ public class PlaylistService {
} }
} }
if (nextTrackId != null) { 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)); publish(broadcaster, PlaylistEvent.next(normalize(broadcaster), playlistId, nextTrackId));
} else { } else {
// End of playlist channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
clearPlaybackState(channel);
channelRepository.save(channel);
});
publish(broadcaster, PlaylistEvent.ended(normalize(broadcaster), playlistId)); publish(broadcaster, PlaylistEvent.ended(normalize(broadcaster), playlistId));
} }
} }
@Transactional(readOnly = true) @Transactional
public void commandPrev(String broadcaster, String playlistId, String currentTrackId) { public void commandPrev(String broadcaster, String playlistId, String currentTrackId) {
requirePlaylist(broadcaster, playlistId); requirePlaylist(broadcaster, playlistId);
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId); List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
@@ -213,16 +265,48 @@ public class PlaylistService {
break; 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)); publish(broadcaster, PlaylistEvent.prev(normalize(broadcaster), playlistId, prevTrackId));
} }
@Transactional
public void commandTrackEnded(String broadcaster, String playlistId, String finishedTrackId) { public void commandTrackEnded(String broadcaster, String playlistId, String finishedTrackId) {
commandNext(broadcaster, playlistId, 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 ─────────────────────────────────────────────────────────── // ── 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) { private Playlist requirePlaylist(String broadcaster, String playlistId) {
return playlistRepository.findByIdAndBroadcaster(playlistId, normalize(broadcaster)) return playlistRepository.findByIdAndBroadcaster(playlistId, normalize(broadcaster))
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Playlist not found")); .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")); .catch(() => this.showToast("Unable to load overlay assets. Retrying may help.", "error"));
fetch(`/api/channels/${this.broadcaster}/playlists/active`) fetch(`/api/channels/${this.broadcaster}/playlists/active`)
.then((r) => r.ok ? r.json() : null) .then((r) => r.ok ? r.json() : null)
.then((playlist) => { .then((state) => {
if (playlist) { if (!state) return;
this.playlistState.playlistId = playlist.id; this.playlistState.playlistId = state.id;
this.playlistState.playlistName = playlist.name; this.playlistState.playlistName = state.name;
this.playlistState.tracks = Array.isArray(playlist.tracks) ? playlist.tracks : []; this.playlistState.tracks = Array.isArray(state.tracks) ? state.tracks : [];
this.playlistState.trackCount = this.playlistState.tracks.length; this.playlistState.trackCount = this.playlistState.tracks.length;
this.updateScriptWorkerPlaylist(); 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(() => {}); .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.preload = "auto";
el.controls = false; el.controls = false;
this.audioManager.releaseMediaElement && this.audioManager.releaseMediaElement(el); this.audioManager.releaseMediaElement && this.audioManager.releaseMediaElement(el);
// Wire through the channel limiter this.audioManager.ensureAudioController(asset);
this.audioManager.ensureAudioController(asset); // ensures limiter is set up
// Use a fresh Audio element independent of the per-asset loop controller
el.volume = Math.min(1, Math.max(0, asset.audioVolume ?? 1)); el.volume = Math.min(1, Math.max(0, asset.audioVolume ?? 1));
el.playbackRate = Math.max(0.25, (asset.audioSpeed ?? 1) * (asset.audioPitch ?? 1)); el.playbackRate = Math.max(0.25, (asset.audioSpeed ?? 1) * (asset.audioPitch ?? 1));
el.onended = () => { el.onended = () => {
@@ -877,6 +900,61 @@ export class BroadcastRenderer {
el.play().catch(() => {}); 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() { _stopPlaylistAudio() {
if (this.playlistCurrentElement) { if (this.playlistCurrentElement) {
this.playlistCurrentElement.onended = null; this.playlistCurrentElement.onended = null;
@@ -888,15 +966,14 @@ export class BroadcastRenderer {
_onPlaylistTrackEnded() { _onPlaylistTrackEnded() {
if (!this.playlistState.playlistId || !this.playlistState.trackId) return; if (!this.playlistState.playlistId || !this.playlistState.trackId) return;
// POST to server so it emits the appropriate NEXT or ENDED event const xsrf = this._xsrfToken();
const xsrfToken = document.cookie.split("; ").find(c => c.startsWith("XSRF-TOKEN="))?.split("=")[1];
fetch( fetch(
`/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`, `/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(xsrfToken ? { "X-XSRF-TOKEN": decodeURIComponent(xsrfToken) } : {}), ...(xsrf ? { "X-XSRF-TOKEN": xsrf } : {}),
}, },
body: JSON.stringify({ trackId: this.playlistState.trackId }), body: JSON.stringify({ trackId: this.playlistState.trackId }),
} }
+10 -2
View File
@@ -84,8 +84,16 @@
async function loadActivePlaylist() { async function loadActivePlaylist() {
try { try {
const active = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null); const state = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null);
activePlaylistId = active?.id ?? null; if (!state) return;
activePlaylistId = state.id ?? null;
if (state.isPlaying && state.currentTrackId) {
playbackState = {
playing: true,
paused: state.isPaused ?? false,
trackId: state.currentTrackId,
};
}
renderPlaylistList(); renderPlaylistList();
} catch { /* silently ignore */ } } catch { /* silently ignore */ }
} }