Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/lib/audio-effects/audio-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,16 +329,22 @@ export default class AudioManager {
this._soundTouchNode.disconnect()

// Disconnect active effects
// NOTE: For equalizer with separate input/output nodes, we DON'T call disconnect()
// on them because they're part of an internal filter chain. Calling disconnect()
// would break the internal connections between filters. We only need to disconnect
// single-node effects or effects that manage their own internal state.
// NOTE: For equalizer with separate input/output nodes, we need to disconnect
// both the input and output nodes from the external chain, but NOT the internal
// connections between filters (the equalizer class handles those).
if (this._activeEffects.equalizer) {
if (
typeof this._activeEffects.equalizer === 'object' &&
'input' in this._activeEffects.equalizer
) {
// Don't disconnect - the equalizer manages its own internal chain
// Disconnect input and output from the external chain
// This prevents duplicate connections when rebuilding
try {
this._activeEffects.equalizer.input.disconnect()
this._activeEffects.equalizer.output.disconnect()
} catch (e) {
// Ignore disconnect errors during cleanup
}
} else {
this._activeEffects.equalizer.disconnect()
}
Expand Down
41 changes: 12 additions & 29 deletions src/lib/components/SkipButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,21 @@
import * as Tooltip from '$lib/components/ui/tooltip'
import { buttonVariants } from '$lib/components/ui/button'

function highlightInTrackList() {
const rowsQuery = document.querySelectorAll('[data-testid="tracklist-row"]')
if (!rowsQuery?.length) return

const trackRows = Array.from(rowsQuery)

const context = trackRows.find(
(row) =>
row.querySelector('a[data-testid="internal-track-link"] div')?.textContent ===
$nowPlaying.title
)

if (!context) return

const blockIcon = context.querySelector('button[role="block"]')
if (!blockIcon) return

const svg = blockIcon.querySelector('svg')
if (!svg) return

blockIcon.setAttribute('aria-label', 'Block Track')
svg.style.stroke = '#1ed760'
}

async function handleBlock() {
async function handleBlock(): Promise<void> {
if ($nowPlaying.track_id) {
nowPlaying.set({ ...$nowPlaying, blocked: true })
const { cover, track_id } = $nowPlaying
console.log({ cover, track_id })

await dataStore.updateTrack({
track_id: $nowPlaying.track_id,
value: { blocked: true }
track_id,
value: { blocked: true, cover }
})
highlightInTrackList()

document.dispatchEvent(
new CustomEvent('chorus:track-blocked', {
detail: { track_id: track_id, cover }
})
)
}

trackObserver?.skipTrack()
Expand Down
25 changes: 14 additions & 11 deletions src/lib/components/TrackListSkipButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@
import type { SimpleTrack } from '$lib/stores/data/cache'

let { track }: { track: SimpleTrack } = $props()
let isBlocked = $state(track?.blocked ?? false)
let isBlocked = $state(track?.blocked || false)

// Listen for unblock events from BlockedTracksDialog (event-driven, no polling)
$effect(() => {
if (!isBlocked) return

const handleUnblock = (event: Event) => {
const handleBlockUnBlock = (event: Event) => {
const customEvent = event as CustomEvent<{ track_id: string }>
if (customEvent.detail.track_id === track.track_id) {
isBlocked = false
const found = dataStore.blocked.find(
(blockedTrack) => blockedTrack.track_id == track.track_id
)
isBlocked = !!found
}
}

document.addEventListener('chorus:track-unblocked', handleUnblock)
return () => document.removeEventListener('chorus:track-unblocked', handleUnblock)
document.addEventListener('chorus:track-blocked', handleBlockUnBlock)
document.addEventListener('chorus:track-unblocked', handleBlockUnBlock)
return () => {
document.removeEventListener('chorus:track-blocked', handleBlockUnBlock)
document.removeEventListener('chorus:track-unblocked', handleBlockUnBlock)
}
})

function getCoverArt() {
Expand All @@ -41,14 +45,13 @@
const cover = track?.cover || getCoverArt()
await dataStore.updateTrack({
track_id: track.track_id,
value: { blocked: isBlocked, cover }
value: { blocked: isBlocked || null, cover }
})

// Emit event for reactive updates
if (isBlocked) {
document.dispatchEvent(
new CustomEvent('chorus:track-blocked', {
detail: { track_id: track.track_id }
detail: { track_id: track.track_id, cover }
})
)

Expand Down
64 changes: 40 additions & 24 deletions src/lib/media/media-override.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default class MediaOverride {
private _sources: any[] = []
private _chorusRate: number = 1
private _chorusPreservesPitch: boolean = true
private _effectUpdateInProgress: Promise<void> | null = null

constructor(options: MediaOverrideOptions) {
this.source = options.source
Expand Down Expand Up @@ -151,37 +152,52 @@ export default class MediaOverride {
return
}

try {
await this.audioManager.ensureAudioChainReady()
// Wait for any in-progress effect update to complete before starting a new one
// This prevents race conditions where multiple rapid effect changes could cause double-application
if (this._effectUpdateInProgress) {
await this._effectUpdateInProgress
}

// Create a new promise for this update operation
this._effectUpdateInProgress = (async () => {
try {
await this.audioManager.ensureAudioChainReady()

// If clear is requested, disconnect all effects
if (effect.clear) {
// If clear is requested, disconnect all effects
if (effect.clear) {
this.audioManager.disconnect()
return
}

// Disconnect all effects first
this.audioManager.disconnect()
return
}

// Disconnect all effects first
this.audioManager.disconnect()
// Apply effects in the order they'll be chained: equalizer → MS processor → reverb
// Each effect can be applied independently
if (effect?.equalizer && effect.equalizer !== 'none') {
this.equalizer.setEQEffect(effect.equalizer)
}

// Apply effects in the order they'll be chained: equalizer → MS processor → reverb
// Each effect can be applied independently
if (effect?.equalizer && effect.equalizer !== 'none') {
this.equalizer.setEQEffect(effect.equalizer)
}
// Apply MS processor (can be combined with any other effect)
if (effect?.msProcessor && effect.msProcessor !== 'none') {
await this.msProcessor.setMSEffect(effect.msProcessor)
}

// Apply MS processor (can be combined with any other effect)
if (effect?.msProcessor && effect.msProcessor !== 'none') {
await this.msProcessor.setMSEffect(effect.msProcessor)
// Apply reverb (can be combined with any other effect)
if (effect?.reverb && effect.reverb !== 'none') {
await this.reverb.setReverbEffect(effect.reverb)
}
} catch (error) {
console.error('Error updating audio effects:', error)
this.audioManager.disconnect()
} finally {
// Clear the lock when done
this._effectUpdateInProgress = null
}
})()

// Apply reverb (can be combined with any other effect)
if (effect?.reverb && effect.reverb !== 'none') {
await this.reverb.setReverbEffect(effect.reverb)
}
} catch (error) {
console.error('Error updating audio effects:', error)
this.audioManager.disconnect()
}
// Wait for this update to complete
await this._effectUpdateInProgress
}

async updateMSParams(params: MSParams): Promise<void> {
Expand Down
24 changes: 18 additions & 6 deletions src/lib/observers/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export class TrackObserver {
private async processMediaPlayInit() {
await this.trackStateManager.updateTrackType()
await this.trackStateManager.setPlayback(this.audioPreset)
this.setEffect()
// Note: setEffect() will be called via REQUEST_EFFECT_REAPPLY message
// dispatched from media-element.ts, so we don't need to call it here
// to avoid redundant effect applications
}

// Simplified getters for commonly accessed stores
Expand Down Expand Up @@ -87,7 +89,7 @@ export class TrackObserver {
playbackObserver.updateChorusUI()
}

private isAtSnipEnd(currentTimeMS: number): boolean {
private isAtTrackOrSnipEnd(currentTimeMS: number): boolean {
return this.trackStateManager.isTrackAtSnipEnd(currentTimeMS, this.currentSong)
}

Expand Down Expand Up @@ -149,10 +151,13 @@ export class TrackObserver {
return this.updateCurrentTime(this.snip.start_time)
}

// Handle looping if at snip end
if (this.loop.looping && currentSong.snip && this.isAtSnipEnd(currentTimeMS)) {
// Handle looping if at track/snip end
if (this.loop.looping && this.isAtTrackOrSnipEnd(currentTimeMS)) {
const loopHandled = await this.playbackController.handleLooping(currentTimeMS)
if (loopHandled) return
// Loop count exhausted, skip to next track
this.skipTrack()
return
}

// Handle shared snip URL cleanup
Expand All @@ -162,17 +167,24 @@ export class TrackObserver {

// Check if at or past snip end for auto-advance
const atSnipEnd = currentSong.snip && currentTimeMS >= currentSong.snip.end_time * 1000
const atTrackEnd = currentTimeMS >= currentSong.duration * 1000 - 100

// Handle track end or snip end - auto-advance if at or past end
const shouldSkip =
(currentSong.snip || currentSong.blocked) &&
(currentTimeMS >= currentSong.duration * 1000 || atSnipEnd)
(currentSong.snip || currentSong.blocked) && (atTrackEnd || atSnipEnd)

if (shouldSkip) {
this.skipTrack()
return
}

// Handle end of track after loop count exhausted (for non-snip tracks)
// When looping ends, iteration is 0 and looping is false
if (this.loop.type === 'amount' && this.loop.iteration === 0 && atTrackEnd) {
this.skipTrack()
return
}

// Early return if we have a snip but not yet at the end (still playing within snip)
if (currentSong.snip && !atSnipEnd) return
}, 50)
Expand Down
39 changes: 28 additions & 11 deletions src/lib/services/playback-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,41 @@ export class PlaybackController {
}

shouldSkipTrack(songInfo: NowPlaying): boolean {
return songInfo?.blocked ||
configStore.checkIfTrackShouldBeSkipped({
title: songInfo?.title ?? '',
artist: songInfo?.artist ?? ''
})
return (
songInfo?.blocked ||
configStore.checkIfTrackShouldBeSkipped({
title: songInfo?.title ?? '',
artist: songInfo?.artist ?? ''
})
)
}

async handleLooping(currentTimeMS: number): Promise<boolean> {
const loop = get(loopStore)
const currentSong = get(nowPlaying)

if (!loop.looping) return false

if (loop.type === 'infinite') {
// Infinite loop: always seek back to start
this.updateCurrentTime(currentSong.snip?.start_time ?? 0)
return true
}

// Count-based loop: check if we have iterations left
if (loop.type === 'amount') {
await loopStore.decrement()
if (loop.iteration >= 1) {
// Iterations remaining, decrement and loop back
await loopStore.decrement()
this.updateCurrentTime(currentSong.snip?.start_time ?? 0)
return true
} else {
// No iterations left (iteration === 0), stop looping and advance
await loopStore.resetIteration()
return false
}
}

this.updateCurrentTime(currentSong.snip?.start_time ?? 0)
return true

return false
}
}
}
2 changes: 1 addition & 1 deletion src/lib/stores/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function createLoopStore() {
const newIteration = state.iteration - 1
return {
...state,
iteration: newIteration == 0 ? state.amount : newIteration,
iteration: Math.max(0, newIteration),
looping: newIteration > 0
}
})
Expand Down