Skip to content
2 changes: 2 additions & 0 deletions src/assets/scss/Global/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ body {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
height: 100vh;
height: 100dvh;
width: 100vw;
width: 100dvw;
overflow: hidden;
margin: 0;
background-color: $body;
Expand Down
8 changes: 4 additions & 4 deletions src/components/NowPlaying/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
<img v-motion-fade class="rounded" :src="paths.images.thumb.large + queue.currenttrack?.image" />
</RouterLink>
<NowPlayingInfo @handle-fav="handleFav" />
<Progress v-if="isSmallPhone" />
<div v-if="isSmallPhone" class="below-progress">
<Progress v-if="isLargerMobile || isSmallPhone" />
<div v-if="isLargerMobile || isSmallPhone" class="below-progress">
<div class="time">
{{ formatSeconds(queue.duration.current) }}
</div>
Expand All @@ -26,7 +26,7 @@
</div>
</div>
</div>
<h3 class="nowplaying_title" v-if="queue.next">Up Next</h3>
<h3 v-if="queue.next" class="nowplaying_title">Up Next</h3>
<SongItem
v-if="queue.next"
:track="queue.next"
Expand All @@ -43,7 +43,7 @@ import { paths } from "@/config";
import { dropSources, favType } from "@/enums";
import favoriteHandler from "@/helpers/favoriteHandler";
import { Routes } from "@/router";
import { isSmallPhone } from "@/stores/content-width";
import { isSmallPhone, isLargerMobile } from "@/stores/content-width";
import useQueueStore from "@/stores/queue";
import { formatSeconds } from "@/utils";

Expand Down
2 changes: 1 addition & 1 deletion src/components/nav/ProfileDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const modal = useModal()
<style lang="scss">
.profiledrop {
position: absolute;
z-index: 10;
z-index: 9999;
top: 2.25rem;
right: 0;
width: 10rem;
Expand Down
8 changes: 7 additions & 1 deletion src/components/shared/DropDown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
:title="reverse !== 'hide' ? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase() : undefined"
>
<span class="ellip">{{ current.title }}</span>
<ArrowSvg :class="{ reverse }" v-if="reverse !== 'hide'" />
<ArrowSvg :class="{ reverse }" class="dropdown-arrow" v-if="reverse !== 'hide'" />
</button>
<div v-if="showDropDown" ref="dropOptionsRef" class="options rounded no-scroll shadow-lg">
<div
Expand Down Expand Up @@ -68,6 +68,12 @@ onClickOutside(dropOptionsRef, e => {
<style lang="scss">
.smdropdown {
z-index: 1000;

.dropdown-arrow {
width: 100%;
aspect-ratio: 1;
}

.selected {
width: 100%;
display: grid;
Expand Down
7 changes: 7 additions & 0 deletions src/helpers/mediaNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,12 @@ export default () => {
navigator.mediaSession.setActionHandler("nexttrack", () => {
queue.playNext();
});
navigator.mediaSession.setActionHandler("seekto", (details) => {
if (details.fastSeek||details.seekTime == undefined) {
return;
}

queue.seek(details.seekTime);
});
}
};
173 changes: 131 additions & 42 deletions src/stores/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,112 @@ import updateMediaNotif from '@/helpers/mediaNotification'
import { crossFade } from '@/utils/audio/crossFade'
import updatePageTitle from '@/utils/updatePageTitle'

class AudioSource {
private sources: HTMLAudioElement[] = []
private playingSourceIndex: number = 0
private handlers: { [key: string]: (err: Event | string) => void } = {}
private requiredAPBlockBypass: boolean = false
settings: ReturnType<typeof useSettings> | null = null

constructor() {
this.sources = [new Audio(), new Audio()]

this.sources.forEach((audio, index) => {
audio.style.display = 'none'
audio.id = `source-${index}`
document.body.appendChild(audio)
})

this.requiredAPBlockBypass = true
}

get standbySource() {
return this.sources[1 - this.playingSourceIndex]
}
get playingSource() {
return this.sources[this.playingSourceIndex]
}

preloadWithUri(uri: string) {
const audio = this.standbySource
if (!this.settings) return audio
audio.src = uri
audio.muted = this.settings.mute
audio.volume = this.settings.volume
audio.load()
return audio
}

switchSources() {
if (!this.settings) return
crossFade({
audio: this.playingSource,
duration: this.settings.crossfade_duration,
start_volume: this.settings.volume,
then_destroy: true,
})

this.playingSourceIndex = 1 - this.playingSourceIndex
}

assignSettings(settings: ReturnType<typeof useSettings>) {
this.settings = settings
this.sources.forEach(audio => {
audio.muted = settings.mute
audio.volume = settings.volume
})
}

assignEventHandlers(onPlaybackError: (err: Event | string) => void) {
this.handlers = {
onPlaybackError,
}
}

pausePlayingSource() {
navigator.mediaSession.playbackState = 'paused'
this.playingSource.pause()
}

async playPlayingSource(
trackSilence?: { starting_file: number; ending_file: number }
) {
const trackDuration = trackSilence
? Math.floor(trackSilence.ending_file / 1000 - trackSilence.starting_file / 1000)
: null

if(this.requiredAPBlockBypass)
this.applyAPBlockBypass()

await this.playingSource.play().catch(this.handlers.onPlaybackError)
navigator.mediaSession.playbackState = 'playing'
navigator.mediaSession.setPositionState({
duration: trackDuration || this.playingSource.duration,
position: this.playingSource.currentTime,
})
}

/**
* This is a workaround for the autoplay policy on mobile devices. (mainly IOS Safari)
*
* for Audio elements to be able to play without being blocked, two main conditions must be met:
* 1. The first time any Audio plays, it must be triggered by user interaction.
* 2. The Audio must exist in the DOM.
*
* without this workaround, the first time `standbySource` plays, it would be blocked by the browser.
*
* this workaround plays the `standbySource` along with the `playingSource` to meet the first condition.
*/
private applyAPBlockBypass(){
this.standbySource.src = ''
this.standbySource.play().then(() => {
this.standbySource.pause()
}).catch(() => {})

this.requiredAPBlockBypass = false
}
}

export function getUrl(filepath: string, trackhash: string, use_legacy: boolean) {
// INFO: Force using legacy streaming endpoint until
// we change the playback engine to properly support
Expand All @@ -27,7 +133,8 @@ export function getUrl(filepath: string, trackhash: string, use_legacy: boolean)
)}&container=${streaming_container}&quality=${streaming_quality}`
}

let audio = new Audio()
const audioSource = new AudioSource()
let audio = audioSource.playingSource
const buffering = ref(true)
const maxSeekPercent = ref(0)

Expand All @@ -40,6 +147,8 @@ export const usePlayer = defineStore('player', () => {
const settings = useSettings()
const tracklist = useTracklist()

audioSource.assignSettings(settings)

let currentAudioData = {
filepath: '',
silence: {
Expand All @@ -50,7 +159,7 @@ export const usePlayer = defineStore('player', () => {

let nextAudioData = {
filepath: '',
audio: new Audio(),
audio: audioSource.standbySource,
loaded: false,
ticking: false,
silence: {
Expand All @@ -70,9 +179,6 @@ export const usePlayer = defineStore('player', () => {

function clearNextAudioData() {
nextAudioData.filepath = ''
nextAudioData.audio.removeEventListener('canplay', () => null)
nextAudioData.audio = new Audio()

nextAudioData.loaded = false
nextAudioData.ticking = false
nextAudioData.silence = {
Expand All @@ -95,19 +201,11 @@ export const usePlayer = defineStore('player', () => {
}

const audio_onerror = (err: Event | string) => {
const { showNotification } = useToast()

if (typeof err != 'string') {
err.stopImmediatePropagation()
}

if (err instanceof DOMException) {
queue.playPause()

return toast.showNotification('Tap anywhere in the page and try again (autoplay blocked))', NotifType.Error)
}

showNotification("Can't load: " + queue.currenttrack.title, NotifType.Error)
handlePlayErrors(err)

// if (queue.currentindex !== tracklist.tracklist.length - 1) {
// if (!queue.playing) return
Expand All @@ -129,13 +227,17 @@ export const usePlayer = defineStore('player', () => {
// queue.setPlaying(false)
}

const handlePlayErrors = (e: Event) => {
const handlePlayErrors = (e: Event | string) => {
if (e instanceof DOMException) {
queue.playPause()
if(e.name === 'NotAllowedError') {
queue.playPause()
return toast.showNotification('Tap anywhere in the page and try again (autoplay blocked)', NotifType.Error)
}

return toast.showNotification('Tap anywhere in the page and try again (autoplay blocked))', NotifType.Error)
return toast.showNotification('Player Error: ' + e.message, NotifType.Error)
}

queue.playNext() // skip unplayable track
toast.showNotification("Can't load: " + queue.currenttrack.title, NotifType.Error)
}

Expand Down Expand Up @@ -167,14 +269,14 @@ export const usePlayer = defineStore('player', () => {

const onAudioCanPlay = () => {
if (!queue.playing) {
audio.pause()
audioSource.pausePlayingSource()
return
}
// queue.setDurationFromFile(audio.duration == Infinity ? queue.currenttrack.duration || 0 : audio.duration)
// console.log(audio.duration == Infinity)
queue.setDurationFromFile(queue.currenttrack.duration || 0)

audio.play().catch(handlePlayErrors)
audioSource.playPlayingSource(currentAudioData.silence)
}

const onAudioEnded = () => {
Expand All @@ -188,7 +290,7 @@ export const usePlayer = defineStore('player', () => {
if (nextAudioData.loaded === false) {
console.log('next audio not loaded')
clearNextAudioData()
queue.autoPlayNext()
queue.playNext()
}
}

Expand Down Expand Up @@ -256,31 +358,23 @@ export const usePlayer = defineStore('player', () => {
if (nextAudioData.filepath === queue.next.filepath) return

const uri = getUrl(queue.next.filepath, queue.next.trackhash, settings.use_legacy_streaming_endpoint)
nextAudioData.audio = new Audio(uri)
nextAudioData.audio.muted = settings.mute
nextAudioData.audio = audioSource.preloadWithUri(uri)
nextAudioData.filepath = queue.next.filepath
nextAudioData.audio.oncanplay = handleNextAudioCanPlay
nextAudioData.audio.load()
}

function moveLoadedForward() {
clearEventHandlers(audio)

const oldAudio = audio
queue.setManual(false)
crossFade({
audio: oldAudio,
duration: settings.crossfade_duration,
start_volume: settings.volume,
then_destroy: true,
})
audioSource.switchSources()

// INFO: Set stuff
audio = nextAudioData.audio
audio = audioSource.playingSource
audio.currentTime = nextAudioData.silence.starting_file / 1000
currentAudioData.silence = nextAudioData.silence
currentAudioData.filepath = nextAudioData.filepath
maxSeekPercent.value = 0
audioSource.playPlayingSource(nextAudioData.silence);

clearNextAudioData()
queue.moveForward()
Expand Down Expand Up @@ -365,22 +459,16 @@ export const usePlayer = defineStore('player', () => {
maxSeekPercent.value = 0

if (!queue.manual && queue.playing && audio.src !== '' && !audio.src.includes('sm.radio.jingles')) {
const oldAudio = audio
crossFade({
audio: oldAudio,
duration: settings.crossfade_duration,
start_volume: settings.volume,
then_destroy: true,
})
audio = new Audio()
audio.muted = settings.mute
audioSource.switchSources()
audio = audioSource.playingSource
}

const { currenttrack: track } = queue
// const uri = `${paths.api.files}/${track.trackhash}?filepath=${encodeURIComponent(track.filepath as string)}`
const uri = getUrl(track.filepath, track.trackhash, settings.use_legacy_streaming_endpoint)

audio.src = uri
audio.load() // on safari, audio won't play without load()

clearNextAudioData()
assignEventHandlers(audio)
Expand All @@ -407,6 +495,7 @@ export const usePlayer = defineStore('player', () => {
}

assignEventHandlers(audio)
audioSource.assignEventHandlers(handlePlayErrors)

return {
audio,
Expand All @@ -418,4 +507,4 @@ export const usePlayer = defineStore('player', () => {
}
})

export { audio, buffering, maxSeekPercent }
export { audioSource, buffering, maxSeekPercent }
Loading