mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Fix audio assets
This commit is contained in:
@@ -3,6 +3,7 @@ package com.imgfloat.app.controller;
|
|||||||
import com.imgfloat.app.model.AdminRequest;
|
import com.imgfloat.app.model.AdminRequest;
|
||||||
import com.imgfloat.app.model.AssetView;
|
import com.imgfloat.app.model.AssetView;
|
||||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||||
|
import com.imgfloat.app.model.PlaybackRequest;
|
||||||
import com.imgfloat.app.model.TransformRequest;
|
import com.imgfloat.app.model.TransformRequest;
|
||||||
import com.imgfloat.app.model.TwitchUserProfile;
|
import com.imgfloat.app.model.TwitchUserProfile;
|
||||||
import com.imgfloat.app.model.VisibilityRequest;
|
import com.imgfloat.app.model.VisibilityRequest;
|
||||||
@@ -175,6 +176,18 @@ public class ChannelApiController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assets/{assetId}/play")
|
||||||
|
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster,
|
||||||
|
@PathVariable("assetId") String assetId,
|
||||||
|
@RequestBody(required = false) PlaybackRequest request,
|
||||||
|
OAuth2AuthenticationToken authentication) {
|
||||||
|
String login = TwitchUser.from(authentication).login();
|
||||||
|
ensureAuthorized(broadcaster, login);
|
||||||
|
return channelDirectoryService.triggerPlayback(broadcaster, assetId, request)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/assets/{assetId}/visibility")
|
@PutMapping("/assets/{assetId}/visibility")
|
||||||
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public class AssetEvent {
|
|||||||
CREATED,
|
CREATED,
|
||||||
UPDATED,
|
UPDATED,
|
||||||
VISIBILITY,
|
VISIBILITY,
|
||||||
|
PLAY,
|
||||||
DELETED
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ public class AssetEvent {
|
|||||||
private String channel;
|
private String channel;
|
||||||
private AssetView payload;
|
private AssetView payload;
|
||||||
private String assetId;
|
private String assetId;
|
||||||
|
private Boolean play;
|
||||||
|
|
||||||
public static AssetEvent created(String channel, AssetView asset) {
|
public static AssetEvent created(String channel, AssetView asset) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
@@ -22,6 +24,16 @@ public class AssetEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AssetEvent play(String channel, AssetView asset, boolean play) {
|
||||||
|
AssetEvent event = new AssetEvent();
|
||||||
|
event.type = Type.PLAY;
|
||||||
|
event.channel = channel;
|
||||||
|
event.payload = asset;
|
||||||
|
event.assetId = asset.id();
|
||||||
|
event.play = play;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
public static AssetEvent updated(String channel, AssetView asset) {
|
public static AssetEvent updated(String channel, AssetView asset) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
event.type = Type.UPDATED;
|
event.type = Type.UPDATED;
|
||||||
@@ -63,4 +75,8 @@ public class AssetEvent {
|
|||||||
public String getAssetId() {
|
public String getAssetId() {
|
||||||
return assetId;
|
return assetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getPlay() {
|
||||||
|
return play;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/main/java/com/imgfloat/app/model/PlaybackRequest.java
Normal file
13
src/main/java/com/imgfloat/app/model/PlaybackRequest.java
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.imgfloat.app.model;
|
||||||
|
|
||||||
|
public class PlaybackRequest {
|
||||||
|
private Boolean play;
|
||||||
|
|
||||||
|
public Boolean getPlay() {
|
||||||
|
return play == null ? Boolean.TRUE : play;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlay(Boolean play) {
|
||||||
|
this.play = play;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.imgfloat.app.model.AssetEvent;
|
|||||||
import com.imgfloat.app.model.Channel;
|
import com.imgfloat.app.model.Channel;
|
||||||
import com.imgfloat.app.model.AssetView;
|
import com.imgfloat.app.model.AssetView;
|
||||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||||
|
import com.imgfloat.app.model.PlaybackRequest;
|
||||||
import com.imgfloat.app.model.TransformRequest;
|
import com.imgfloat.app.model.TransformRequest;
|
||||||
import com.imgfloat.app.model.VisibilityRequest;
|
import com.imgfloat.app.model.VisibilityRequest;
|
||||||
import com.imgfloat.app.repository.AssetRepository;
|
import com.imgfloat.app.repository.AssetRepository;
|
||||||
@@ -187,6 +188,18 @@ public class ChannelDirectoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest request) {
|
||||||
|
String normalized = normalize(broadcaster);
|
||||||
|
return assetRepository.findById(assetId)
|
||||||
|
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||||
|
.map(asset -> {
|
||||||
|
AssetView view = AssetView.from(normalized, asset);
|
||||||
|
boolean shouldPlay = request == null || request.getPlay();
|
||||||
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, shouldPlay));
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
|
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
|
||||||
String normalized = normalize(broadcaster);
|
String normalized = normalize(broadcaster);
|
||||||
return assetRepository.findById(assetId)
|
return assetRepository.findById(assetId)
|
||||||
|
|||||||
@@ -834,6 +834,14 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const renderStates = new Map();
|
|||||||
const animatedCache = new Map();
|
const animatedCache = new Map();
|
||||||
const audioControllers = new Map();
|
const audioControllers = new Map();
|
||||||
const pendingAudioUnlock = new Set();
|
const pendingAudioUnlock = new Set();
|
||||||
|
const loopPlaybackState = new Map();
|
||||||
let drawPending = false;
|
let drawPending = false;
|
||||||
let zOrderDirty = true;
|
let zOrderDirty = true;
|
||||||
let zOrderCache = [];
|
let zOrderCache = [];
|
||||||
@@ -43,7 +44,6 @@ const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
|||||||
const selectedAssetBadges = document.getElementById('selected-asset-badges');
|
const selectedAssetBadges = document.getElementById('selected-asset-badges');
|
||||||
const selectedVisibilityBtn = document.getElementById('selected-asset-visibility');
|
const selectedVisibilityBtn = document.getElementById('selected-asset-visibility');
|
||||||
const selectedDeleteBtn = document.getElementById('selected-asset-delete');
|
const selectedDeleteBtn = document.getElementById('selected-asset-delete');
|
||||||
const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#1f2937" rx="8"/><g fill="#fbbf24" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#fef3c7"/><rect x="45" y="23" width="110" height="5" fill="#fef3c7"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>');
|
|
||||||
const aspectLockState = new Map();
|
const aspectLockState = new Map();
|
||||||
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
|
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
|
||||||
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
||||||
@@ -127,10 +127,10 @@ if (heightInput) heightInput.addEventListener('change', () => commitSizeChange()
|
|||||||
if (speedInput) speedInput.addEventListener('input', updatePlaybackFromInputs);
|
if (speedInput) speedInput.addEventListener('input', updatePlaybackFromInputs);
|
||||||
if (muteInput) muteInput.addEventListener('change', updateMuteFromInput);
|
if (muteInput) muteInput.addEventListener('change', updateMuteFromInput);
|
||||||
if (audioLoopInput) audioLoopInput.addEventListener('change', updateAudioSettingsFromInputs);
|
if (audioLoopInput) audioLoopInput.addEventListener('change', updateAudioSettingsFromInputs);
|
||||||
if (audioDelayInput) audioDelayInput.addEventListener('input', updateAudioSettingsFromInputs);
|
if (audioDelayInput) audioDelayInput.addEventListener('change', updateAudioSettingsFromInputs);
|
||||||
if (audioSpeedInput) audioSpeedInput.addEventListener('input', updateAudioSettingsFromInputs);
|
if (audioSpeedInput) audioSpeedInput.addEventListener('change', updateAudioSettingsFromInputs);
|
||||||
if (audioPitchInput) audioPitchInput.addEventListener('input', updateAudioSettingsFromInputs);
|
if (audioPitchInput) audioPitchInput.addEventListener('change', updateAudioSettingsFromInputs);
|
||||||
if (audioVolumeInput) audioVolumeInput.addEventListener('input', updateAudioSettingsFromInputs);
|
if (audioVolumeInput) audioVolumeInput.addEventListener('change', updateAudioSettingsFromInputs);
|
||||||
if (selectedVisibilityBtn) {
|
if (selectedVisibilityBtn) {
|
||||||
selectedVisibilityBtn.addEventListener('click', () => {
|
selectedVisibilityBtn.addEventListener('click', () => {
|
||||||
const asset = getSelectedAsset();
|
const asset = getSelectedAsset();
|
||||||
@@ -258,6 +258,7 @@ function handleEvent(event) {
|
|||||||
zOrderDirty = true;
|
zOrderDirty = true;
|
||||||
clearMedia(event.assetId);
|
clearMedia(event.assetId);
|
||||||
renderStates.delete(event.assetId);
|
renderStates.delete(event.assetId);
|
||||||
|
loopPlaybackState.delete(event.assetId);
|
||||||
if (selectedAssetId === event.assetId) {
|
if (selectedAssetId === event.assetId) {
|
||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
}
|
}
|
||||||
@@ -265,11 +266,12 @@ function handleEvent(event) {
|
|||||||
storeAsset(event.payload);
|
storeAsset(event.payload);
|
||||||
if (!event.payload.hidden) {
|
if (!event.payload.hidden) {
|
||||||
ensureMedia(event.payload);
|
ensureMedia(event.payload);
|
||||||
if (isAudioAsset(event.payload) && event.type === 'VISIBILITY') {
|
if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) {
|
||||||
playAudioFromCanvas(event.payload, true);
|
loopPlaybackState.set(event.payload.id, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearMedia(event.payload.id);
|
clearMedia(event.payload.id);
|
||||||
|
loopPlaybackState.delete(event.payload.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drawAndList();
|
drawAndList();
|
||||||
@@ -325,12 +327,15 @@ function drawAsset(asset) {
|
|||||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
const media = ensureMedia(asset);
|
|
||||||
const drawSource = media?.isAnimated ? media.bitmap : media;
|
|
||||||
const ready = isAudioAsset(asset) || isDrawable(media);
|
|
||||||
if (isAudioAsset(asset)) {
|
if (isAudioAsset(asset)) {
|
||||||
autoStartAudio(asset);
|
autoStartAudio(asset);
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const media = ensureMedia(asset);
|
||||||
|
const drawSource = media?.isAnimated ? media.bitmap : media;
|
||||||
|
const ready = isDrawable(media);
|
||||||
if (ready && drawSource) {
|
if (ready && drawSource) {
|
||||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||||
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||||
@@ -350,9 +355,6 @@ function drawAsset(asset) {
|
|||||||
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
||||||
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
||||||
ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||||
if (isAudioAsset(asset)) {
|
|
||||||
drawAudioIndicators(asset, halfWidth, halfHeight);
|
|
||||||
}
|
|
||||||
if (asset.id === selectedAssetId) {
|
if (asset.id === selectedAssetId) {
|
||||||
drawSelectionOverlay(renderState);
|
drawSelectionOverlay(renderState);
|
||||||
}
|
}
|
||||||
@@ -416,59 +418,6 @@ function drawHandle(x, y, isRotation) {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAudioIndicators(asset, halfWidth, halfHeight) {
|
|
||||||
const controller = audioControllers.get(asset.id);
|
|
||||||
const isPlaying = controller && !controller.element.paused && !controller.element.ended;
|
|
||||||
const hasDelay = !!(controller && controller.delayTimeout);
|
|
||||||
if (!isPlaying && !hasDelay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const indicatorSize = 18;
|
|
||||||
const padding = 10;
|
|
||||||
let x = -halfWidth + padding + indicatorSize / 2;
|
|
||||||
const y = -halfHeight + padding + indicatorSize / 2;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
if (isPlaying) {
|
|
||||||
ctx.fillStyle = 'rgba(52, 211, 153, 0.9)';
|
|
||||||
ctx.strokeStyle = '#0f172a';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.fillStyle = '#0f172a';
|
|
||||||
ctx.beginPath();
|
|
||||||
const radius = indicatorSize * 0.22;
|
|
||||||
ctx.moveTo(x - radius, y - radius * 1.1);
|
|
||||||
ctx.lineTo(x + radius * 1.2, y);
|
|
||||||
ctx.lineTo(x - radius, y + radius * 1.1);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
x += indicatorSize + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDelay) {
|
|
||||||
ctx.fillStyle = 'rgba(251, 191, 36, 0.9)';
|
|
||||||
ctx.strokeStyle = '#0f172a';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#0f172a';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x, y - indicatorSize * 0.22);
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x + indicatorSize * 0.22, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHandlePositions(asset) {
|
function getHandlePositions(asset) {
|
||||||
return [
|
return [
|
||||||
{ x: 0, y: 0, type: 'nw' },
|
{ x: 0, y: 0, type: 'nw' },
|
||||||
@@ -759,29 +708,11 @@ function stopAudio(assetId) {
|
|||||||
controller.delayMs = controller.baseDelayMs;
|
controller.delayMs = controller.baseDelayMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAudioFromCanvas(asset, resetDelay = false) {
|
|
||||||
const controller = ensureAudioController(asset);
|
|
||||||
if (controller.delayTimeout) {
|
|
||||||
clearTimeout(controller.delayTimeout);
|
|
||||||
controller.delayTimeout = null;
|
|
||||||
}
|
|
||||||
controller.element.currentTime = 0;
|
|
||||||
controller.delayMs = resetDelay ? 0 : controller.baseDelayMs;
|
|
||||||
safePlay(controller);
|
|
||||||
controller.delayMs = controller.baseDelayMs;
|
|
||||||
requestDraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoStartAudio(asset) {
|
function autoStartAudio(asset) {
|
||||||
if (!isAudioAsset(asset) || asset.hidden) {
|
if (!isAudioAsset(asset) || asset.hidden) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = ensureAudioController(asset);
|
ensureAudioController(asset);
|
||||||
if (controller.loopEnabled && controller.element.paused && !controller.delayTimeout) {
|
|
||||||
controller.delayTimeout = setTimeout(() => {
|
|
||||||
safePlay(controller);
|
|
||||||
}, controller.delayMs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMedia(asset) {
|
function ensureMedia(asset) {
|
||||||
@@ -793,10 +724,8 @@ function ensureMedia(asset) {
|
|||||||
|
|
||||||
if (isAudioAsset(asset)) {
|
if (isAudioAsset(asset)) {
|
||||||
ensureAudioController(asset);
|
ensureAudioController(asset);
|
||||||
const placeholder = new Image();
|
mediaCache.delete(asset.id);
|
||||||
placeholder.src = audioPlaceholder;
|
return null;
|
||||||
mediaCache.set(asset.id, placeholder);
|
|
||||||
return placeholder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGifAsset(asset) && 'ImageDecoder' in window) {
|
if (isGifAsset(asset) && 'ImageDecoder' in window) {
|
||||||
@@ -821,7 +750,7 @@ function ensureMedia(asset) {
|
|||||||
if (playback === 0) {
|
if (playback === 0) {
|
||||||
element.pause();
|
element.pause();
|
||||||
} else {
|
} else {
|
||||||
element.play().catch(() => {});
|
element.play().catch(() => { });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
element.onload = requestDraw;
|
element.onload = requestDraw;
|
||||||
@@ -924,7 +853,7 @@ function applyMediaSettings(element, asset) {
|
|||||||
if (nextSpeed === 0) {
|
if (nextSpeed === 0) {
|
||||||
element.pause();
|
element.pause();
|
||||||
} else if (element.paused) {
|
} else if (element.paused) {
|
||||||
element.play().catch(() => {});
|
element.play().catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,6 +935,31 @@ function renderAssetList() {
|
|||||||
updateVisibility(asset, !asset.hidden);
|
updateVisibility(asset, !asset.hidden);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAudioAsset(asset)) {
|
||||||
|
const playBtn = document.createElement('button');
|
||||||
|
playBtn.type = 'button';
|
||||||
|
playBtn.className = 'ghost icon-button';
|
||||||
|
const isLooping = !!asset.audioLoop;
|
||||||
|
const isPlayingLoop = getLoopPlaybackState(asset);
|
||||||
|
updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop);
|
||||||
|
playBtn.title = isLooping
|
||||||
|
? (isPlayingLoop ? 'Pause looping audio' : 'Play looping audio')
|
||||||
|
: 'Play audio';
|
||||||
|
playBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const nextPlay = isLooping
|
||||||
|
? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset))
|
||||||
|
: true;
|
||||||
|
if (isLooping) {
|
||||||
|
loopPlaybackState.set(asset.id, nextPlay);
|
||||||
|
updatePlayButtonIcon(playBtn, true, nextPlay);
|
||||||
|
playBtn.title = nextPlay ? 'Pause looping audio' : 'Play looping audio';
|
||||||
|
}
|
||||||
|
triggerAudioPlayback(asset, nextPlay);
|
||||||
|
});
|
||||||
|
actions.appendChild(playBtn);
|
||||||
|
}
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.type = 'button';
|
deleteBtn.type = 'button';
|
||||||
deleteBtn.className = 'ghost danger icon-button';
|
deleteBtn.className = 'ghost danger icon-button';
|
||||||
@@ -1043,14 +997,29 @@ function createBadge(label, extraClass = '') {
|
|||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLoopPlaybackState(asset) {
|
||||||
|
if (!isAudioAsset(asset) || !asset.audioLoop) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (loopPlaybackState.has(asset.id)) {
|
||||||
|
return loopPlaybackState.get(asset.id);
|
||||||
|
}
|
||||||
|
const isVisible = asset.hidden === false || asset.hidden === undefined;
|
||||||
|
loopPlaybackState.set(asset.id, isVisible);
|
||||||
|
return isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayButtonIcon(button, isLooping, isPlayingLoop) {
|
||||||
|
const icon = isLooping ? (isPlayingLoop ? 'fa-pause' : 'fa-play') : 'fa-play';
|
||||||
|
button.innerHTML = `<i class="fa-solid ${icon}"></i>`;
|
||||||
|
}
|
||||||
|
|
||||||
function createPreviewElement(asset) {
|
function createPreviewElement(asset) {
|
||||||
if (isAudioAsset(asset)) {
|
if (isAudioAsset(asset)) {
|
||||||
const audio = document.createElement('audio');
|
const icon = document.createElement('div');
|
||||||
audio.className = 'asset-preview audio-preview';
|
icon.className = 'asset-preview audio-icon';
|
||||||
audio.src = asset.url;
|
icon.innerHTML = '<i class="fa-solid fa-music" aria-hidden="true"></i>';
|
||||||
audio.controls = true;
|
return icon;
|
||||||
audio.preload = 'metadata';
|
|
||||||
return audio;
|
|
||||||
}
|
}
|
||||||
if (isVideoAsset(asset)) {
|
if (isVideoAsset(asset)) {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
@@ -1060,7 +1029,7 @@ function createPreviewElement(asset) {
|
|||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.autoplay = true;
|
video.autoplay = true;
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => { });
|
||||||
return video;
|
return video;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1385,6 +1354,7 @@ function updateVisibility(asset, hidden) {
|
|||||||
}).then((updated) => {
|
}).then((updated) => {
|
||||||
storeAsset(updated);
|
storeAsset(updated);
|
||||||
if (updated.hidden) {
|
if (updated.hidden) {
|
||||||
|
loopPlaybackState.set(updated.id, false);
|
||||||
stopAudio(updated.id);
|
stopAudio(updated.id);
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast('Asset hidden from broadcast.', 'info');
|
showToast('Asset hidden from broadcast.', 'info');
|
||||||
@@ -1406,6 +1376,19 @@ function updateVisibility(asset, hidden) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerAudioPlayback(asset, shouldPlay = true) {
|
||||||
|
if (!asset) return Promise.resolve();
|
||||||
|
return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ play: shouldPlay })
|
||||||
|
}).then((r) => r.json()).then((updated) => {
|
||||||
|
storeAsset(updated);
|
||||||
|
updateRenderState(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deleteAsset(asset) {
|
function deleteAsset(asset) {
|
||||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' })
|
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -1506,7 +1489,7 @@ function isPointOnAsset(asset, x, y) {
|
|||||||
|
|
||||||
function findAssetAtPoint(x, y) {
|
function findAssetAtPoint(x, y) {
|
||||||
const ordered = [...getZOrderedAssets()].reverse();
|
const ordered = [...getZOrderedAssets()].reverse();
|
||||||
return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null;
|
return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistTransform(asset, silent = false) {
|
function persistTransform(asset, silent = false) {
|
||||||
@@ -1573,13 +1556,6 @@ canvas.addEventListener('mousedown', (event) => {
|
|||||||
|
|
||||||
const hit = findAssetAtPoint(point.x, point.y);
|
const hit = findAssetAtPoint(point.x, point.y);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
if (isAudioAsset(hit) && !handle && event.detail >= 2) {
|
|
||||||
selectedAssetId = hit.id;
|
|
||||||
updateRenderState(hit);
|
|
||||||
playAudioFromCanvas(hit);
|
|
||||||
drawAndList();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedAssetId = hit.id;
|
selectedAssetId = hit.id;
|
||||||
updateRenderState(hit);
|
updateRenderState(hit);
|
||||||
interactionState = {
|
interactionState = {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ let pendingDraw = false;
|
|||||||
let sortedAssetsCache = [];
|
let sortedAssetsCache = [];
|
||||||
let assetsDirty = true;
|
let assetsDirty = true;
|
||||||
let renderIntervalId = null;
|
let renderIntervalId = null;
|
||||||
const audioPlaceholder = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="#0f172a" rx="8"/><g fill="#22d3ee" transform="translate(20 20)"><circle cx="15" cy="20" r="6"/><rect x="28" y="5" width="12" height="30" rx="2"/><rect x="45" y="10" width="140" height="5" fill="#a5f3fc"/><rect x="45" y="23" width="110" height="5" fill="#a5f3fc"/></g><text x="20" y="70" fill="#e5e7eb" font-family="sans-serif" font-size="14">Audio</text></svg>');
|
|
||||||
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
||||||
|
|
||||||
audioUnlockEvents.forEach((eventName) => {
|
audioUnlockEvents.forEach((eventName) => {
|
||||||
@@ -99,6 +98,12 @@ function handleEvent(event) {
|
|||||||
assets.delete(event.assetId);
|
assets.delete(event.assetId);
|
||||||
clearMedia(event.assetId);
|
clearMedia(event.assetId);
|
||||||
renderStates.delete(event.assetId);
|
renderStates.delete(event.assetId);
|
||||||
|
} else if (event.type === 'PLAY' && event.payload) {
|
||||||
|
const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
|
||||||
|
assets.set(payload.id, payload);
|
||||||
|
if (isAudioAsset(payload)) {
|
||||||
|
handleAudioPlay(payload, event.play !== false);
|
||||||
|
}
|
||||||
} else if (event.payload && !event.payload.hidden) {
|
} else if (event.payload && !event.payload.hidden) {
|
||||||
const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
|
const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
|
||||||
assets.set(payload.id, payload);
|
assets.set(payload.id, payload);
|
||||||
@@ -169,18 +174,17 @@ function drawAsset(asset) {
|
|||||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||||
|
|
||||||
const media = ensureMedia(asset);
|
|
||||||
const drawSource = media?.isAnimated ? media.bitmap : media;
|
|
||||||
const ready = isAudioAsset(asset) || isDrawable(media);
|
|
||||||
if (isAudioAsset(asset)) {
|
if (isAudioAsset(asset)) {
|
||||||
autoStartAudio(asset);
|
autoStartAudio(asset);
|
||||||
}
|
ctx.restore();
|
||||||
if (ready && drawSource) {
|
return;
|
||||||
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAudioAsset(asset)) {
|
const media = ensureMedia(asset);
|
||||||
drawAudioIndicators(asset, halfWidth, halfHeight);
|
const drawSource = media?.isAnimated ? media.bitmap : media;
|
||||||
|
const ready = isDrawable(media);
|
||||||
|
if (ready && drawSource) {
|
||||||
|
ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@@ -253,59 +257,6 @@ function isGifAsset(asset) {
|
|||||||
return asset?.mediaType?.toLowerCase() === 'image/gif';
|
return asset?.mediaType?.toLowerCase() === 'image/gif';
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAudioIndicators(asset, halfWidth, halfHeight) {
|
|
||||||
const controller = audioControllers.get(asset.id);
|
|
||||||
const isPlaying = controller && !controller.element.paused && !controller.element.ended;
|
|
||||||
const hasDelay = !!(controller && controller.delayTimeout);
|
|
||||||
if (!isPlaying && !hasDelay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const indicatorSize = 18;
|
|
||||||
const padding = 8;
|
|
||||||
let x = -halfWidth + padding + indicatorSize / 2;
|
|
||||||
const y = -halfHeight + padding + indicatorSize / 2;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
if (isPlaying) {
|
|
||||||
ctx.fillStyle = 'rgba(34, 197, 94, 0.9)';
|
|
||||||
ctx.strokeStyle = '#020617';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.fillStyle = '#020617';
|
|
||||||
ctx.beginPath();
|
|
||||||
const radius = indicatorSize * 0.22;
|
|
||||||
ctx.moveTo(x - radius, y - radius * 1.1);
|
|
||||||
ctx.lineTo(x + radius * 1.2, y);
|
|
||||||
ctx.lineTo(x - radius, y + radius * 1.1);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
x += indicatorSize + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDelay) {
|
|
||||||
ctx.fillStyle = 'rgba(234, 179, 8, 0.9)';
|
|
||||||
ctx.strokeStyle = '#020617';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, indicatorSize / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#020617';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x, y - indicatorSize * 0.22);
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x + indicatorSize * 0.22, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDrawable(element) {
|
function isDrawable(element) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -365,6 +316,7 @@ function ensureAudioController(asset) {
|
|||||||
element,
|
element,
|
||||||
delayTimeout: null,
|
delayTimeout: null,
|
||||||
loopEnabled: false,
|
loopEnabled: false,
|
||||||
|
loopActive: true,
|
||||||
delayMs: 0,
|
delayMs: 0,
|
||||||
baseDelayMs: 0
|
baseDelayMs: 0
|
||||||
};
|
};
|
||||||
@@ -376,19 +328,24 @@ function ensureAudioController(asset) {
|
|||||||
|
|
||||||
function applyAudioSettings(controller, asset, resetPosition = false) {
|
function applyAudioSettings(controller, asset, resetPosition = false) {
|
||||||
controller.loopEnabled = !!asset.audioLoop;
|
controller.loopEnabled = !!asset.audioLoop;
|
||||||
|
controller.loopActive = controller.loopEnabled && controller.loopActive !== false;
|
||||||
controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0);
|
controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0);
|
||||||
controller.delayMs = controller.baseDelayMs;
|
controller.delayMs = controller.baseDelayMs;
|
||||||
const speed = Math.max(0.25, asset.audioSpeed || 1);
|
applyAudioElementSettings(controller.element, asset);
|
||||||
const pitch = Math.max(0.5, asset.audioPitch || 1);
|
|
||||||
controller.element.playbackRate = speed * pitch;
|
|
||||||
const volume = Math.max(0, Math.min(1, asset.audioVolume ?? 1));
|
|
||||||
controller.element.volume = volume;
|
|
||||||
if (resetPosition) {
|
if (resetPosition) {
|
||||||
controller.element.currentTime = 0;
|
controller.element.currentTime = 0;
|
||||||
controller.element.pause();
|
controller.element.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAudioElementSettings(element, asset) {
|
||||||
|
const speed = Math.max(0.25, asset.audioSpeed || 1);
|
||||||
|
const pitch = Math.max(0.5, asset.audioPitch || 1);
|
||||||
|
element.playbackRate = speed * pitch;
|
||||||
|
const volume = Math.max(0, Math.min(1, asset.audioVolume ?? 1));
|
||||||
|
element.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
function handleAudioEnded(assetId) {
|
function handleAudioEnded(assetId) {
|
||||||
const controller = audioControllers.get(assetId);
|
const controller = audioControllers.get(assetId);
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
@@ -396,7 +353,7 @@ function handleAudioEnded(assetId) {
|
|||||||
if (controller.delayTimeout) {
|
if (controller.delayTimeout) {
|
||||||
clearTimeout(controller.delayTimeout);
|
clearTimeout(controller.delayTimeout);
|
||||||
}
|
}
|
||||||
if (controller.loopEnabled) {
|
if (controller.loopEnabled && controller.loopActive) {
|
||||||
controller.delayTimeout = setTimeout(() => {
|
controller.delayTimeout = setTimeout(() => {
|
||||||
safePlay(controller);
|
safePlay(controller);
|
||||||
}, controller.delayMs);
|
}, controller.delayMs);
|
||||||
@@ -405,6 +362,19 @@ function handleAudioEnded(assetId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopAudio(assetId) {
|
||||||
|
const controller = audioControllers.get(assetId);
|
||||||
|
if (!controller) return;
|
||||||
|
if (controller.delayTimeout) {
|
||||||
|
clearTimeout(controller.delayTimeout);
|
||||||
|
}
|
||||||
|
controller.element.pause();
|
||||||
|
controller.element.currentTime = 0;
|
||||||
|
controller.delayTimeout = null;
|
||||||
|
controller.delayMs = controller.baseDelayMs;
|
||||||
|
controller.loopActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
function playAudioImmediately(asset) {
|
function playAudioImmediately(asset) {
|
||||||
const controller = ensureAudioController(asset);
|
const controller = ensureAudioController(asset);
|
||||||
if (controller.delayTimeout) {
|
if (controller.delayTimeout) {
|
||||||
@@ -418,11 +388,42 @@ function playAudioImmediately(asset) {
|
|||||||
controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0;
|
controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playOverlappingAudio(asset) {
|
||||||
|
const temp = new Audio(asset.url);
|
||||||
|
temp.autoplay = true;
|
||||||
|
temp.preload = 'auto';
|
||||||
|
temp.controls = false;
|
||||||
|
applyAudioElementSettings(temp, asset);
|
||||||
|
const controller = { element: temp };
|
||||||
|
temp.onended = () => {
|
||||||
|
temp.remove();
|
||||||
|
};
|
||||||
|
safePlay(controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAudioPlay(asset, shouldPlay) {
|
||||||
|
const controller = ensureAudioController(asset);
|
||||||
|
controller.loopActive = !!shouldPlay;
|
||||||
|
if (!shouldPlay) {
|
||||||
|
stopAudio(asset.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (asset.audioLoop) {
|
||||||
|
controller.delayMs = controller.baseDelayMs;
|
||||||
|
safePlay(controller);
|
||||||
|
} else {
|
||||||
|
playOverlappingAudio(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function autoStartAudio(asset) {
|
function autoStartAudio(asset) {
|
||||||
if (!isAudioAsset(asset) || asset.hidden) {
|
if (!isAudioAsset(asset) || asset.hidden) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = ensureAudioController(asset);
|
const controller = ensureAudioController(asset);
|
||||||
|
if (!controller.loopEnabled || !controller.loopActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!controller.element.paused && !controller.element.ended) {
|
if (!controller.element.paused && !controller.element.ended) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -443,10 +444,8 @@ function ensureMedia(asset) {
|
|||||||
|
|
||||||
if (isAudioAsset(asset)) {
|
if (isAudioAsset(asset)) {
|
||||||
ensureAudioController(asset);
|
ensureAudioController(asset);
|
||||||
const placeholder = new Image();
|
mediaCache.delete(asset.id);
|
||||||
placeholder.src = audioPlaceholder;
|
return null;
|
||||||
mediaCache.set(asset.id, placeholder);
|
|
||||||
return placeholder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGifAsset(asset) && 'ImageDecoder' in window) {
|
if (isGifAsset(asset) && 'ImageDecoder' in window) {
|
||||||
|
|||||||
Reference in New Issue
Block a user