diff --git a/frontend/js/src/brainzplayer/AppleMusicPlayer.tsx b/frontend/js/src/brainzplayer/AppleMusicPlayer.tsx new file mode 100644 index 0000000000..e5a320a58e --- /dev/null +++ b/frontend/js/src/brainzplayer/AppleMusicPlayer.tsx @@ -0,0 +1,360 @@ +/* eslint-disable no-underscore-dangle */ +import * as React from "react"; +import { debounce as _debounce, get as _get, isString } from "lodash"; +import { faApple } from "@fortawesome/free-brands-svg-icons"; +import { getArtistName, getTrackName, loadScriptAsync } from "../utils/utils"; +import { DataSourceProps, DataSourceType } from "./BrainzPlayer"; + +export type AppleMusicPlayerProps = DataSourceProps & { + appleMusicUser?: AppleMusicUser; +}; + +export type AppleMusicPlayerState = { + currentAppleMusicTrack?: any; + progressMs: number; + durationMs: number; +}; + +export default class AppleMusicPlayer + extends React.Component + implements DataSourceType { + static hasPermissions = (appleMusicUser?: AppleMusicUser) => { + return true; + }; + + static isListenFromThisService = (listen: Listen | JSPFTrack): boolean => { + // Retro-compatibility: listening_from has been deprecated in favor of music_service + const listeningFrom = _get( + listen, + "track_metadata.additional_info.listening_from" + ); + const musicService = _get( + listen, + "track_metadata.additional_info.music_service" + ); + return ( + (isString(listeningFrom) && + listeningFrom.toLowerCase() === "apple_music") || + (isString(musicService) && + musicService.toLowerCase() === "music.apple.com") || + Boolean(AppleMusicPlayer.getURLFromListen(listen)) + ); + }; + + static getURLFromListen(listen: Listen | JSPFTrack): string | undefined { + return _get(listen, "track_metadata.additional_info.apple_music_id"); + } + + public name = "Apple Music"; + public domainName = "music.apple.com"; + public icon = faApple; + // Saving the access token outside of React state , we do not need it for any rendering purposes + // and it simplifies some of the closure issues we've had with old tokens. + private accessToken = ""; + + private readonly _boundOnPlaybackStateChange: (event: any) => any; + private readonly _boundOnPlaybackTimeChange: (event: any) => any; + private readonly _boundOnPlaybackDurationChange: (event: any) => any; + private readonly _boundOnNowPlayingItemChange: (event: any) => any; + + appleMusicPlayer?: AppleMusicPlayerType; + debouncedOnTrackEnd: () => void; + + constructor(props: AppleMusicPlayerProps) { + super(props); + this.state = { + durationMs: 0, + progressMs: 0, + }; + + this.accessToken = props.appleMusicUser?.music_user_token || ""; + + this.debouncedOnTrackEnd = _debounce(props.onTrackEnd, 700, { + leading: true, + trailing: false, + }); + + // Do an initial check of the AppleMusic token permissions (scopes) before loading the SDK library + if (AppleMusicPlayer.hasPermissions(props.appleMusicUser)) { + window.addEventListener("musickitloaded", this.connectAppleMusicPlayer); + loadScriptAsync( + document, + "https://js-cdn.music.apple.com/musickit/v3/musickit.js", + true + ); + } else { + this.handleAccountError(); + } + + this._boundOnPlaybackStateChange = this.onPlaybackStateChange.bind(this); + this._boundOnPlaybackTimeChange = this.onPlaybackTimeChange.bind(this); + this._boundOnPlaybackDurationChange = this.onPlaybackDurationChange.bind( + this + ); + this._boundOnNowPlayingItemChange = this.onNowPlayingItemChange.bind(this); + } + + componentDidUpdate(prevProps: DataSourceProps) { + const { show } = this.props; + if (prevProps.show && !show) { + this.stopAndClear(); + } + } + + componentWillUnmount(): void { + this.disconnectAppleMusicPlayer(); + } + + playAppleMusicId = async ( + appleMusicId: string, + retryCount = 0 + ): Promise => { + const { handleError } = this.props; + if (retryCount > 5) { + handleError("Could not play AppleMusic track", "Playback error"); + return; + } + if (!this.appleMusicPlayer) { + await this.connectAppleMusicPlayer(); + return; + } + try { + await this.appleMusicPlayer.setQueue({ + song: appleMusicId, + startPlaying: true, + }); + } catch (error) { + handleError(error.message, "Error playing on Apple Music"); + } + }; + + canSearchAndPlayTracks = (): boolean => { + return true; + }; + + searchAndPlayTrack = async (listen: Listen | JSPFTrack): Promise => { + if (!this.appleMusicPlayer) { + return; + } + const trackName = getTrackName(listen); + const artistName = getArtistName(listen); + const releaseName = _get(listen, "track_metadata.release_name"); + const searchTerm = `${trackName} ${artistName} ${releaseName}`; + if (!searchTerm) { + return; + } + const response = await this.appleMusicPlayer.api.music( + `/v1/catalog/us/search`, + { term: searchTerm, types: "songs" } + ); + const apple_music_id = response?.data?.results?.songs?.data?.[0]?.id; + // eslint-disable-next-line no-console + console.log("Apple Music Id:", apple_music_id); + if (apple_music_id) { + await this.playAppleMusicId(apple_music_id); + } + }; + + datasourceRecordsListens = (): boolean => { + return false; + }; + + playListen = (listen: Listen | JSPFTrack): void => { + const { show } = this.props; + if (!show) { + return; + } + const apple_music_id = AppleMusicPlayer.getURLFromListen(listen as Listen); + // eslint-disable-next-line no-console + console.log("Apple Music Id:", apple_music_id); + if (apple_music_id) { + this.playAppleMusicId(apple_music_id); + return; + } + this.searchAndPlayTrack(listen); + }; + + togglePlay = (): void => { + if ( + this.appleMusicPlayer.playbackState === MusicKit.PlaybackStates.playing || + this.appleMusicPlayer.playbackState === MusicKit.PlaybackStates.loading + ) { + this.appleMusicPlayer.pause(); + } else { + this.appleMusicPlayer.play(); + } + }; + + stopAndClear = (): void => { + // eslint-disable-next-line react/no-unused-state + this.setState({ currentAppleMusicTrack: undefined }); + if (this.appleMusicPlayer) { + this.appleMusicPlayer.pause(); + } + }; + + handleAccountError = (): void => { + const errorMessage = ( +

+ In order to play music with AppleMusic, you will need a AppleMusic + Premium account linked to your ListenBrainz account. +
+ Please try to{" "} + + link for "playing music" feature + {" "} + and refresh this page +

+ ); + const { onInvalidateDataSource } = this.props; + onInvalidateDataSource(this, errorMessage); + }; + + seekToPositionMs = (msTimecode: number): void => { + const timeCode = Math.floor(msTimecode / 1000); + this.appleMusicPlayer.seekToTime(timeCode); + }; + + disconnectAppleMusicPlayer = (): void => { + if (!this.appleMusicPlayer) { + return; + } + this.appleMusicPlayer.removeEventListener( + "playbackStateDidChange", + this._boundOnPlaybackStateChange + ); + this.appleMusicPlayer.removeEventListener( + "playbackTimeDidChange", + this._boundOnPlaybackTimeChange + ); + this.appleMusicPlayer.removeEventListener( + "playbackDurationDidChange", + this._boundOnPlaybackDurationChange + ); + this.appleMusicPlayer.removeEventListener( + "nowPlayingItemDidChange", + this._boundOnNowPlayingItemChange + ); + this.appleMusicPlayer = null; + }; + + connectAppleMusicPlayer = async (): Promise => { + this.disconnectAppleMusicPlayer(); + + const musickit = window.MusicKit; + if (!musickit) { + setTimeout(this.connectAppleMusicPlayer.bind(this), 1000); + return; + } + await musickit.configure({ + developerToken: "developer token here", + app: { + name: "ListenBrainz", + build: "latest", + }, + }); + this.appleMusicPlayer = musickit.getInstance(); + await this.appleMusicPlayer.authorize(); + this.appleMusicPlayer.addEventListener( + "playbackStateDidChange", + this._boundOnPlaybackStateChange + ); + this.appleMusicPlayer.addEventListener( + "playbackTimeDidChange", + this._boundOnPlaybackTimeChange + ); + this.appleMusicPlayer.addEventListener( + "playbackDurationDidChange", + this._boundOnPlaybackDurationChange + ); + this.appleMusicPlayer.addEventListener( + "nowPlayingItemDidChange", + this._boundOnNowPlayingItemChange + ); + }; + + onPlaybackStateChange = ({ state: currentState }: any) => { + const { onPlayerPausedChange, onTrackEnd } = this.props; + if (currentState === MusicKit.PlaybackStates.playing) { + onPlayerPausedChange(false); + } + if (currentState === MusicKit.PlaybackStates.paused) { + onPlayerPausedChange(true); + } + if (currentState === MusicKit.PlaybackStates.completed) { + onTrackEnd(); + } + }; + + onPlaybackTimeChange = ({ currentPlaybackTime }: any) => { + const { onProgressChange } = this.props; + const { progressMs } = this.state; + const currentPlaybackTimeMs = currentPlaybackTime * 1000; + if (progressMs !== currentPlaybackTimeMs) { + this.setState({ progressMs: currentPlaybackTimeMs }); + onProgressChange(currentPlaybackTimeMs); + } + }; + + onPlaybackDurationChange = ({ duration }: any) => { + const { onDurationChange } = this.props; + const { durationMs } = this.state; + const currentDurationMs = duration * 1000; + if (durationMs !== currentDurationMs) { + this.setState({ durationMs: currentDurationMs }); + onDurationChange(currentDurationMs); + } + }; + + onNowPlayingItemChange = ({ item }: any) => { + if (!item) { + return; + } + const { onTrackInfoChange } = this.props; + const { name, artistName, albumName, url, artwork } = item.attributes; + let mediaImages: Array | undefined; + if (artwork) { + mediaImages = [ + { + src: artwork.url + .replace("{w}", artwork.width) + .replace("{h}", artwork.height), + sizes: `${artwork.width}x${artwork.height}`, + }, + ]; + } + onTrackInfoChange(name, url, artistName, albumName, mediaImages); + this.setState({ currentAppleMusicTrack: item }); + // eslint-disable-next-line no-console + console.log("Now Playing Item Change:", item); + }; + + getAlbumArt = (): JSX.Element | null => { + const { currentAppleMusicTrack } = this.state; + if ( + !currentAppleMusicTrack || + !currentAppleMusicTrack.attributes || + !currentAppleMusicTrack.attributes.artwork + ) { + return null; + } + const { artwork } = currentAppleMusicTrack.attributes; + return ( + coverart + ); + }; + + render() { + const { show } = this.props; + if (!show) { + return null; + } + return
{this.getAlbumArt()}
; + } +} diff --git a/frontend/js/src/brainzplayer/BrainzPlayer.tsx b/frontend/js/src/brainzplayer/BrainzPlayer.tsx index 007532a54e..afd9eca3df 100644 --- a/frontend/js/src/brainzplayer/BrainzPlayer.tsx +++ b/frontend/js/src/brainzplayer/BrainzPlayer.tsx @@ -3,7 +3,6 @@ import { isEqual as _isEqual, isNil as _isNil, isString as _isString, - get as _get, has as _has, throttle as _throttle, assign, @@ -20,6 +19,7 @@ import GlobalAppContext from "../utils/GlobalAppContext"; import SpotifyPlayer from "./SpotifyPlayer"; import YoutubePlayer from "./YoutubePlayer"; import SoundcloudPlayer from "./SoundcloudPlayer"; +import AppleMusicPlayer from "./AppleMusicPlayer"; import { hasNotificationPermission, createNotification, @@ -41,7 +41,11 @@ export type DataSourceType = { datasourceRecordsListens: () => boolean; }; -export type DataSourceTypes = SpotifyPlayer | YoutubePlayer | SoundcloudPlayer; +export type DataSourceTypes = + | SpotifyPlayer + | YoutubePlayer + | SoundcloudPlayer + | AppleMusicPlayer; export type DataSourceProps = { show: boolean; @@ -112,7 +116,7 @@ function isListenFromDatasource( if (datasource instanceof SoundcloudPlayer) { return SoundcloudPlayer.isListenFromThisService(listen); } - return undefined; + return AppleMusicPlayer.isListenFromThisService(listen); } export default class BrainzPlayer extends React.Component< @@ -125,6 +129,7 @@ export default class BrainzPlayer extends React.Component< spotifyPlayer?: React.RefObject; youtubePlayer?: React.RefObject; soundcloudPlayer?: React.RefObject; + appleMusicPlayer?: React.RefObject; dataSources: Array> = []; playerStateTimerID?: NodeJS.Timeout; @@ -146,13 +151,16 @@ export default class BrainzPlayer extends React.Component< super(props); this.spotifyPlayer = React.createRef(); - this.dataSources.push(this.spotifyPlayer); + // this.dataSources.push(this.spotifyPlayer); this.youtubePlayer = React.createRef(); - this.dataSources.push(this.youtubePlayer); + // this.dataSources.push(this.youtubePlayer); this.soundcloudPlayer = React.createRef(); - this.dataSources.push(this.soundcloudPlayer); + // this.dataSources.push(this.soundcloudPlayer); + + this.appleMusicPlayer = React.createRef(); + this.dataSources.push(this.appleMusicPlayer); this.state = { currentDataSourceIndex: 0, @@ -886,6 +894,25 @@ export default class BrainzPlayer extends React.Component< handleWarning={this.handleWarning} handleSuccess={this.handleSuccess} /> + { - this.createWebsocketsConnection(); - this.addWebsocketsHandlers(); + // this.createWebsocketsConnection(); + // this.addWebsocketsHandlers(); }; createWebsocketsConnection = (): void => { diff --git a/frontend/js/src/utils/musickit.d.ts b/frontend/js/src/utils/musickit.d.ts new file mode 100644 index 0000000000..f76bf4ba60 --- /dev/null +++ b/frontend/js/src/utils/musickit.d.ts @@ -0,0 +1,668 @@ +// eslint-disable-next-line max-classes-per-file +interface Window { + MusicKit: typeof MusicKit.MusicKitInstance; +} + +declare namespace MusicKit { + /** + * This class represents the Apple Music API. + */ + interface API { + /** + * Search the catalog using a query. + * + * @param term The term to search. + * @param parameters A query parameters object that is serialized and passed + * directly to the Apple Music API. + */ + search(term: string, parameters?: QueryParameters): Promise; + } + + interface Resource { + [key: string]: any; + } + + interface QueryParameters { + [key: string]: any; + } + + /** + * An object that represents artwork. + */ + interface Artwork { + bgColor: string; + height: number; + width: number; + textColor1: string; + textColor2: string; + textColor3: string; + textColor4: string; + url: string; + } + + /** + * A class that describes an error that may occur when using MusicKit JS, + * including server and local errors. + */ + class MKError extends Error { + /** + * The error code for this error. + */ + errorCode: string; + /** + * A description of the error that occurred. + */ + description?: string; + /** + * Error code indicating that you don't have permission to access the + * endpoint, media item, or content. + */ + static ACCESS_DENIED: string; + /** + * Error code indicating the authorization was rejected. + */ + static AUTHORIZATION_ERROR: string; + /** + * Error code indicating a MusicKit JS configuration error. + */ + static CONFIGURATION_ERROR: string; + /** + * Error code indicating you don't have permission to access this content, + * due to content restrictions. + */ + static CONTENT_RESTRICTED: string; + /** + * Error code indicating the parameters provided for this method are invalid. + */ + static INVALID_ARGUMENTS: string; + /** + * Error code indicating that the VM certificate could not be applied. + */ + static MEDIA_CERTIFICATE: string; + /** + * Error code indicating that the media item descriptor is invalid. + */ + static MEDIA_DESCRIPTOR: string; + /** + * Error code indicating that a DRM key could not be generated. + */ + static MEDIA_KEY: string; + /** + * Error code indicating a DRM license error. + */ + static MEDIA_LICENSE: string; + /** + * Error code indicating a media playback error. + */ + static MEDIA_PLAYBACK: string; + /** + * Error code indicating that an EME session could not be created. + */ + static MEDIA_SESSION: string; + /** + * Error code indicating a network error. + */ + static NETWORK_ERROR: string; + /** + * Error code indicating that the resource was not found. + */ + static NOT_FOUND: string; + /** + * Error code indicating that you have exceeded the Apple Music API quota. + */ + static QUOTA_EXCEEDED: string; + static SERVER_ERROR: string; + /** + * Error code indicating the MusicKit service could not be reached. + */ + static SERVICE_UNAVAILABLE: string; + /** + * Error code indicating that the user's Apple Music subscription has expired. + */ + static SUBSCRIPTION_ERROR: string; + /** + * Error code indicating an unknown error. + */ + static UNKNOWN_ERROR: string; + /** + * Error code indicating that the operation is not supported. + */ + static UNSUPPORTED_ERROR: string; + } + + /** + * An array of media items to be played. + */ + interface Queue { + /** + * A Boolean value indicating whether the queue has no items. + */ + readonly isEmpty: boolean; + /** + * An array of all the media items in the queue. + */ + readonly items: MediaItem[]; + /** + * The number of items in the queue. + */ + readonly length: number; + /** + * The next playable media item in the queue. + */ + readonly nextPlayableItem?: MediaItem; + /** + * The current queue position. + */ + readonly position: number; + /** + * The previous playable media item in the queue. + */ + readonly previousPlayableItem?: MediaItem; + + /** + * Add an event listener for a MusicKit queue by name. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + addEventListener(name: string, callback: () => any): void; + /** + * Inserts the media items defined by the queue descriptor after the last + * media item in the current queue. + */ + append(descriptor: descriptor): void; + /** + * Returns the index in the playback queue for a media item descriptor. + * + * @param descriptor A descriptor can be an instance of the MusicKit.MediaItem + * class, or a string identifier. + */ + indexForItem(descriptor: descriptor): number; + /** + * Returns the media item located in the indicated array index. + */ + item(index: number): MediaItem | null | undefined; + /** + * Inserts the media items defined by the queue descriptor into the current + * queue immediately after the currently playing media item. + */ + prepend(descriptor: any): void; + /** + * Removes an event listener for a MusicKit queue by name. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + removeEventListener(name: string, callback: () => any): void; + } + + /** + * A media player that represents the media player for a MusicKit instance. + */ + interface Player { + /** + * The current bit rate of the music player. + */ + readonly bitrate: number; + /** + * The music player has EME loaded. + */ + readonly canSupportDRM: boolean; + /** + * The current playback duration. + */ + readonly currentPlaybackDuration: number; + /** + * The current playback progress. + */ + readonly currentPlaybackProgress: number; + /** + * The current position of the playhead. + */ + readonly currentPlaybackTime: number; + /** + * No description available. + */ + readonly currentPlaybackTimeRemaining: number; + /** + * The current playback duration in hours and minutes. + */ + readonly formattedCurrentPlaybackDuration: FormattedPlaybackDuration; + /** + * A Boolean value indicating whether the player is currently playing. + */ + readonly isPlaying: boolean; + /** + * The currently-playing media item, or the media item, within an queue, + * that you have designated to begin playback. + */ + readonly nowPlayingItem: MediaItem; + /** + * The index of the now playing item in the current playback queue. + */ + readonly nowPlayingItemIndex?: number; + /** + * The current playback rate for the player. + */ + readonly playbackRate: number; + /** + * The current playback state of the music player. + */ + readonly playbackState: PlaybackStates; + /** + * A Boolean value that indicates whether a playback target is available. + */ + readonly playbackTargetAvailable?: boolean; + /** + * The current playback queue of the music player. + */ + readonly queue: Queue; + /** + * The current repeat mode of the music player. + */ + repeatMode: PlayerRepeatMode; + /** + * The current shuffle mode of the music player. + */ + shuffleMode: PlayerShuffleMode; + /** + * A number indicating the current volume of the music player. + */ + volume: number; + /** + * Adds an event listener as a callback for an event name. + * + * @param name The name of the event. + * @param callback The callback function to invoke when the event occurs. + */ + addEventListener(name: string, callback: () => any): void; + /** + * Begins playing the media item at the specified index in the queue immediately. + * + * @param index The queue index to begin playing media. + */ + changeToMediaAtIndex(index: number): Promise; + /** + * Begins playing the media item in the queue immediately. + * + * @param descriptor descriptor can be a MusicKit.MediaItem instance or a + * string identifier. + */ + changeToMediaItem(descriptor: descriptor): Promise; + /** + * Sets the volume to 0. + */ + mute(): void; + /** + * Pauses playback of the current item. + */ + pause(): void; + /** + * Initiates playback of the current item. + */ + play(): Promise; + /** + * Prepares a music player for playback. + * + * @param descriptor descriptor can be a MusicKit.MediaItem instance or a + * string identifier. + */ + prepareToPlay(descriptor: descriptor): Promise; + /** + * No description available. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + removeEventListener(name: string, callback: () => any): void; + /** + * Sets the playback point to a specified time. + * + * @param time The time to set as the playback point. + */ + seekToTime(time: number): Promise; + /** + * Displays the playback target picker if a playback target is available. + */ + showPlaybackTargetPicker(): void; + /** + * Starts playback of the next media item in the playback queue. + */ + skipToNextItem(): Promise; + /** + * Starts playback of the previous media item in the playback queue. + */ + skipToPreviousItem(): Promise; + /** + * Stops the currently playing media item. + */ + stop(): void; + } + + /** + * The playback states of the music player. + */ + enum PlaybackStates { + none, + loading, + playing, + paused, + stopped, + ended, + seeking, + waiting, + stalled, + completed, + } + + /** + * The playback bit rate of the music player. + */ + enum PlaybackBitrate { + HIGH = 256, + STANDARD = 64, + } + + // enum is not exposed via the MusicKit namespace + type PlayerRepeatMode = 0 | 1 | 2; + + // enum is not exposed via the MusicKit namespace + type PlayerShuffleMode = 0 | 1; + + type MediaItemPosition = number; + + /** + * This class represents a single media item. + */ + class MediaItem { + /** + * A constructor that creates a new media item from specified options. + */ + constructor(options?: MediaItemOptions); + /** + * Prepares a media item for playback. + */ + prepareToPlay(): Promise; + /** + * A string of information about the album. + */ + readonly albumInfo: string; + /** + * The title of the album. + */ + readonly albumName: string; + /** + * The artist for a media item. + */ + readonly artistName: string; + /** + * The artwork object for the media item. + */ + readonly artwork: Artwork; + /** + * The artwork image for the media item. + */ + readonly artworkURL: string; + /** + * The attributes object for the media item. + */ + readonly attributes: any; + /** + * A string containing the content rating for the media item. + */ + readonly contentRating: string; + /** + * The disc number where the media item appears. + */ + readonly discNumber: number; + /** + * The identifier for the media item. + */ + readonly id: string; + /** + * A string of common information about the media item. + */ + readonly info: string; + /** + * A Boolean value that indicates whether the item has explicit lyrics or language. + */ + readonly isExplicitItem: boolean; + /** + * A Boolean value that indicated whether the item is playable. + */ + readonly isPlayable: boolean; + /** + * A Boolean value indicating whether the media item is prepared to play. + */ + readonly isPreparedToPlay: boolean; + /** + * The ISRC (International Standard Recording Code) for a media item. + */ + readonly isrc: string; + /** + * The playback duration of the media item. + */ + readonly playbackDuration: number; + readonly playlistArtworkURL: string; + readonly playlistName: string; + /** + * The URL to an unencrypted preview of the media item. + */ + readonly previewURL: string; + /** + * The release date of the media item. + */ + readonly releaseDate?: Date; + /** + * The name of the media item. + */ + readonly title: string; + /** + * The number of the media item in the album's track list. + */ + readonly trackNumber: number; + /** + * The type of the media item. + */ + type: any; + } + + /** + * The options to use when defining a media item. + */ + interface MediaItemOptions { + /** + * The attributes for the media item. + */ + attributes?: any; + /** + * The identifier for the media item. + */ + id?: string; + /** + * The type for the media item. + */ + type?: any; + } + + /** + * This property describes a media item. + */ + type descriptor = MediaItem | string; + + /** + * The options to use when defining a media item. + */ + interface MediaItemOptions { + /** + * The attributes for the media item. + */ + attributes?: any; + /** + * The identifier for the media item. + */ + id?: string; + /** + * The type for the media item. + */ + type?: any; + } + + + interface SetQueueOptions { + /** + * The catalog album used to set a music player's playback queue. + */ + album?: string; + /** + * The media items used to set a music player's playback queue. + */ + items?: descriptor[]; + /** + * The parameters used to set a music player's playback queue. + */ + parameters?: QueryParameters; + /** + * The playlist used to set a music player's playback queue. + */ + playlist?: string; + /** + * The song used to set a music player's playback queue. + */ + song?: string; + /** + * The songs used to set a music player's playback queue. + */ + songs?: string[]; + /** + * The start position for a set playback queue. + */ + startPosition?: number; + /** + * A content URL used to set a music player's playback queue. + */ + url?: string; + startPlaying?: boolean; + repeatMode?: boolean; + startTime?: number; + } + + /** + * This property describes a media item. + */ + type descriptor = MediaItem | string; + + /** + * This object provides access to a player instance, and through the player + * instance, access to control playback. + */ + interface MusicKitInstance { + /** + * An instance of the MusicKit API. + */ + readonly api: API; + /** + * An instance of the MusicKit API. + */ + readonly bitrate: PlaybackBitrate; + /** + * The developer token to identify yourself as a trusted developer and + * member of the Apple Developer Program. + */ + readonly developerToken: string; + /** + * A Boolean value indicating whether the user has authenticated and + * authorized the application for use. + */ + readonly isAuthorized: boolean; + /** + * A user token used to access personalized Apple Music content. + */ + readonly musicUserToken: string; + /** + * The current playback state of the music player. + */ + readonly playbackState: PlaybackStates; + /** + * A Boolean value that indicates if a playback target is available. + */ + readonly playbackTargetAvailable: boolean; + /** + * An instance of the MusicKit player. + */ + readonly player: Player; + /** + * The current storefront for the configured MusicKit instance. + */ + readonly storefrontId: string; + /** + * Add an event listener for a MusicKit instance by name. + * + * @param name The name of the event. + * @param callback The callback function to invoke when the event occurs. + */ + addEventListener(name: string, callback: () => any): void; + /** + * Returns a promise containing a music user token when a user has + * authenticated and authorized the app. + */ + authorize(): Promise; + /** + * Begins playing the media item at the specified index in the queue. + * + * @param index The queue index to begin playing media. + */ + changeToMediaAtIndex(index: number): Promise; + /** + * Pauses playback of the media player. + */ + pause(): void; + /** + * Begins playback of the current media item. + */ + play(): Promise; + /** + * No description available. + */ + playLater(options: SetQueueOptions): Promise; + /** + * No description available. + */ + playNext(options: SetQueueOptions): Promise; + /** + * Removes an event listener for a MusicKit instance by name. + * + * @param name The name of the event. + * @param callback The callback function to remove. + */ + removeEventListener(name: string, callback: () => any): void; + /** + * Sets the playback point to a specified time. + * + * @param time The time to set as the playback point. + */ + seekToTime(time: number): Promise; + /** + * Sets a music player's playback queue using queue options. + * + * @param options The option used to set the playback queue. + */ + setQueue(options: SetQueueOptions): Promise; + /** + * Starts playback of the next media item in the playback queue. + */ + skipToNextItem(): Promise; + /** + * Starts playback of the previous media item in the playback queue. + */ + skipToPreviousItem(): Promise; + /** + * Stops playback of the media player. + */ + stop(): void; + /** + * Unauthorizes the app for the current user. + */ + unauthorize(): Promise; + } +} diff --git a/frontend/js/src/utils/types.d.ts b/frontend/js/src/utils/types.d.ts index d4650235cb..caa8011397 100644 --- a/frontend/js/src/utils/types.d.ts +++ b/frontend/js/src/utils/types.d.ts @@ -2,6 +2,8 @@ declare module "react-responsive"; declare module "spotify-web-playback-sdk"; +declare module "musickit-typescript"; + declare module "time-ago"; declare module "debounce-async"; // declaration typescript file doesn't exist for react-datetime-picker/dist/entry.nostyle.js so had to declare a dummy declaration. @@ -98,6 +100,10 @@ declare type SpotifyUser = { permission?: Array; }; +declare type AppleMusicUser = { + music_user_token?: string; +}; + declare type YoutubeUser = { api_key?: string; }; @@ -216,6 +222,8 @@ declare type SpotifyPagingObject = { // TODO: remove this any eventually declare type SpotifyPlayerType = any | Spotify.SpotifyPlayer; +declare type AppleMusicPlayerType = any | MusicKit.MusicKitInstance; + // Expect either a string or an Error or an html Response object declare type BrainzPlayerError = | string diff --git a/frontend/js/src/utils/utils.tsx b/frontend/js/src/utils/utils.tsx index b2ec03d65e..260dad8b34 100644 --- a/frontend/js/src/utils/utils.tsx +++ b/frontend/js/src/utils/utils.tsx @@ -59,6 +59,52 @@ const searchForSpotifyTrack = async ( return null; }; +const searchForAppleMusicTrack = async ( + spotifyToken?: string, + trackName?: string, + artistName?: string, + releaseName?: string +): Promise => { + if (!spotifyToken) { + // eslint-disable-next-line no-throw-literal + throw { + status: 403, + message: "You need to connect to your Spotify account", + }; + } + let queryString = `type=track&q=`; + if (trackName) { + queryString += `track:${encodeURIComponent(trackName)}`; + } + if (artistName) { + queryString += ` artist:${encodeURIComponent(artistName)}`; + } + if (releaseName) { + queryString += ` album:${encodeURIComponent(releaseName)}`; + } + + const response = await fetch( + `https://api.spotify.com/v1/search?${queryString}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${spotifyToken}`, + }, + } + ); + const responseBody = await response.json(); + if (!response.ok) { + throw responseBody.error; + } + // Valid response + const tracks: SpotifyTrack[] = _.get(responseBody, "tracks.items"); + if (tracks && tracks.length) { + return tracks[0]; + } + return null; +}; + const searchForYoutubeTrack = async ( apiKey?: string, trackName?: string, @@ -405,12 +451,19 @@ const convertDateToUnixTimestamp = (date: Date): number => { }; /** Loads a script asynchronously into the HTML page */ -export function loadScriptAsync(document: any, scriptSrc: string): void { +export function loadScriptAsync( + document: any, + scriptSrc: string, + attribute?: any +): void { const el = document.createElement("script"); const container = document.head || document.body; el.type = "text/javascript"; el.async = true; el.src = scriptSrc; + if (attribute) { + el.dataset.webComponents = ""; + } container.appendChild(el); } @@ -864,6 +917,7 @@ export function getPersonalRecommendationEventContent( export { searchForSpotifyTrack, + searchForAppleMusicTrack, getArtistLink, getTrackLink, formatWSMessageToListen, diff --git a/package.json b/package.json index 96eb95a873..d13b20b673 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "rc-slider": "^10.1.0", "react": "^18.2.0", "react-bs-notifier": "^7.0.0", - "react-toastify": "^8.2.0", "react-datetime-picker": "^4.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.5", @@ -83,6 +82,7 @@ "react-select": "^3.1.0", "react-simple-star-rating": "^4.0.0", "react-sortablejs": "^6.1.4", + "react-toastify": "^8.2.0", "react-tooltip": "^4.2.21", "react-youtube": "^7.12.0", "socket.io-client": "^4.5.4",