- {artist ?? ""} -
+ {!playerState.radioId && ( ++ {artist ?? ""} +
+ )} {playerState.radioId && ( diff --git a/packages/app/src/components/Player/index.less b/packages/app/src/components/Player/index.less index 6a21b0df..479ef956 100755 --- a/packages/app/src/components/Player/index.less +++ b/packages/app/src/components/Player/index.less @@ -159,6 +159,13 @@ } .toolbar_player_info_subtitle { + display: inline-block; + + overflow: hidden; + white-space: nowrap; + + text-overflow: ellipsis; + font-size: 0.8rem; font-weight: 400; @@ -246,6 +253,8 @@ border-radius: 12px; + gap: 10px; + background-color: rgba(var(--layoutBackgroundColor), 0.7); -webkit-backdrop-filter: blur(5px); backdrop-filter: blur(5px); diff --git a/packages/app/src/cores/player/classes/AudioBase.js b/packages/app/src/cores/player/classes/AudioBase.js index 6e9a2ae0..67f668b4 100644 --- a/packages/app/src/cores/player/classes/AudioBase.js +++ b/packages/app/src/cores/player/classes/AudioBase.js @@ -1,10 +1,17 @@ -import { MediaPlayer, Debug } from "dashjs" +import shaka from "shaka-player/dist/shaka-player.compiled.js" + import PlayerProcessors from "./PlayerProcessors" import AudioPlayerStorage from "../player.storage" +import TrackManifest from "../classes/TrackManifest" + +import findInitializationChunk from "../helpers/findInitializationChunk" +import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata" +import handleInlineDashManifest from "../helpers/handleInlineDashManifest" export default class AudioBase { constructor(player) { this.player = player + this.console = player.console } audio = new Audio() @@ -16,6 +23,7 @@ export default class AudioBase { processors = {} waitUpdateTimeout = null + _firstSegmentReceived = false initialize = async () => { // create a audio context @@ -26,73 +34,289 @@ export default class AudioBase { latencyHint: "playback", }) - // configure some settings for audio + // configure some settings for audio with optimized settings this.audio.crossOrigin = "anonymous" - this.audio.preload = "metadata" + this.audio.preload = "auto" this.audio.loop = this.player.state.playback_mode === "repeat" + this.audio.volume = 1 // listen all events for (const [key, value] of Object.entries(this.audioEvents)) { this.audio.addEventListener(key, value) } - // setup demuxer for mpd + // setup shaka player for mpd this.createDemuxer() - // create element source + // create element source with low latency buffer this.elementSource = this.context.createMediaElementSource(this.audio) - // initialize audio processors - await this.processorsManager.initialize() - await this.processorsManager.attachAllNodes() + await this.processorsManager.initialize(), + await this.processorsManager.attachAllNodes() } - createDemuxer() { - this.demuxer = MediaPlayer().create() + itemInit = async (manifest) => { + if (!manifest) { + return null + } - this.demuxer.updateSettings({ - streaming: { - buffer: { - resetSourceBuffersForTrackSwitch: true, + if ( + typeof manifest === "string" || + (!manifest.source && !manifest.dash_manifest) + ) { + this.console.time("resolve") + manifest = await this.player.serviceProviders.resolve(manifest) + this.console.timeEnd("resolve") + } + + if (!(manifest instanceof TrackManifest)) { + this.console.time("init manifest") + manifest = new TrackManifest(manifest, this.player) + this.console.timeEnd("init manifest") + } + + if (manifest.mpd_mode === true && !manifest.dash_manifest) { + this.console.time("fetch dash manifest") + manifest.dash_manifest = await fetch(manifest.source).then((r) => + r.text(), + ) + this.console.timeEnd("fetch dash manifest") + } + + return manifest + } + + play = async (manifest, params = {}) => { + // Pre-initialize audio context if needed + if (this.context.state === "suspended") { + await this.context.resume() + } + + manifest = await this.itemInit(manifest) + + this.console.time("load source") + await this.loadSource(manifest) + this.console.timeEnd("load source") + + this.player.queue.currentItem = manifest + this.player.state.track_manifest = manifest.toSeriableObject() + this.player.nativeControls.update(manifest.toSeriableObject()) + + // reset audio properties + this.audio.currentTime = params.time ?? 0 + this.audio.volume = 1 + + if (this.processors && this.processors.gain) { + this.processors.gain.set(this.player.state.volume) + } + + if (this.audio.paused) { + try { + this.console.time("play") + await this.audio.play() + this.console.timeEnd("play") + } catch (error) { + this.console.error( + "Error during audio.play():", + error, + "State:", + this.audio.readyState, + ) + } + } + + let initChunk = manifest.source + + if (this.demuxer && manifest.dash_manifest) { + initChunk = findInitializationChunk( + manifest.source, + manifest.dash_manifest, + ) + } + + try { + this.player.state.format_metadata = + await parseSourceFormatMetadata(initChunk) + } catch (e) { + this.player.state.format_metadata = null + console.warn("Could not parse audio metadata from source:", e) + } + } + + pause = async () => { + this.audio.pause() + } + + resume = async () => { + this.audio.play() + } + + async loadSource(manifest) { + if (!manifest || !(manifest instanceof TrackManifest)) { + return null + } + + // reset some state + this._firstSegmentReceived = false + this.player.state.format_metadata = null + + const isMpd = manifest.mpd_mode + + if (isMpd) { + const audioSrcAtt = this.audio.getAttribute("src") + + if (audioSrcAtt && !audioSrcAtt.startsWith("blob:")) { + this.audio.removeAttribute("src") + this.audio.load() + } + + if (!this.demuxer) { + this.console.log("Creating demuxer cause not initialized") + this.createDemuxer() + } + + if (manifest._preloaded) { + this.console.log( + `using preloaded source >`, + manifest._preloaded, + ) + + return await this.demuxer.load(manifest._preloaded) + } + + const inlineManifest = + "inline://" + manifest.source + "::" + manifest.dash_manifest + + return await this.demuxer + .load(inlineManifest, 0, "application/dash+xml") + .catch((err) => { + this.console.error("Error loading inline manifest", err) + }) + } + + // if not using demuxer, destroy previous instance + if (this.demuxer) { + await this.demuxer.unload() + await this.demuxer.destroy() + this.demuxer = null + } + + // load source + this.audio.src = manifest.source + return this.audio.load() + } + + async createDemuxer() { + // Destroy previous instance if exists + if (this.demuxer) { + await this.demuxer.unload() + await this.demuxer.detach() + await this.demuxer.destroy() + } + + this.demuxer = new shaka.Player() + + this.demuxer.attach(this.audio) + + this.demuxer.configure({ + manifest: { + //updatePeriod: 5, + disableVideo: true, + disableText: true, + dash: { + ignoreMinBufferTime: true, + ignoreMaxSegmentDuration: true, + autoCorrectDrift: false, + enableFastSwitching: true, + useStreamOnceInPeriodFlattening: false, }, }, - // debug: { - // logLevel: Debug.LOG_LEVEL_DEBUG, - // }, + streaming: { + bufferingGoal: 15, + rebufferingGoal: 1, + bufferBehind: 30, + stallThreshold: 0.5, + }, }) - this.demuxer.initialize(this.audio, null, false) + shaka.net.NetworkingEngine.registerScheme( + "inline", + handleInlineDashManifest, + ) + + this.demuxer.addEventListener("error", (event) => { + console.error("Demuxer error", event) + }) + } + + timeTick = async () => { + if ( + !this.audio || + !this.audio.duration || + this.audio.duration === Infinity + ) { + return false + } + + const remainingTime = this.audio.duration - this.audio.currentTime + + // if remaining time is less than 3s, try to init next item + if (parseInt(remainingTime) <= 10) { + // check if queue has next item + if (this.player.queue.nextItems[0]) { + this.player.queue.nextItems[0] = await this.itemInit( + this.player.queue.nextItems[0], + ) + + if ( + this.demuxer && + this.player.queue.nextItems[0].source && + this.player.queue.nextItems[0].mpd_mode && + !this.player.queue.nextItems[0]._preloaded + ) { + const manifest = this.player.queue.nextItems[0] + + // preload next item + this.console.time("preload next item") + this.player.queue.nextItems[0]._preloaded = + await this.demuxer.preload( + "inline://" + + manifest.source + + "::" + + manifest.dash_manifest, + 0, + "application/dash+xml", + ) + this.console.timeEnd("preload next item") + } + } + } } flush() { this.audio.pause() - this.audio.src = null this.audio.currentTime = 0 - - if (this.demuxer) { - this.demuxer.destroy() - } - this.createDemuxer() } audioEvents = { ended: () => { - this.player.next() - }, - loadeddata: () => { - this.player.state.loading = false - }, - loadedmetadata: () => { - if (this.audio.duration === Infinity) { - this.player.state.live = true - } else { - this.player.state.live = false + try { + this.player.next() + } catch (e) { + console.error(e) } }, play: () => { this.player.state.playback_status = "playing" }, + pause: () => { + this.player.state.playback_status = "paused" + + if (typeof this._timeTickInterval !== "undefined") { + clearInterval(this._timeTickInterval) + } + }, playing: () => { this.player.state.loading = false @@ -102,15 +326,24 @@ export default class AudioBase { clearTimeout(this.waitUpdateTimeout) this.waitUpdateTimeout = null } + + if (typeof this._timeTickInterval !== "undefined") { + clearInterval(this._timeTickInterval) + } + + this.timeTick() + + this._timeTickInterval = setInterval(this.timeTick, 1000) }, - pause: () => { - this.player.state.playback_status = "paused" + loadeddata: () => { + this.player.state.loading = false }, - durationchange: () => { - this.player.eventBus.emit( - `player.durationchange`, - this.audio.duration, - ) + loadedmetadata: () => { + if (this.audio.duration === Infinity) { + this.player.state.live = true + } else { + this.player.state.live = false + } }, waiting: () => { if (this.waitUpdateTimeout) { diff --git a/packages/app/src/cores/player/classes/PlayerState.js b/packages/app/src/cores/player/classes/PlayerState.js index c2d71115..a43c1057 100644 --- a/packages/app/src/cores/player/classes/PlayerState.js +++ b/packages/app/src/cores/player/classes/PlayerState.js @@ -2,36 +2,48 @@ import { Observable } from "object-observer" import AudioPlayerStorage from "../player.storage" export default class PlayerState { - static defaultState = { - loading: false, - playback_status: "stopped", - track_manifest: null, + static defaultState = { + loading: false, - muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false), - volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3), - playback_mode: AudioPlayerStorage.get("mode") ?? "normal", - } + playback_status: "stopped", + playback_mode: AudioPlayerStorage.get("mode") ?? "normal", - constructor(player) { - this.player = player + track_manifest: null, + demuxer_metadata: null, - this.state = Observable.from(PlayerState.defaultState) + muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false), + volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3), + } - Observable.observe(this.state, async (changes) => { - try { - changes.forEach((change) => { - if (change.type === "update") { - const stateKey = change.path[0] + constructor(player) { + this.player = player - this.player.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey]) - this.player.eventBus.emit("player.state.update", change.object) - } - }) - } catch (error) { - this.player.console.error(`Failed to dispatch state updater >`, error) - } - }) + this.state = Observable.from(PlayerState.defaultState) - return this.state - } -} \ No newline at end of file + Observable.observe(this.state, async (changes) => { + try { + changes.forEach((change) => { + if (change.type === "update") { + const stateKey = change.path[0] + + this.player.eventBus.emit( + `player.state.update:${stateKey}`, + change.object[stateKey], + ) + this.player.eventBus.emit( + "player.state.update", + change.object, + ) + } + }) + } catch (error) { + this.player.console.error( + `Failed to dispatch state updater >`, + error, + ) + } + }) + + return this.state + } +} diff --git a/packages/app/src/cores/player/classes/Services.js b/packages/app/src/cores/player/classes/Services.js index 2b2e2c66..e2c492f8 100755 --- a/packages/app/src/cores/player/classes/Services.js +++ b/packages/app/src/cores/player/classes/Services.js @@ -1,59 +1,71 @@ import ComtyMusicServiceInterface from "../providers/comtymusic" export default class ServiceProviders { - providers = [ - // add by default here - new ComtyMusicServiceInterface() - ] + providers = [ + // add by default here + new ComtyMusicServiceInterface(), + ] - findProvider(providerId) { - return this.providers.find((provider) => provider.constructor.id === providerId) - } + findProvider(providerId) { + return this.providers.find( + (provider) => provider.constructor.id === providerId, + ) + } - register(provider) { - this.providers.push(provider) - } + register(provider) { + this.providers.push(provider) + } - has(providerId) { - return this.providers.some((provider) => provider.constructor.id === providerId) - } + has(providerId) { + return this.providers.some( + (provider) => provider.constructor.id === providerId, + ) + } - operation = async (operationName, providerId, manifest, args) => { - const provider = await this.findProvider(providerId) + operation = async (operationName, providerId, manifest, args) => { + const provider = await this.findProvider(providerId) - if (!provider) { - console.error(`Failed to resolve manifest, provider [${providerId}] not registered`) - return manifest - } + if (!provider) { + console.error( + `Failed to resolve manifest, provider [${providerId}] not registered`, + ) + return manifest + } - const operationFn = provider[operationName] + const operationFn = provider[operationName] - if (typeof operationFn !== "function") { - console.error(`Failed to resolve manifest, provider [${providerId}] operation [${operationName}] not found`) - return manifest - } + if (typeof operationFn !== "function") { + console.error( + `Failed to resolve manifest, provider [${providerId}] operation [${operationName}] not found`, + ) + return manifest + } - return await operationFn(manifest, args) - } + return await operationFn(manifest, args) + } - resolve = async (providerId, manifest) => { - const provider = await this.findProvider(providerId) + resolve = async (manifest) => { + let providerId = manifest.service ?? "default" - if (!provider) { - console.error(`Failed to resolve manifest, provider [${providerId}] not registered`) - return manifest - } + const provider = this.findProvider(providerId) - return await provider.resolve(manifest) - } + if (!provider) { + console.error( + `Failed to resolve manifest, provider [${providerId}] not registered`, + ) + return manifest + } - resolveMany = async (manifests) => { - manifests = manifests.map(async (manifest) => { - return await this.resolve(manifest.service ?? "default", manifest) - }) + return await provider.resolve(manifest) + } - manifests = await Promise.all(manifests) + resolveMany = async (manifests) => { + manifests = manifests.map(async (manifest) => { + return await this.resolve(manifest) + }) - return manifests - } -} \ No newline at end of file + manifests = await Promise.all(manifests) + + return manifests + } +} diff --git a/packages/app/src/cores/player/classes/SyncRoom.js b/packages/app/src/cores/player/classes/SyncRoom.js new file mode 100644 index 00000000..6b332b4b --- /dev/null +++ b/packages/app/src/cores/player/classes/SyncRoom.js @@ -0,0 +1,202 @@ +import { RTEngineClient } from "linebridge-client" +import SessionModel from "@models/session" + +export default class SyncRoom { + constructor(player) { + this.player = player + } + + static pushInterval = 1000 + static maxTimeOffset = parseFloat(0.15) + + state = { + joined_room: null, + last_track_id: null, + } + + pushInterval = null + + socket = null + + start = async () => { + if (!this.socket) { + await this.createSocket() + } + + await this.pushState() + setInterval(this.pushState, SyncRoom.pushInterval) + + this.player.eventBus.on("player.state.update", this.pushState) + + this.socket.on( + `sync_room:${app.userData._id}:request_lyrics`, + async () => { + let lyrics = null + + if (this.player.queue.currentItem) { + lyrics = + await this.player.queue.currentItem.manifest.serviceOperations.fetchLyrics( + { + preferTranslation: false, + }, + ) + } + + this.socket.emit( + `sync_room:${app.userData._id}:request_lyrics`, + lyrics, + ) + }, + ) + } + + stop = async () => { + if (this.pushInterval) { + clearInterval(this.pushInterval) + } + + if (this.socket) { + await this.socket.destroy() + } + } + + pushState = async () => { + if (!this.socket) { + return null + } + + let track_manifest = null + const currentItem = this.player.queue.currentItem + + if (currentItem) { + track_manifest = { + ...currentItem.toSeriableObject(), + } + } + + // check if has changed the track + if ( + this.state.last_track_id && + this.state.last_track_id !== track_manifest?._id + ) { + // try to get lyrics + const lyrics = await currentItem.serviceOperations + .fetchLyrics() + .catch(() => null) + + this.socket.emit(`sync_room:push_lyrics`, lyrics) + } + + this.state.last_track_id = track_manifest?._id + + await this.socket.emit(`sync_room:push`, { + ...this.player.state, + track_manifest: track_manifest, + duration: this.player.duration(), + currentTime: this.player.seek(), + }) + } + + syncState = async (data) => { + console.log(data) + + if (!data || !data.track_manifest) { + return false + } + + // first check if manifest id is different + if ( + !this.player.state.track_manifest || + data.track_manifest._id !== this.player.state.track_manifest._id + ) { + if (data.track_manifest && data.track_manifest.encoded_manifest) { + let mpd = new Blob( + [window.atob(data.track_manifest.encoded_manifest)], + { + type: "application/dash+xml", + }, + ) + + data.track_manifest.dash_manifest = URL.createObjectURL(mpd) + } + + // start the player + this.player.start(data.track_manifest) + } + + // check if currentTime is more than maxTimeOffset + const serverTime = data.currentTime ?? 0 + const currentTime = this.player.seek() + const offset = serverTime - currentTime + + console.log({ + serverTime: serverTime, + currentTime: currentTime, + maxTimeOffset: SyncRoom.maxTimeOffset, + offset: offset, + }) + + if ( + typeof serverTime === "number" && + typeof currentTime === "number" && + Math.abs(offset) > SyncRoom.maxTimeOffset + ) { + // seek to currentTime + this.player.seek(serverTime) + } + + // check if playback is paused + if ( + !app.cores.player.base().audio.paused && + data.playback_status === "paused" + ) { + this.player.pausePlayback() + } + + if ( + app.cores.player.base().audio.paused && + data.playback_status === "playing" + ) { + this.player.resumePlayback() + } + } + + join = async (user_id) => { + if (!this.socket) { + await this.createSocket() + } + + this.socket.emit(`sync_room:join`, user_id) + + this.socket.on(`sync:receive`, this.syncState) + + this.state.joined_room = { + user_id: user_id, + members: [], + } + } + + leave = async () => { + await this.socket.emit(`sync_room:leave`, this.state.joined_room) + + this.state.joined_room = null + + if (this.socket) { + await this.socket.disconnect() + } + } + + createSocket = async () => { + if (this.socket) { + await this.socket.disconnect() + } + + this.socket = new RTEngineClient({ + refName: "sync-room", + url: app.cores.api.client().mainOrigin + "/music", + token: SessionModel.token, + }) + + await this.socket.connect() + } +} diff --git a/packages/app/src/cores/player/classes/TrackInstance.js b/packages/app/src/cores/player/classes/TrackInstance.js deleted file mode 100644 index d8069ba6..00000000 --- a/packages/app/src/cores/player/classes/TrackInstance.js +++ /dev/null @@ -1,100 +0,0 @@ -import TrackManifest from "./TrackManifest" - -export default class TrackInstance { - constructor(manifest, player) { - if (typeof manifest === "undefined") { - throw new Error("Manifest is required") - } - - if (!player) { - throw new Error("Player core is required") - } - - if (!(manifest instanceof TrackManifest)) { - manifest = new TrackManifest(manifest, player) - } - - if (!manifest.source) { - throw new Error("Manifest must have a source") - } - - this.player = player - this.manifest = manifest - - this.id = this.manifest.id ?? this.manifest._id - } - - play = async (params = {}) => { - const startTime = performance.now() - - const isMpd = this.manifest.source.endsWith(".mpd") - const audioEl = this.player.base.audio - - if (!isMpd) { - // if a demuxer exists (from a previous MPD track), destroy it - if (this.player.base.demuxer) { - this.player.base.demuxer.destroy() - this.player.base.demuxer = null - } - - // set the audio source directly - if (audioEl.src !== this.manifest.source) { - audioEl.src = this.manifest.source - audioEl.load() // important to apply the new src and stop previous playback - } - } else { - // ensure the direct 'src' attribute is removed if it was set - const currentSrc = audioEl.getAttribute("src") - - if (currentSrc && !currentSrc.startsWith("blob:")) { - // blob: indicates MSE is likely already in use - audioEl.removeAttribute("src") - audioEl.load() // tell the element to update its state after src removal - } - - // ensure a demuxer instance exists - if (!this.player.base.demuxer) { - this.player.base.createDemuxer() - } - - // attach the mpd source to the demuxer - await this.player.base.demuxer.attachSource(this.manifest.source) - } - - // reset audio properties - audioEl.currentTime = params.time ?? 0 - audioEl.volume = 1 - - if (this.player.base.processors && this.player.base.processors.gain) { - this.player.base.processors.gain.set(this.player.state.volume) - } - - if (audioEl.paused) { - try { - await audioEl.play() - } catch (error) { - console.error("[INSTANCE] Error during audio.play():", error) - } - } else { - console.log( - "[INSTANCE] Audio is already playing or will start shortly.", - ) - } - - this._loadMs = performance.now() - startTime - - console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this) - } - - pause = async () => { - console.log("[INSTANCE] Pausing >", this) - - this.player.base.audio.pause() - } - - resume = async () => { - console.log("[INSTANCE] Resuming >", this) - - this.player.base.audio.play() - } -} diff --git a/packages/app/src/cores/player/classes/TrackManifest.js b/packages/app/src/cores/player/classes/TrackManifest.js index a2dd06b1..2bd15498 100644 --- a/packages/app/src/cores/player/classes/TrackManifest.js +++ b/packages/app/src/cores/player/classes/TrackManifest.js @@ -27,16 +27,32 @@ export default class TrackManifest { if (typeof params.album !== "undefined") { this.album = params.album + + if (typeof this.album === "object") { + this.album = this.album.title + } } if (typeof params.artist !== "undefined") { this.artist = params.artist + + if (typeof this.artist === "object") { + this.artist = this.artist.name + } } if (typeof params.source !== "undefined") { this.source = params.source } + if (typeof params.dash_manifest !== "undefined") { + this.dash_manifest = params.dash_manifest + } + + if (typeof params.encoded_manifest !== "undefined") { + this.encoded_manifest = params.encoded_manifest + } + if (typeof params.metadata !== "undefined") { this.metadata = params.metadata } @@ -45,6 +61,15 @@ export default class TrackManifest { this.liked = params.liked } + if (typeof params.public !== "undefined") { + this.public = params.public + } + + if (this.source) { + this.mpd_mode = + this.source.startsWith("blob:") || this.source.endsWith(".mpd") + } + return this } @@ -60,9 +85,10 @@ export default class TrackManifest { // set default service to default service = "default" + mpd_mode = false async initialize() { - if (!this.params.file) { + if (!this.params.file || !(this.params.file instanceof File)) { return this } @@ -93,7 +119,12 @@ export default class TrackManifest { analyzeCoverColor = async () => { const fac = new FastAverageColor() - return await fac.getColorAsync(this.cover) + const img = new Image() + + img.src = this.cover + "?t=a" + img.crossOrigin = "anonymous" + + return await fac.getColorAsync(img) } serviceOperations = { @@ -164,8 +195,11 @@ export default class TrackManifest { album: this.album, artist: this.artist, source: this.source, + dash_manifest: this.dash_manifest, + encoded_manifest: this.encoded_manifest, metadata: this.metadata, liked: this.liked, + service: this.service, } } } diff --git a/packages/app/src/cores/player/helpers/findInitializationChunk.js b/packages/app/src/cores/player/helpers/findInitializationChunk.js new file mode 100644 index 00000000..1b50ae93 --- /dev/null +++ b/packages/app/src/cores/player/helpers/findInitializationChunk.js @@ -0,0 +1,81 @@ +export default (baseUri, mpdText, periodId = null, repId = null) => { + // parse xml + const parser = new DOMParser() + const xml = parser.parseFromString(mpdText, "application/xml") + + // check parse errors + const err = xml.querySelector("parsererror") + + if (err) { + console.error("Failed to parse MPD:", err.textContent) + return null + } + + // select period (by ID or first) + let period = null + + if (periodId) { + period = xml.querySelector(`Period[id="${periodId}"]`) + } + + // if not found, select first + if (!period) { + period = xml.querySelector("Period") + } + + // ultimately, return err + if (!period) { + console.error("Cannot find a