diff --git a/.changeset/afraid-mayflies-hunt.md b/.changeset/afraid-mayflies-hunt.md deleted file mode 100644 index 2588afa7c57..00000000000 --- a/.changeset/afraid-mayflies-hunt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@audius/sdk": minor ---- - -Add programmable distribution config to stream_conditions diff --git a/.changeset/chilled-bobcats-sit.md b/.changeset/chilled-bobcats-sit.md deleted file mode 100644 index 662841f1e46..00000000000 --- a/.changeset/chilled-bobcats-sit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@audius/sdk": patch ---- - -Fix missing bearer token for PUT /users diff --git a/.changeset/four-peas-impress.md b/.changeset/four-peas-impress.md deleted file mode 100644 index 26804fcedb7..00000000000 --- a/.changeset/four-peas-impress.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@audius/sdk': major ---- - -Remove getPlaylistByHandleAndSlug in favor of getBulkPlaylists - -- Removes `sdk.playlists.getPlaylistByHandleAndSlug()` in favor of calling `sdk.playlists.getBulkPlaylists({ permalink: ['/handle/playlist/playlist-name-slug'] })` -- Changes return values of `CommentsAPI` to match other APIs, removing `success` param. diff --git a/.changeset/plenty-starfishes-walk.md b/.changeset/plenty-starfishes-walk.md deleted file mode 100644 index f0009b9bf19..00000000000 --- a/.changeset/plenty-starfishes-walk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@audius/sdk": patch ---- - -Fix cover art CID metadata properties for playlists and tracks. diff --git a/.changeset/purple-experts-allow.md b/.changeset/purple-experts-allow.md deleted file mode 100644 index 2421101869f..00000000000 --- a/.changeset/purple-experts-allow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@audius/sdk": patch ---- - -Fix UploadsApi to make start() a function diff --git a/package-lock.json b/package-lock.json index a6c1d8f6653..5a84d65c9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98247,12 +98247,12 @@ }, "devDependencies": { "@playwright/test": "1.42.1", - "@types/cross-spawn": "^6.0.6", + "@types/cross-spawn": "6.0.6", "@types/glob": "7.1.1", "@types/prompts": "2.4.2", "@types/tar": "6.1.11", "@types/validate-npm-package-name": "4.0.2", - "commander": "2.20.0", + "commander": "9.2.0", "execa": "2.0.3", "fast-glob": "3.3.1", "glob": "7.1.6", @@ -122486,12 +122486,12 @@ }, "packages/libs": { "name": "@audius/sdk-legacy", - "version": "6.0.20", + "version": "6.0.21", "license": "Apache-2.0", "dependencies": { "@audius/fixed-decimal": "0.2.1", "@audius/hedgehog": "3.0.0-alpha.1", - "@audius/sdk": "13.1.0", + "@audius/sdk": "14.0.0", "@audius/spl": "2.1.0", "@babel/core": "^7.23.7", "@babel/plugin-proposal-class-static-block": "7.21.0", @@ -130489,7 +130489,7 @@ }, "packages/sdk": { "name": "@audius/sdk", - "version": "13.1.0", + "version": "14.0.0", "license": "Apache-2.0", "dependencies": { "@audius/eth": "0.1.0", @@ -132628,10 +132628,10 @@ }, "packages/sp-actions": { "name": "@audius/sp-actions", - "version": "1.0.24", + "version": "1.0.25", "license": "Apache-2.0", "dependencies": { - "@audius/sdk-legacy": "6.0.20", + "@audius/sdk-legacy": "6.0.21", "@truffle/hdwallet-provider": "^1.2.2", "axios": "^0.21.0", "commander": "^6.2.1", diff --git a/packages/common/src/adapters/collection.ts b/packages/common/src/adapters/collection.ts index 68a3f86eb2f..831267c0f12 100644 --- a/packages/common/src/adapters/collection.ts +++ b/packages/common/src/adapters/collection.ts @@ -190,7 +190,7 @@ export const playlistMetadataForCreateWithSDK = ( parentalWarningType: input.parental_warning_type ?? undefined, ...('cover_art_sizes' in input ? { - coverArtCid: input.cover_art_sizes ?? '', + playlistImageSizesMultihash: input.cover_art_sizes ?? '', isImageAutogenerated: input.is_image_autogenerated ?? false } : {}) @@ -211,7 +211,7 @@ export const playlistMetadataForUpdateWithSDK = ( : undefined, playlistName: input.playlist_name ?? '', description: input.description ?? '', - coverArtCid: input.cover_art_sizes ?? '', + playlistImageSizesMultihash: input.cover_art_sizes ?? '', isPrivate: input.is_private ?? false } } diff --git a/packages/common/src/api/tan-query/upload/usePublishCollection.ts b/packages/common/src/api/tan-query/upload/usePublishCollection.ts index c0756744add..fd1a86d3130 100644 --- a/packages/common/src/api/tan-query/upload/usePublishCollection.ts +++ b/packages/common/src/api/tan-query/upload/usePublishCollection.ts @@ -103,11 +103,13 @@ const getPublishCollectionOptions = (context: PublishCollectionContext) => const metadata = albumMetadataForCreateWithSDK( params.collectionMetadata ) - metadata.playlistContents = publishedTracks.map((t) => ({ - timestamp: Math.round(Date.now() / 1000), - trackId: Id.parse(t.trackId), - metadataTimestamp: Math.round(Date.now() / 1000) - })) + metadata.playlistContents = publishedTracks + .filter((t) => !!t.trackId) + .map((t) => ({ + timestamp: Math.round(Date.now() / 1000), + trackId: t.trackId!, + metadataTimestamp: Math.round(Date.now() / 1000) + })) return await sdk.albums.createAlbum({ userId: Id.parse(userId), imageFile: coverArtFile, @@ -117,11 +119,13 @@ const getPublishCollectionOptions = (context: PublishCollectionContext) => const metadata = playlistMetadataForCreateWithSDK( params.collectionMetadata ) - metadata.playlistContents = publishedTracks.map((t) => ({ - timestamp: Math.round(Date.now() / 1000), - trackId: Id.parse(t.trackId), - metadataTimestamp: Math.round(Date.now() / 1000) - })) + metadata.playlistContents = publishedTracks + .filter((t) => !!t.trackId) + .map((t) => ({ + timestamp: Math.round(Date.now() / 1000), + trackId: t.trackId!, + metadataTimestamp: Math.round(Date.now() / 1000) + })) return await sdk.playlists.createPlaylist({ userId: Id.parse(userId), imageFile: coverArtFile, diff --git a/packages/common/src/store/account/sagas.ts b/packages/common/src/store/account/sagas.ts index beaef5b4241..e4f427ab383 100644 --- a/packages/common/src/store/account/sagas.ts +++ b/packages/common/src/store/account/sagas.ts @@ -362,6 +362,16 @@ function* fetchLocalAccountAsync() { users: [cachedAccountUser], queryClient }) + // Set walletAddresses so useCurrentAccount/useWalletAddresses work correctly. + // Without this, components that depend on currentUserWallet stay disabled. + const web3WalletAddress = wallet + yield* put( + setWalletAddresses({ currentUser: wallet, web3User: web3WalletAddress }) + ) + queryClient.setQueryData(getWalletAddressesQueryKey(), { + currentUser: wallet, + web3User: web3WalletAddress + }) queryClient.setQueryData(getAccountStatusQueryKey(), Status.SUCCESS) yield* put(fetchAccountSucceeded(cachedAccount)) } diff --git a/packages/create-audius-app/index.ts b/packages/create-audius-app/index.ts index 0b2707b75c8..1a8731ffdbb 100644 --- a/packages/create-audius-app/index.ts +++ b/packages/create-audius-app/index.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node import { cyan, green, red, bold } from 'picocolors' -import Commander from 'commander' +import { Command } from 'commander' import path from 'path' import prompts from 'prompts' import { createApp } from './create-app' +import type { ExampleType } from './helpers/examples' import { validateNpmName } from './helpers/validate-pkg' import packageJson from './package.json' import { isFolderEmpty } from './helpers/is-folder-empty' @@ -16,12 +17,12 @@ const handleSigTerm = () => process.exit(0) process.on('SIGINT', handleSigTerm) process.on('SIGTERM', handleSigTerm) -const program = new Commander.Command(packageJson.name) +const program = new Command(packageJson.name) .version(packageJson.version) - .arguments('') + .arguments('[project-directory]') .usage(`${green('')} [options]`) .action((name) => { - projectPath = name + projectPath = name ?? '' }) .option( '-e, --example [name]', @@ -89,7 +90,8 @@ async function run() { process.exit(1) } - if (program.example === true) { + const exampleOption = program.opts().example + if (exampleOption === true) { console.error( 'Please provide an example name, otherwise remove the example option.' ) @@ -107,7 +109,7 @@ async function run() { process.exit(1) } - const example = program.example.trim() + const example = (typeof exampleOption === 'string' ? exampleOption : 'react-hono').trim() as ExampleType await createApp({ appPath: resolvedProjectPath, diff --git a/packages/create-audius-app/package.json b/packages/create-audius-app/package.json index c73274472cd..61bf9b3605d 100644 --- a/packages/create-audius-app/package.json +++ b/packages/create-audius-app/package.json @@ -36,12 +36,12 @@ ], "devDependencies": { "@playwright/test": "1.42.1", - "@types/cross-spawn": "^6.0.6", + "@types/cross-spawn": "6.0.6", "@types/glob": "7.1.1", "@types/prompts": "2.4.2", "@types/tar": "6.1.11", "@types/validate-npm-package-name": "4.0.2", - "commander": "2.20.0", + "commander": "9.2.0", "execa": "2.0.3", "fast-glob": "3.3.1", "glob": "7.1.6", diff --git a/packages/discovery-provider/src/models/tracks/track.py b/packages/discovery-provider/src/models/tracks/track.py index 4a2e71c82f6..b7cb5ee556c 100644 --- a/packages/discovery-provider/src/models/tracks/track.py +++ b/packages/discovery-provider/src/models/tracks/track.py @@ -83,6 +83,7 @@ class Track(Base, RepresentableMixin): slot = Column(Integer) is_available = Column(Boolean, nullable=False, server_default=text("true")) allowed_api_keys = Column(ARRAY(String)) + access_authorities = Column(ARRAY(String)) is_stream_gated = Column(Boolean, nullable=False, server_default=text("false")) stream_conditions = Column(JSONB(True)) is_download_gated = Column(Boolean, nullable=False, server_default=text("false")) diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/track.py b/packages/discovery-provider/src/tasks/entity_manager/entities/track.py index 61ec1b343c4..eddee85c79a 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/track.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/track.py @@ -274,6 +274,16 @@ def populate_track_record_metadata(track_record: Track, track_metadata, handle, api_key.lower() for api_key in track_metadata["allowed_api_keys"] ] + elif key == "access_authorities": + if key in track_metadata: + if track_metadata[key] is None: + track_record.access_authorities = None + elif isinstance(track_metadata[key], list): + track_record.access_authorities = [ + str(addr).strip() + for addr in track_metadata["access_authorities"] + if isinstance(addr, str) + ] elif key == "stem_of": if "stem_of" in track_metadata and is_valid_json_field( track_metadata, "stem_of" diff --git a/packages/discovery-provider/src/tasks/entity_manager/utils.py b/packages/discovery-provider/src/tasks/entity_manager/utils.py index 2361780c820..fb1d12be219 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/utils.py +++ b/packages/discovery-provider/src/tasks/entity_manager/utils.py @@ -432,6 +432,14 @@ def parse_metadata(metadata: str, action: str, entity_type: str): cid = data["cid"] metadata_json = data["data"] + if entity_type == EntityType.TRACK and "access_authorities" in data: + if ( + "access_authorities" not in metadata_json + or metadata_json["access_authorities"] is None + ): + metadata_json = dict(metadata_json) + metadata_json["access_authorities"] = data["access_authorities"] + # Don't format metadata for UPDATEs # This is to support partial updates # Individual entities are responsible for updating existing records with metadata diff --git a/packages/discovery-provider/src/tasks/metadata.py b/packages/discovery-provider/src/tasks/metadata.py index 7e4870ab557..66a219ffee7 100644 --- a/packages/discovery-provider/src/tasks/metadata.py +++ b/packages/discovery-provider/src/tasks/metadata.py @@ -142,6 +142,7 @@ class TrackMetadata(TypedDict): producer_copyright_line: Optional[Copyright] parental_warning_type: Optional[str] allowed_api_keys: Optional[str] + access_authorities: Optional[List[str]] bpm: Optional[float] is_custom_bpm: Optional[bool] musical_key: Optional[str] @@ -203,6 +204,7 @@ class TrackMetadata(TypedDict): "producer_copyright_line": None, "parental_warning_type": None, "allowed_api_keys": None, + "access_authorities": None, "bpm": None, "is_custom_bpm": False, "musical_key": None, diff --git a/packages/libs/CHANGELOG.md b/packages/libs/CHANGELOG.md index 49b4c0973e0..ce7a81d4754 100644 --- a/packages/libs/CHANGELOG.md +++ b/packages/libs/CHANGELOG.md @@ -1,5 +1,17 @@ # @audius/sdk +## 6.0.21 + +### Patch Changes + +- Updated dependencies [71bb31b] +- Updated dependencies [71bb31b] +- Updated dependencies [a7a9e17] +- Updated dependencies [d864806] +- Updated dependencies [8f12bb7] +- Updated dependencies [6cb4b6f] + - @audius/sdk@14.0.0 + ## 6.0.20 ### Patch Changes diff --git a/packages/libs/package.json b/packages/libs/package.json index a9038c42939..f4ab2f316fd 100644 --- a/packages/libs/package.json +++ b/packages/libs/package.json @@ -1,6 +1,6 @@ { "name": "@audius/sdk-legacy", - "version": "6.0.20", + "version": "6.0.21", "audius": { "releaseSHA": "f1d70a2a0643c5c84d8ab053f70c1e0a2ec3ad49" }, @@ -44,7 +44,7 @@ "dependencies": { "@audius/fixed-decimal": "0.2.1", "@audius/hedgehog": "3.0.0-alpha.1", - "@audius/sdk": "13.1.0", + "@audius/sdk": "14.0.0", "@audius/spl": "2.1.0", "@babel/core": "^7.23.7", "@babel/plugin-proposal-class-static-block": "7.21.0", diff --git a/packages/mobile/src/components/image/CollectionImage.tsx b/packages/mobile/src/components/image/CollectionImage.tsx index 54ad8828a07..4369e516bb6 100644 --- a/packages/mobile/src/components/image/CollectionImage.tsx +++ b/packages/mobile/src/components/image/CollectionImage.tsx @@ -1,21 +1,36 @@ +import { useState } from 'react' + import { useCollection } from '@audius/common/api' import { useImageSize } from '@audius/common/hooks' import type { SquareSizes, ID } from '@audius/common/models' import { reachabilitySelectors } from '@audius/common/store' import type { Maybe } from '@audius/common/utils' +import type { LayoutChangeEvent } from 'react-native' +import { View } from 'react-native' import { useSelector } from 'react-redux' -import { Artwork, preload } from '@audius/harmony-native' +import { Artwork, IconImage, preload } from '@audius/harmony-native' import type { ImageProps } from '@audius/harmony-native' -import imageEmpty from 'app/assets/images/imageBlank2x.png' import { getLocalCollectionCoverArtPath } from 'app/services/offline-downloader' import { getCollectionDownloadStatus } from 'app/store/offline-downloads/selectors' import { OfflineDownloadStatus } from 'app/store/offline-downloads/slice' +import { useThemeColors } from 'app/utils/theme' import { primitiveToImageSource } from './primitiveToImageSource' const { getIsReachable } = reachabilitySelectors +const EMPTY_ICON_MIN = 12 +const EMPTY_ICON_MAX = 128 +const EMPTY_ICON_RATIO = 0.35 + +const hasValidArtwork = (artwork: unknown): boolean => + !!artwork && + typeof artwork === 'object' && + Object.entries(artwork as Record).some( + ([k, v]) => k !== 'mirrors' && typeof v === 'string' && v.length > 0 + ) + export const useLocalCollectionImageUri = (collectionId: Maybe) => { const collectionImageUri = useSelector((state) => { if (!collectionId) return null @@ -45,9 +60,17 @@ export const useCollectionImage = ({ collectionId: Maybe size: SquareSizes }) => { - const { data: artwork } = useCollection(collectionId, { - select: (collection) => collection.artwork + const { data: artworkData } = useCollection(collectionId, { + select: (collection) => + collection != null + ? { + artwork: collection.artwork, + hasNoArtwork: !hasValidArtwork(collection.artwork) + } + : undefined }) + const artwork = artworkData?.artwork + const hasNoArtwork = artworkData?.hasNoArtwork ?? false const { imageUrl, onError: onImageError } = useImageSize({ artwork, targetSize: size, @@ -57,30 +80,25 @@ export const useCollectionImage = ({ } }) - if (imageUrl === '') { - return { - source: imageEmpty, - isFallbackImage: true, - onError: onImageError - } + if (hasNoArtwork || artworkData === undefined) { + return { source: undefined, hasNoArtwork: true, onError: onImageError } } - // Return edited artwork from this session, if it exists - // TODO(PAY-3588) Update field once we've switched to another property name - // for local changes to artwork - // @ts-ignore if (artwork?.url) { return { - // @ts-ignore source: primitiveToImageSource(artwork.url), - isFallbackImage: false, + hasNoArtwork: false, onError: onImageError } } + if (imageUrl === '') { + return { source: undefined, hasNoArtwork: true, onError: onImageError } + } + return { source: primitiveToImageSource(imageUrl), - isFallbackImage: false, + hasNoArtwork: false, onError: onImageError } } @@ -97,11 +115,39 @@ type CollectionImageProps = { export const CollectionImage = (props: CollectionImageProps) => { const { collectionId, size, style, onLoad, onError, ...other } = props + const { staticWhite } = useThemeColors() + const [containerSize, setContainerSize] = useState({ w: 0, h: 0 }) const localCollectionImageUri = useLocalCollectionImageUri(collectionId) const collectionImageSource = useCollectionImage({ collectionId, size }) - const { source: loadedSource, onError: onImageError } = collectionImageSource - - const source = loadedSource ?? localCollectionImageUri + const { + source: loadedSource, + onError: onImageError, + hasNoArtwork + } = collectionImageSource + + const onEmptyStateLayout = (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout + setContainerSize((prev) => + prev.w === width && prev.h === height ? prev : { w: width, h: height } + ) + } + const emptyIconSize = + containerSize.w > 0 && containerSize.h > 0 + ? Math.round( + Math.min( + EMPTY_ICON_MAX, + Math.max( + EMPTY_ICON_MIN, + Math.min(containerSize.w, containerSize.h) * EMPTY_ICON_RATIO + ) + ) + ) + : EMPTY_ICON_MIN + + const source = + hasNoArtwork === true + ? undefined + : (loadedSource ?? localCollectionImageUri) const handleError = (error: { nativeEvent: { error: string } }) => { if (source && typeof source === 'object' && 'uri' in source) { @@ -117,6 +163,29 @@ export const CollectionImage = (props: CollectionImageProps) => { onLoad={onLoad} onError={handleError} style={style} - /> + > + {hasNoArtwork ? ( + + + + ) : null} + ) } diff --git a/packages/mobile/src/components/image/TrackImage.tsx b/packages/mobile/src/components/image/TrackImage.tsx index af2c0ca6429..ce2bec5967a 100644 --- a/packages/mobile/src/components/image/TrackImage.tsx +++ b/packages/mobile/src/components/image/TrackImage.tsx @@ -1,21 +1,36 @@ +import { useState } from 'react' + import { useTrack } from '@audius/common/api' import { useImageSize } from '@audius/common/hooks' import type { SquareSizes, ID } from '@audius/common/models' import { reachabilitySelectors } from '@audius/common/store' import type { Maybe } from '@audius/common/utils' +import type { LayoutChangeEvent } from 'react-native' +import { View } from 'react-native' import { useSelector } from 'react-redux' import type { CornerRadiusOptions, ImageProps } from '@audius/harmony-native' -import { Artwork, preload } from '@audius/harmony-native' -import imageEmpty from 'app/assets/images/imageBlank2x.png' +import { Artwork, IconImage, preload } from '@audius/harmony-native' import { getLocalTrackCoverArtPath } from 'app/services/offline-downloader' import { getTrackDownloadStatus } from 'app/store/offline-downloads/selectors' import { OfflineDownloadStatus } from 'app/store/offline-downloads/slice' +import { useThemeColors } from 'app/utils/theme' import { primitiveToImageSource } from './primitiveToImageSource' const { getIsReachable } = reachabilitySelectors +const EMPTY_ICON_MIN = 12 +const EMPTY_ICON_MAX = 128 +const EMPTY_ICON_RATIO = 0.35 + +const hasValidArtwork = (artwork: unknown): boolean => + !!artwork && + typeof artwork === 'object' && + Object.entries(artwork as Record).some( + ([k, v]) => k !== 'mirrors' && typeof v === 'string' && v.length > 0 + ) + const useLocalTrackImageUri = (trackId: Maybe) => { const trackImageUri = useSelector((state) => { if (!trackId) return null @@ -40,11 +55,17 @@ export const useTrackImage = ({ trackId?: ID size: SquareSizes }) => { - const { data: artwork } = useTrack(trackId, { - select: (track) => { - return track.artwork - } + const { data: artworkData } = useTrack(trackId, { + select: (track) => + track != null + ? { + artwork: track.artwork, + hasNoArtwork: !hasValidArtwork(track.artwork) + } + : undefined }) + const artwork = artworkData?.artwork + const hasNoArtwork = artworkData?.hasNoArtwork ?? false const { imageUrl, onError: onImageError } = useImageSize({ artwork, targetSize: size, @@ -54,29 +75,29 @@ export const useTrackImage = ({ } }) - if (imageUrl === '') { - return { - source: imageEmpty, - isFallbackImage: true - } + // When track has no artwork or track not loaded yet, don't pass a URL so we never show stale image + if (hasNoArtwork || artworkData === undefined) { + return { source: undefined, hasNoArtwork: true } } // Return edited artwork from this session, if it exists - // TODO(PAY-3588) Update field once we've switched to another property name - // for local changes to artwork - // @ts-ignore + // @ts-expect-error - url is added for in-session edits if (artwork?.url) { return { - // @ts-ignore + // @ts-expect-error - url is added for in-session edits source: primitiveToImageSource(artwork.url), - isFallbackImage: false, + hasNoArtwork: false, onError: onImageError } } + if (imageUrl === '') { + return { source: undefined, hasNoArtwork: true } + } + return { source: primitiveToImageSource(imageUrl), - isFallbackImage: false, + hasNoArtwork: false, onError: onImageError } } @@ -102,11 +123,37 @@ export const TrackImage = (props: TrackImageProps) => { children } = props + const { staticWhite } = useThemeColors() + const [containerSize, setContainerSize] = useState({ w: 0, h: 0 }) const localTrackImageUri = useLocalTrackImageUri(trackId) const trackImageSource = useTrackImage({ trackId, size }) - const { source: loadedSource, onError: onImageError } = trackImageSource - - const source = loadedSource ?? localTrackImageUri + const { + source: loadedSource, + onError: onImageError, + hasNoArtwork + } = trackImageSource + + const onEmptyStateLayout = (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout + setContainerSize((prev) => + prev.w === width && prev.h === height ? prev : { w: width, h: height } + ) + } + const emptyIconSize = + containerSize.w > 0 && containerSize.h > 0 + ? Math.round( + Math.min( + EMPTY_ICON_MAX, + Math.max( + EMPTY_ICON_MIN, + Math.min(containerSize.w, containerSize.h) * EMPTY_ICON_RATIO + ) + ) + ) + : EMPTY_ICON_MIN + + const source = + hasNoArtwork === true ? undefined : (loadedSource ?? localTrackImageUri) const handleError = (error: any) => { try { @@ -134,6 +181,28 @@ export const TrackImage = (props: TrackImageProps) => { borderRadius={borderRadius} style={style} > + {hasNoArtwork ? ( + + + + ) : null} {children} ) diff --git a/packages/mobile/src/components/now-playing-drawer/Artwork.tsx b/packages/mobile/src/components/now-playing-drawer/Artwork.tsx index 988ff1ea873..a46803b93ec 100644 --- a/packages/mobile/src/components/now-playing-drawer/Artwork.tsx +++ b/packages/mobile/src/components/now-playing-drawer/Artwork.tsx @@ -78,6 +78,7 @@ export const Artwork = ({ track }: ArtworkProps) => { return ( { borderRadius={borderRadius} border='default' shadow={shadow} + w='100%' + h='100%' style={{ borderWidth }} > {isLoading && hasImageSource ? ( @@ -102,7 +104,7 @@ export const Artwork = (props: ArtworkProps) => { style={{ backgroundColor: !hasImageSource && children - ? color.neutral.n400 + ? color.neutral.n100 : color.background.surface2 }} /> diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 459228af0f4..f00b425b72b 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,5 +1,31 @@ # @audius/sdk +## 14.0.0 + +### Major Changes + +- d864806: Remove getPlaylistByHandleAndSlug in favor of getBulkPlaylists + + - Removes `sdk.playlists.getPlaylistByHandleAndSlug()` in favor of calling `sdk.playlists.getBulkPlaylists({ permalink: ['/handle/playlist/playlist-name-slug'] })` + - Changes return values of `CommentsAPI` to match other APIs, removing `success` param. + +### Minor Changes + +- 71bb31b: Add programmable distribution config to stream_conditions + +### Patch Changes + +- 71bb31b: Fix missing bearer token for PUT /users +- a7a9e17: Fix create/upload/update playlist in legacy path + + - `publishTracks` returns string track IDs, which were being incorrectly parsed as though they were numbers that needed converting. This was changed behavior from recent SDK changes made to match the POST endpoints as this was working previously + - `createPlaylist` wasn't equipped to handle using a preset `playlistId` like our client expects, rejecting calls that had `playlistId` already set in the metadata (which would happen on our creation of playlists from scratch). + - `createPlaylistInternal` was being passed parsed parameters in the `createPlaylist` case, and unparsed in the `uploadPlaylist` case, and used types that made it hard to squeeze both callsites in. This was resulting in incorrectly setting some IDs to hash IDs (eg in `playlistContents`) and was uncovered when fixing the playlistId bug above + - `updatePlaylist` had incorrect schema still referencing `coverArtCid` instead of `playlistImageSizesMultihash`, blocking any playlist updates that included an image update + +- 8f12bb7: Fix cover art CID metadata properties for playlists and tracks. +- 6cb4b6f: Fix UploadsApi to make start() a function + ## 13.1.0 ### Minor Changes diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f6d51bd0d96..774eb307d9a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@audius/sdk", - "version": "13.1.0", + "version": "14.0.0", "audius": { "releaseSHA": "f1d70a2a0643c5c84d8ab053f70c1e0a2ec3ad49" }, diff --git a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts index 269bd68ac67..61acaaf2f82 100644 --- a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts +++ b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts @@ -90,14 +90,7 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { params: EntityManagerCreatePlaylistRequest, advancedOptions?: AdvancedOptions ) { - // Parse inputs - const parsedParameters = await parseParams( - 'createPlaylist', - CreatePlaylistSchema - )(params) - - // Call createPlaylistInternal with parsed inputs - return await this.createPlaylistInternal(parsedParameters, advancedOptions) + return await this.createPlaylistInternal(params, advancedOptions) } override async createPlaylist( @@ -105,11 +98,7 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { requestInit?: RequestInit ) { if (this.entityManager) { - const { metadata } = params - return await this.createPlaylistWithEntityManager({ - userId: params.userId, - metadata - }) + return await this.createPlaylistWithEntityManager(params) } return super.createPlaylist(params, requestInit) } @@ -175,9 +164,9 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { // Transform track metadata (cast: SDK upload schema and API body types align at runtime) const trackMetadata = this.combineMetadata( this.trackUploadHelper.transformTrackUploadMetadataV2( - t as unknown as CreateTrackRequestBody, + t as CreateTrackRequestBody, userId - ) as CreateTrackRequestBody, + ), playlistMetadata ) @@ -729,43 +718,40 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { } /** @internal - * Method to create a playlist with already parsed inputs + * Method to create a playlist from raw inputs, parsing them with CreatePlaylistSchema * This is used for both playlists and albums */ - public async createPlaylistInternal( - { - userId, - imageFile, - metadata, - onProgress, - trackIds, - playlistId: providedPlaylistId - }: z.infer & { metadata: Metadata }, + public async createPlaylistInternal( + params: z.input, advancedOptions?: AdvancedOptions ) { + const { userId, imageFile, metadata, onProgress } = await parseParams( + 'createPlaylistInternal', + CreatePlaylistSchema + )(params) + // Upload cover art to storage node const coverArtResponse = imageFile && (await this.storage .uploadFile({ file: imageFile, - onProgress, + onProgress: (event) => + onProgress?.(event.loaded / event.total, { + ...event, + key: 'image' + }), metadata: { template: 'img_square' } }) .start()) + const providedPlaylistId = metadata.playlistId const playlistId = providedPlaylistId || (await this.generatePlaylistId()) - const timestamp = getCurrentTimestamp() - // Update metadata to include track ids const updatedMetadata = { ...metadata, - playlistContents: (trackIds ?? []).map((trackId) => ({ - trackId, - timestamp - })), playlistImageSizesMultihash: coverArtResponse?.orig_file_cid } diff --git a/packages/sdk/src/sdk/api/playlists/types.ts b/packages/sdk/src/sdk/api/playlists/types.ts index 8e9714245d8..8df39223fa9 100644 --- a/packages/sdk/src/sdk/api/playlists/types.ts +++ b/packages/sdk/src/sdk/api/playlists/types.ts @@ -20,6 +20,7 @@ export type PlaylistsApiServicesConfig = { const CreatePlaylistMetadataSchema = z .object({ + playlistId: z.optional(HashId), description: z.optional(z.string().max(1000)), playlistName: z.string(), isPrivate: z.optional(z.boolean()), @@ -53,12 +54,24 @@ export type CreatePlaylistMetadata = z.input< typeof CreatePlaylistMetadataSchema > +export const UploadPlaylistProgressEventSchema = ProgressEventSchema.extend({ + /** + * Index of the track being uploaded in the playlist tracks array, or 'image' if for the image + */ + key: z.number().or(z.literal('image')) +}) + +export const UploadPlaylistProgressHandlerSchema = z + .function() + .args(z.number(), UploadPlaylistProgressEventSchema) + .returns(z.void()) + export const CreatePlaylistSchema = z .object({ playlistId: z.optional(HashId), imageFile: z.optional(ImageFile), metadata: CreatePlaylistMetadataSchema, - onProgress: z.optional(z.function()), + onProgress: UploadPlaylistProgressHandlerSchema.optional(), trackIds: z.optional(z.array(HashId)), userId: HashId }) @@ -101,13 +114,6 @@ const PlaylistTrackMetadataSchema = UploadTrackMetadataSchema.partial({ */ export type PlaylistTrackMetadata = z.infer -export const UploadPlaylistProgressEventSchema = ProgressEventSchema.extend({ - /** - * Index of the track being uploaded in the playlist tracks array, or 'image' if for the image - */ - key: z.number().or(z.literal('image')) -}) - /** * The progress event for updating a playlist */ @@ -115,11 +121,6 @@ type UploadPlaylistProgressEvent = z.input< typeof UploadPlaylistProgressEventSchema > -export const UploadPlaylistProgressHandlerSchema = z - .function() - .args(z.number(), UploadPlaylistProgressEventSchema) - .returns(z.void()) - export type UploadPlaylistProgressHandler = ( /** * Overall progress percentage (0-1) @@ -166,7 +167,7 @@ export const UpdatePlaylistMetadataSchema = }) ) ), - coverArtCid: z.optional(z.string()) + playlistImageSizesMultihash: z.optional(z.string()) }) ) .strict() diff --git a/packages/sdk/src/sdk/createSdkWithServices.ts b/packages/sdk/src/sdk/createSdkWithServices.ts index 4a5e8febac2..0d60722546b 100644 --- a/packages/sdk/src/sdk/createSdkWithServices.ts +++ b/packages/sdk/src/sdk/createSdkWithServices.ts @@ -29,9 +29,11 @@ import { developmentConfig } from './config/development' import { productionConfig } from './config/production' import { addAppInfoMiddleware, - addRequestSignatureMiddleware + addRequestSignatureMiddleware, + addTokenRefreshMiddleware } from './middleware' import { OAuth } from './oauth' +import { OAuthTokenStore } from './oauth/tokenStore' import { PaymentRouterClient, getDefaultPaymentRouterClientConfig @@ -133,7 +135,7 @@ export const createSdkWithServices = (config: SdkConfig) => { ) } - // Initialize APIs + // Initialize APIs (also creates tokenStore and oauth) const apis = initializeApis({ config, apiKey, @@ -142,18 +144,7 @@ export const createSdkWithServices = (config: SdkConfig) => { services }) - // Initialize OAuth - const oauth = isBrowser - ? new OAuth({ - appName, - apiKey, - usersApi: apis.users, - logger: services.logger - }) - : undefined - return { - oauth, ...apis } } @@ -460,11 +451,36 @@ const initializeApis = ({ }) ] + // Token store for PKCE flow — provides dynamic accessToken to Configuration + const tokenStore = new OAuthTokenStore() + + // Auto-refresh middleware — intercepts 401s and retries with a fresh token. + const oauth = + typeof window !== 'undefined' + ? new OAuth({ + apiKey, + tokenStore, + basePath + }) + : undefined + + if (apiKey && oauth) { + middleware.push( + addTokenRefreshMiddleware({ + oauth + }) + ) + } + + const bearerToken = 'bearerToken' in config ? config.bearerToken : undefined + const apiClientConfig = new Configuration({ fetchApi: fetch, middleware, basePath, - accessToken: 'bearerToken' in config ? config.bearerToken : undefined + // Static bearerToken takes precedence; otherwise use the dynamic store + // so PKCE login can inject tokens after construction. + accessToken: bearerToken ?? tokenStore.asAccessTokenProvider() }) const tracks = new TracksApi(apiClientConfig, services) @@ -507,6 +523,8 @@ const initializeApis = ({ const search = new SearchApi(apiClientConfig) return { + oauth, + tokenStore, tracks, users, albums, diff --git a/packages/sdk/src/sdk/createSdkWithoutServices.ts b/packages/sdk/src/sdk/createSdkWithoutServices.ts index 02bd1d354f7..c7080fb5515 100644 --- a/packages/sdk/src/sdk/createSdkWithoutServices.ts +++ b/packages/sdk/src/sdk/createSdkWithoutServices.ts @@ -59,6 +59,16 @@ export const createSdkWithoutServices = (config: SdkConfig) => { // Token store for PKCE flow — provides dynamic accessToken to Configuration const tokenStore = new OAuthTokenStore() + // Initialize OAuth early so it can be passed to middleware + const oauth = + typeof window !== 'undefined' + ? new OAuth({ + apiKey, + tokenStore, + basePath + }) + : undefined + if (apiSecret || services?.audiusWalletClient) { middleware.push( addRequestSignatureMiddleware({ @@ -87,12 +97,10 @@ export const createSdkWithoutServices = (config: SdkConfig) => { } // Auto-refresh middleware — intercepts 401s and retries with a fresh token. - if (apiKey) { + if (apiKey && oauth) { middleware.push( addTokenRefreshMiddleware({ - tokenStore, - apiKey, - basePath + oauth }) ) } @@ -106,17 +114,8 @@ export const createSdkWithoutServices = (config: SdkConfig) => { accessToken: bearerToken ?? tokenStore.asAccessTokenProvider() }) - // Initialize OAuth + // Initialize API clients const usersApi = new UsersApi(apiConfig) - const oauth = - typeof window !== 'undefined' - ? new OAuth({ - apiKey, - usersApi, - tokenStore, - basePath - }) - : undefined return { oauth, diff --git a/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.test.ts b/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.test.ts index 560688b8387..123fd07ce59 100644 --- a/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.test.ts +++ b/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.test.ts @@ -1,17 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { OAuthTokenStore } from '../oauth/tokenStore' +import type { OAuth } from '../oauth/OAuth' import { addTokenRefreshMiddleware } from './addTokenRefreshMiddleware' -// Mock cross-fetch used by the middleware -vi.mock('cross-fetch', () => ({ - default: vi.fn() -})) - -import crossFetch from 'cross-fetch' -const mockCrossFetch = vi.mocked(crossFetch) - // Minimal fetch mock helper function mockResponse(status: number, body?: object): Response { return new Response(body ? JSON.stringify(body) : null, { @@ -20,18 +12,22 @@ function mockResponse(status: number, body?: object): Response { }) } -describe('addTokenRefreshMiddleware', () => { - let tokenStore: OAuthTokenStore - const apiKey = 'test-api-key' - const basePath = 'https://api.example.com/v1' +function createMockOAuth( + refreshBehaviour: () => Promise +): OAuth { + return { + refreshAccessToken: refreshBehaviour + } as unknown as OAuth +} +describe('addTokenRefreshMiddleware', () => { beforeEach(() => { - tokenStore = new OAuthTokenStore() vi.restoreAllMocks() }) it('passes through non-401 responses unchanged', async () => { - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) + const oauth = createMockOAuth(async () => 'token') + const mw = addTokenRefreshMiddleware({ oauth }) const response = mockResponse(200, { data: 'ok' }) const result = await mw.post!({ @@ -44,8 +40,9 @@ describe('addTokenRefreshMiddleware', () => { expect(result).toBe(response) }) - it('passes through 401 when no refresh token is available', async () => { - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) + it('passes through 401 when refresh returns null (no refresh token)', async () => { + const oauth = createMockOAuth(async () => null) + const mw = addTokenRefreshMiddleware({ oauth }) const response = mockResponse(401) const result = await mw.post!({ @@ -58,26 +55,12 @@ describe('addTokenRefreshMiddleware', () => { expect(result).toBe(response) }) - it('refreshes and retries on 401 when refresh token exists', async () => { - tokenStore.setTokens('expired-access', 'valid-refresh') - - const refreshResponse = mockResponse(200, { - access_token: 'new-access', - refresh_token: 'new-refresh', - token_type: 'Bearer', - expires_in: 3600, - scope: 'write' - }) - + it('refreshes and retries on 401 when refresh succeeds', async () => { + const oauth = createMockOAuth(async () => 'new-access') const retryResponse = mockResponse(200, { data: 'success' }) - - // Mock cross-fetch for the refresh call - mockCrossFetch.mockResolvedValueOnce(refreshResponse) - - // The retry fetch provided in context const contextFetch = vi.fn().mockResolvedValueOnce(retryResponse) - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) + const mw = addTokenRefreshMiddleware({ oauth }) const result = await mw.post!({ fetch: contextFetch, @@ -89,19 +72,7 @@ describe('addTokenRefreshMiddleware', () => { response: mockResponse(401) }) - // Refresh endpoint was called - expect(mockCrossFetch).toHaveBeenCalledWith( - `${basePath}/oauth/token`, - expect.objectContaining({ - method: 'POST' - }) - ) - - // Token store was updated - expect(tokenStore.accessToken).toBe('new-access') - expect(tokenStore.refreshToken).toBe('new-refresh') - - // Original request was retried + // Original request was retried with new token expect(contextFetch).toHaveBeenCalledWith( 'https://api.example.com/v1/tracks/123', expect.objectContaining({ @@ -115,12 +86,8 @@ describe('addTokenRefreshMiddleware', () => { }) it('surfaces 401 when refresh fails', async () => { - tokenStore.setTokens('expired-access', 'revoked-refresh') - - // Refresh returns 400 (invalid_grant) - mockCrossFetch.mockResolvedValueOnce(mockResponse(400)) - - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) + const oauth = createMockOAuth(async () => null) + const mw = addTokenRefreshMiddleware({ oauth }) const original401 = mockResponse(401) const result = await mw.post!({ @@ -130,80 +97,14 @@ describe('addTokenRefreshMiddleware', () => { response: original401 }) - // Returns the original 401 — doesn't swallow it expect(result).toBe(original401) }) - it('surfaces 401 when refresh endpoint returns invalid JSON', async () => { - tokenStore.setTokens('expired-access', 'valid-refresh') - - // Return 200 but with garbage body - mockCrossFetch.mockResolvedValueOnce( - new Response('not json', { status: 200 }) - ) - - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) - const original401 = mockResponse(401) - - const result = await mw.post!({ - fetch, - url: 'https://api.example.com/v1/tracks/123', - init: {}, - response: original401 + it('surfaces 401 when refreshAccessToken throws', async () => { + const oauth = createMockOAuth(async () => { + throw new Error('network failure') }) - - expect(result).toBe(original401) - }) - - it('surfaces 401 when refresh response is missing required fields', async () => { - tokenStore.setTokens('expired-access', 'valid-refresh') - - // Return 200 but only access_token (no refresh_token) - mockCrossFetch.mockResolvedValueOnce( - mockResponse(200, { access_token: 'new-access' }) - ) - - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) - const original401 = mockResponse(401) - - const result = await mw.post!({ - fetch, - url: 'https://api.example.com/v1/tracks/123', - init: {}, - response: original401 - }) - - expect(result).toBe(original401) - }) - - it('surfaces 401 when network error occurs during refresh', async () => { - tokenStore.setTokens('expired-access', 'valid-refresh') - - mockCrossFetch.mockRejectedValueOnce(new Error('network failure')) - - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) - const original401 = mockResponse(401) - - const result = await mw.post!({ - fetch, - url: 'https://api.example.com/v1/tracks/123', - init: {}, - response: original401 - }) - - expect(result).toBe(original401) - }) - - it('surfaces 401 when refresh token is cleared between check and exchange', async () => { - tokenStore.setTokens('expired-access', 'about-to-be-cleared') - - // Simulate: refresh token exists at guard check, but the exchange returns - // empty strings (server rejected it). The middleware should not store empty tokens. - mockCrossFetch.mockResolvedValueOnce( - mockResponse(200, { access_token: '', refresh_token: '' }) - ) - - const mw = addTokenRefreshMiddleware({ tokenStore, apiKey, basePath }) + const mw = addTokenRefreshMiddleware({ oauth }) const original401 = mockResponse(401) const result = await mw.post!({ @@ -213,7 +114,6 @@ describe('addTokenRefreshMiddleware', () => { response: original401 }) - // Empty tokens are rejected by the validation expect(result).toBe(original401) }) }) diff --git a/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.ts b/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.ts index d756e26d07d..55fd49075bc 100644 --- a/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.ts +++ b/packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.ts @@ -1,74 +1,20 @@ -import fetch from 'cross-fetch' - import type { Middleware, ResponseContext } from '../api/generated/default' -import type { OAuthTokenStore } from '../oauth/tokenStore' - -/** - * Shape of the token endpoint's JSON response. - */ -interface TokenResponse { - access_token: string - refresh_token: string - token_type: string - expires_in: number - scope: string -} - -/** - * Exchange a refresh token for a new access + refresh pair. - * Returns `null` if the refresh itself fails (expired / revoked). - */ -async function exchangeRefreshToken( - refreshToken: string, - clientId: string, - basePath: string -): Promise { - try { - const res = await fetch(`${basePath}/oauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: clientId - }) - }) - if (!res.ok) return null - const json = await res.json() - // Validate the response contains the required fields - if ( - typeof json?.access_token !== 'string' || - typeof json?.refresh_token !== 'string' || - !json.access_token || - !json.refresh_token - ) { - return null - } - return json as TokenResponse - } catch { - // Network error, timeout, invalid JSON, etc. - return null - } -} +import type { OAuth } from '../oauth/OAuth' /** * Middleware that transparently refreshes an expired access token on 401. * - * When a response comes back with HTTP 401 and the token store holds a refresh - * token, the middleware attempts a single refresh. On success it updates the - * store and retries the original request. On failure (refresh token expired - * or revoked) it lets the 401 propagate. + * When a response comes back with HTTP 401 the middleware delegates to + * `OAuth.refreshAccessToken()` which checks for a refresh token, performs the + * HTTP exchange, and updates the token store. On success the original request + * is retried with the fresh access token. On failure the 401 propagates. */ export const addTokenRefreshMiddleware = ({ - tokenStore, - apiKey, - basePath + oauth }: { - tokenStore: OAuthTokenStore - apiKey: string - basePath: string + oauth: OAuth }): Middleware => { - let refreshInFlight: Promise | null = null + let refreshInFlight: Promise | null = null return { post: async (context: ResponseContext): Promise => { @@ -76,37 +22,27 @@ export const addTokenRefreshMiddleware = ({ return context.response } - // Snapshot the refresh token — it may be cleared concurrently. - const currentRefreshToken = tokenStore.refreshToken - if (!currentRefreshToken) { - return context.response - } - // Coalesce concurrent 401s into a single refresh call. if (!refreshInFlight) { - refreshInFlight = exchangeRefreshToken( - currentRefreshToken, - apiKey, - basePath - ).finally(() => { - refreshInFlight = null - }) + refreshInFlight = oauth + .refreshAccessToken() + .catch(() => null) + .finally(() => { + refreshInFlight = null + }) } - const newTokens = await refreshInFlight - if (!newTokens) { - // Refresh failed — surface the original 401. + const newAccessToken = await refreshInFlight + if (!newAccessToken) { return context.response } - tokenStore.setTokens(newTokens.access_token, newTokens.refresh_token) - // Retry the original request with the new access token. const retryInit: RequestInit = { ...context.init, headers: { ...((context.init.headers as Record) ?? {}), - Authorization: `Bearer ${newTokens.access_token}` + Authorization: `Bearer ${newAccessToken}` } } return context.fetch(context.url, retryInit) diff --git a/packages/sdk/src/sdk/oauth/OAuth.ts b/packages/sdk/src/sdk/oauth/OAuth.ts index 66b17b79cb7..8aca14182ac 100644 --- a/packages/sdk/src/sdk/oauth/OAuth.ts +++ b/packages/sdk/src/sdk/oauth/OAuth.ts @@ -1,18 +1,10 @@ -import type { DecodedUserToken, UsersApi } from '../api/generated/default' +import type { DecodedUserToken } from '../api/generated/default' import { Logger, type LoggerService } from '../services/Logger' import { isOAuthScopeValid, isWriteOnceParams } from '../utils/oauthScope' -import { parseParams } from '../utils/parseParams' import { generateCodeVerifier, generateCodeChallenge } from './pkce' import type { OAuthTokenStore } from './tokenStore' -import { - OAuthScope, - IsWriteAccessGrantedSchema, - IsWriteAccessGrantedRequest, - WriteOnceParams, - OAuthEnv, - OAUTH_URL -} from './types' +import { OAuthScope, WriteOnceParams, OAuthEnv, OAUTH_URL } from './types' export type LoginSuccessCallback = ( profile: DecodedUserToken, @@ -122,7 +114,6 @@ const PKCE_REDIRECT_URI_KEY = 'audiusPkceRedirectUri' type OAuthConfig = { appName?: string apiKey?: string - usersApi: UsersApi logger?: LoggerService tokenStore?: OAuthTokenStore basePath?: string @@ -174,28 +165,6 @@ export class OAuth { ) } - async isWriteAccessGranted(params: IsWriteAccessGrantedRequest) { - const { userId, apiKey } = await parseParams( - 'isWriteAccessGranted', - IsWriteAccessGrantedSchema - )(params) - if (!this.apiKey && !apiKey) { - this._surfaceError( - 'Need to init Audius SDK with API key or pass in API Key directly to oauth.isWriteAccessGranted.' - ) - } - const authorizedApps = await this.config.usersApi.getAuthorizedApps({ - id: userId - }) - - const foundIndex = authorizedApps.data?.findIndex( - (a) => - a.address.toLowerCase() === - `0x${(apiKey || this.apiKey)!.toLowerCase()}` - ) - return foundIndex !== undefined && foundIndex > -1 - } - login({ scope = 'read', params, @@ -362,16 +331,6 @@ export class OAuth { element.replaceWith(button) } - /** - * Verify if the given jwt ID token was signed by the subject (user) in the payload - * @deprecated see `UsersApi.verifyIDToken` - * @param token the token to verify - * @returns - */ - async verifyToken(token: string) { - return await this.config.usersApi.verifyIDToken({ token }) - } - getCsrfToken() { return window.localStorage.getItem(CSRF_TOKEN_KEY) } @@ -497,31 +456,49 @@ export class OAuth { this._surfaceError('State mismatch.') } // Verify token and decode - const decodedJwt = await this.verifyToken(event.data.token) - if (decodedJwt?.data) { - if (this.loginSuccessCallback) { - this.loginSuccessCallback(decodedJwt.data, event.data.token) + if (!this.config.basePath) { + this._surfaceError('basePath is required for token verification.') + return + } + try { + const verifyRes = await fetch( + `${this.config.basePath}/users/verify_token?token=${encodeURIComponent(event.data.token)}` + ) + if (!verifyRes.ok) { + this._surfaceError('The token was invalid.') + return } - } else { - this._surfaceError('The token was invalid.') + const decoded = (await verifyRes.json()) as { + data?: DecodedUserToken + } + if (decoded?.data) { + if (this.loginSuccessCallback) { + this.loginSuccessCallback(decoded.data, event.data.token) + } + } else { + this._surfaceError('The token was invalid.') + } + } catch { + this._surfaceError('Token verification request failed.') } } /** * Refresh the access token using the stored refresh token. - * Updates the token store on success. Returns `true` if refresh succeeded. + * Updates the token store on success. + * Returns the new access token, or `null` if refresh failed. */ - async refreshAccessToken(): Promise { + async refreshAccessToken(): Promise { if (!this.config.tokenStore || !this.config.basePath) { this._surfaceError( 'Token store and basePath are required for token refresh.' ) - return false + return null } const refreshToken = this.config.tokenStore.refreshToken if (!refreshToken) { this._surfaceError('No refresh token available.') - return false + return null } try { const res = await fetch(`${this.config.basePath}/oauth/token`, { @@ -534,7 +511,7 @@ export class OAuth { }) }) if (!res.ok) { - return false + return null } const tokens = await res.json() if (tokens.access_token && tokens.refresh_token) { @@ -542,11 +519,11 @@ export class OAuth { tokens.access_token, tokens.refresh_token ) - return true + return tokens.access_token } - return false + return null } catch { - return false + return null } } diff --git a/packages/sdk/src/sdk/sdk.ts b/packages/sdk/src/sdk/sdk.ts index 1cfa441fa87..3248d758450 100644 --- a/packages/sdk/src/sdk/sdk.ts +++ b/packages/sdk/src/sdk/sdk.ts @@ -25,7 +25,7 @@ const createSdkWithApiName = (config: SdkWithAppNameOnlyConfig) => { const createSdkWithApiKey = (config: SdkWithApiKeyOnlyConfig) => { DevAppSchemaWithApiKeyOnly.parse(config) - return createSdkWithoutServices(config) + return createSdkWithServices(config) } const createSdkWithApiSecret = (config: SdkWithApiSecretConfig) => { diff --git a/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts b/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts index c97996bd185..5ba98f96af2 100644 --- a/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts +++ b/packages/sdk/src/sdk/services/StorageNodeSelector/StorageNodeSelector.ts @@ -89,12 +89,9 @@ export class StorageNodeSelector implements StorageNodeSelectorService { this.selectedNode = selectedNode this.logger.info('Selected content node', this.selectedNode) } else { - // No healthy nodes found. Fall back to a random node - this.selectedNode = this.getRandomNode() - this.logger.warn( - 'No healthy nodes found. Falling back to random node:', - this.selectedNode - ) + // No healthy nodes found + this.selectedNode = null + this.logger.warn('No healthy nodes found') this.selectionState = 'failed_all' } @@ -130,17 +127,6 @@ export class StorageNodeSelector implements StorageNodeSelectorService { return selectedNode } - private getRandomNode(): string | null { - if (!this.orderedNodes?.length) { - this.orderedNodes = this.orderNodes(new Date().toString()) - } - if (this.orderedNodes.length === 0) { - return null - } - const randomIndex = Math.floor(Math.random() * this.orderedNodes.length) - return this.orderedNodes[randomIndex] ?? null - } - private orderNodes(key: string) { const endpoints = this.nodes.map((node) => node.endpoint.toLowerCase()) const hash = new RendezvousHash(...endpoints) diff --git a/packages/sp-actions/CHANGELOG.md b/packages/sp-actions/CHANGELOG.md index 4b531bea4ad..17ec92e1ca5 100644 --- a/packages/sp-actions/CHANGELOG.md +++ b/packages/sp-actions/CHANGELOG.md @@ -1,5 +1,11 @@ # @audius/sp-actions +## 1.0.25 + +### Patch Changes + +- @audius/sdk-legacy@6.0.21 + ## 1.0.24 ### Patch Changes diff --git a/packages/sp-actions/package.json b/packages/sp-actions/package.json index 3b22ca6e88e..dc2a3502450 100644 --- a/packages/sp-actions/package.json +++ b/packages/sp-actions/package.json @@ -1,6 +1,6 @@ { "name": "@audius/sp-actions", - "version": "1.0.24", + "version": "1.0.25", "description": "A utility for audius service providers to claim token rewards.", "bin": { "claim": "claim.js" @@ -8,7 +8,7 @@ "author": "Audius", "license": "Apache-2.0", "dependencies": { - "@audius/sdk-legacy": "6.0.20", + "@audius/sdk-legacy": "6.0.21", "@truffle/hdwallet-provider": "^1.2.2", "axios": "^0.21.0", "commander": "^6.2.1", diff --git a/packages/web/src/Root.tsx b/packages/web/src/Root.tsx index d39cc026876..a844295ce62 100644 --- a/packages/web/src/Root.tsx +++ b/packages/web/src/Root.tsx @@ -61,6 +61,7 @@ const AppOrPublicSite = () => { }> diff --git a/packages/web/src/common/store/pages/signon/sagas.ts b/packages/web/src/common/store/pages/signon/sagas.ts index 63d2da45fd7..7b2b2e36605 100644 --- a/packages/web/src/common/store/pages/signon/sagas.ts +++ b/packages/web/src/common/store/pages/signon/sagas.ts @@ -1,6 +1,9 @@ import { getAccountStatusQueryKey, + getCurrentAccountQueryKey, getWalletAccountSaga, + getUserQueryKey, + primeUserData, queryAccountUser, queryHasAccount, queryIsAccountComplete, @@ -523,6 +526,27 @@ function* signUp() { }) ) + // Optimistically update localStorage so getLocalAccount returns + // complete user data before discovery provider has indexed. + const updatedUser = { + ...account.user, + handle, + handle_lc: handle.toLowerCase(), + name, + location: location ?? null + } + yield* call( + [localStorage, localStorage.setAudiusAccountUser], + updatedUser + ) + primeUserData({ users: [updatedUser], queryClient }) + queryClient.invalidateQueries({ + queryKey: getUserQueryKey(userId) + }) + queryClient.invalidateQueries({ + queryKey: getCurrentAccountQueryKey() + }) + return userId } else { const authService = yield* getContext('authService') diff --git a/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx b/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx index 35bfbdc01f5..cf440f68f60 100644 --- a/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx +++ b/packages/web/src/components/add-to-collection/desktop/AddToCollectionModal.tsx @@ -219,7 +219,7 @@ const CollectionItem = ({ collection, collectionType }: CollectionItemProps) => { - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId: collection.playlist_id, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/collection/CollectionImage.tsx b/packages/web/src/components/collection/CollectionImage.tsx index 134d2c215b4..c4f12e37b93 100644 --- a/packages/web/src/components/collection/CollectionImage.tsx +++ b/packages/web/src/components/collection/CollectionImage.tsx @@ -11,7 +11,7 @@ type CollectionImageProps = { export const CollectionImage = (props: CollectionImageProps) => { const { collectionId, size, ...other } = props - const imageSource = useCollectionCoverArt({ + const { imageUrl: imageSource } = useCollectionCoverArt({ collectionId, size }) diff --git a/packages/web/src/components/collection/desktop/Artwork.tsx b/packages/web/src/components/collection/desktop/Artwork.tsx index 5447a4314a0..57cd1b798c3 100644 --- a/packages/web/src/components/collection/desktop/Artwork.tsx +++ b/packages/web/src/components/collection/desktop/Artwork.tsx @@ -31,7 +31,7 @@ export const Artwork = (props: ArtworkProps) => { }) const { is_image_autogenerated, permalink } = partialCollection ?? {} - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId, size: SquareSizes.SIZE_1000_BY_1000 }) diff --git a/packages/web/src/components/collection/mobile/CollectionHeader.tsx b/packages/web/src/components/collection/mobile/CollectionHeader.tsx index babd024b2f8..e06863c8b9d 100644 --- a/packages/web/src/components/collection/mobile/CollectionHeader.tsx +++ b/packages/web/src/components/collection/mobile/CollectionHeader.tsx @@ -166,7 +166,7 @@ const CollectionHeader = ({ onClickMobileOverflow?.(collectionId, overflowActions) } - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId, size: SquareSizes.SIZE_1000_BY_1000 }) diff --git a/packages/web/src/components/dynamic-image/DynamicImage.tsx b/packages/web/src/components/dynamic-image/DynamicImage.tsx index 41fadca5535..b19703ac1d9 100644 --- a/packages/web/src/components/dynamic-image/DynamicImage.tsx +++ b/packages/web/src/components/dynamic-image/DynamicImage.tsx @@ -50,6 +50,7 @@ const moveBehind = (ref: RefObject) => { ref.current.style.animation = 'none' ref.current.style.zIndex = '1' ref.current.style.backgroundColor = 'unset' + ref.current.style.backgroundImage = 'none' } } diff --git a/packages/web/src/components/nav/desktop/NowPlayingArtworkTile.tsx b/packages/web/src/components/nav/desktop/NowPlayingArtworkTile.tsx index 11fd474c986..e60519eefb4 100644 --- a/packages/web/src/components/nav/desktop/NowPlayingArtworkTile.tsx +++ b/packages/web/src/components/nav/desktop/NowPlayingArtworkTile.tsx @@ -4,6 +4,7 @@ import { useCurrentUserId, useTrack } from '@audius/common/api' import { SquareSizes } from '@audius/common/models' import { playerSelectors } from '@audius/common/store' import { + IconImage, IconWaveForm as IconVisualizer, IconButton, useTheme, @@ -56,7 +57,7 @@ export const NowPlayingArtworkTile = () => { }) const { title, isStreamGated, permalink, isOwner } = partialTrack ?? {} - const trackCoverArtImage = useTrackCoverArt({ + const { imageUrl: trackCoverArtImage, hasNoArtwork } = useTrackCoverArt({ trackId: trackId ?? undefined, size: SquareSizes.SIZE_480_BY_480 }) @@ -100,7 +101,26 @@ export const NowPlayingArtworkTile = () => { style={slideInProps} > - + + {hasNoArtwork ? ( + + + + ) : null} { const { track, hideTitle = false } = props - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId: track.track_id, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/now-playing/NowPlaying.module.css b/packages/web/src/components/now-playing/NowPlaying.module.css index 3e5e1be3d28..2d647b75234 100644 --- a/packages/web/src/components/now-playing/NowPlaying.module.css +++ b/packages/web/src/components/now-playing/NowPlaying.module.css @@ -84,6 +84,22 @@ overflow: hidden; } +/* Empty artwork: white image icon on gray (no skeleton) */ +.emptyArtworkIcon { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} +.emptyArtworkIcon path { + fill: var(--harmony-static-white); +} + .info { text-align: center; width: 100%; diff --git a/packages/web/src/components/now-playing/NowPlaying.tsx b/packages/web/src/components/now-playing/NowPlaying.tsx index 4518a947a66..e52f2e6bb87 100644 --- a/packages/web/src/components/now-playing/NowPlaying.tsx +++ b/packages/web/src/components/now-playing/NowPlaying.tsx @@ -34,7 +34,11 @@ import { PurchaseableContentType } from '@audius/common/store' import { Genre, route } from '@audius/common/utils' -import { IconCaretRight as IconCaret, Scrubber } from '@audius/harmony' +import { + IconCaretRight as IconCaret, + IconImage, + Scrubber +} from '@audius/harmony' import { Location } from 'history' import { connect, useSelector } from 'react-redux' import { Dispatch } from 'redux' @@ -213,7 +217,7 @@ const NowPlaying = g( } = track const { name, handle } = user - const image = useTrackCoverArt({ + const { imageUrl: image, hasNoArtwork } = useTrackCoverArt({ trackId: track_id, size: SquareSizes.SIZE_480_BY_480 }) @@ -403,7 +407,13 @@ const NowPlaying = g( style={artworkAverageColor} > - + + {hasNoArtwork ? ( +
+ +
+ ) : null} +
) : ( @@ -415,7 +425,13 @@ const NowPlaying = g( style={artworkAverageColor} > - + + {hasNoArtwork ? ( +
+ +
+ ) : null} +
)} diff --git a/packages/web/src/components/play-bar/mobile/PlayBar.module.css b/packages/web/src/components/play-bar/mobile/PlayBar.module.css index 4265978009b..24ce9aba614 100644 --- a/packages/web/src/components/play-bar/mobile/PlayBar.module.css +++ b/packages/web/src/components/play-bar/mobile/PlayBar.module.css @@ -46,6 +46,24 @@ overflow: hidden; } +.imageEmpty { + background-color: var(--harmony-n-100); +} + +.emptyArtworkIcon { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; +} +.emptyArtworkIcon path { + fill: var(--harmony-static-white); +} + .info { flex: 1 1 auto; display: flex; diff --git a/packages/web/src/components/play-bar/mobile/PlayBar.tsx b/packages/web/src/components/play-bar/mobile/PlayBar.tsx index 4dede0e6937..1ced9ddb77d 100644 --- a/packages/web/src/components/play-bar/mobile/PlayBar.tsx +++ b/packages/web/src/components/play-bar/mobile/PlayBar.tsx @@ -15,7 +15,8 @@ import { tracksSocialActions, playerSelectors } from '@audius/common/store' -import { IconLock } from '@audius/harmony' +import { IconImage, IconLock } from '@audius/harmony' +import cn from 'classnames' import { connect, useSelector } from 'react-redux' import { Dispatch } from 'redux' @@ -84,7 +85,7 @@ const PlayBar = ({ return () => clearInterval(seekInterval) }) - const image = useTrackCoverArt({ + const { imageUrl: image, hasNoArtwork } = useTrackCoverArt({ trackId: track ? track.track_id : undefined, size: SquareSizes.SIZE_150_BY_150, defaultImage: '' @@ -166,11 +167,18 @@ const PlayBar = ({ id={track?.track_id} >
+ {hasNoArtwork ? ( +
+ +
+ ) : null} {shouldShowPreviewLock ? (
diff --git a/packages/web/src/components/remix-card/ConnectedRemixCard.tsx b/packages/web/src/components/remix-card/ConnectedRemixCard.tsx index 5801fcf2453..c153a5d2542 100644 --- a/packages/web/src/components/remix-card/ConnectedRemixCard.tsx +++ b/packages/web/src/components/remix-card/ConnectedRemixCard.tsx @@ -44,7 +44,7 @@ const ConnectedRemixCard = ({ userId: partialUser?.user_id, size: SquareSizes.SIZE_150_BY_150 }) - const coverArtImage = useTrackCoverArt({ + const { imageUrl: coverArtImage } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_480_BY_480 }) diff --git a/packages/web/src/components/search-bar/SearchBarResult.tsx b/packages/web/src/components/search-bar/SearchBarResult.tsx index 04ab39bb9f4..3fc40a0e9ef 100644 --- a/packages/web/src/components/search-bar/SearchBarResult.tsx +++ b/packages/web/src/components/search-bar/SearchBarResult.tsx @@ -202,7 +202,7 @@ export const TrackResult = ({ }: TrackResultProps) => { const { data: track } = useTrack(trackId) const { data: user } = useUser(track?.owner_id) - const trackArtwork = useTrackCoverArt({ + const { imageUrl: trackArtwork } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_150_BY_150 }) @@ -238,7 +238,7 @@ export const CollectionResult = ({ const { data: user } = useUser( collection ? collection.playlist_owner_id : null ) - const collectionArtwork = useCollectionCoverArt({ + const { imageUrl: collectionArtwork } = useCollectionCoverArt({ collectionId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx b/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx index fd739d39295..5d942db8435 100644 --- a/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx +++ b/packages/web/src/components/suggested-tracks/SuggestedTracks.tsx @@ -47,7 +47,7 @@ const SuggestedTrackRow = (props: SuggestedTrackProps) => { const { track_id, title, owner_id } = track const { data: user } = useUser(owner_id) const { data: collection } = useCollection(collectionId) - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId: track_id, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/track/Artwork.module.css b/packages/web/src/components/track/Artwork.module.css index 10951fed684..3b90b21715f 100644 --- a/packages/web/src/components/track/Artwork.module.css +++ b/packages/web/src/components/track/Artwork.module.css @@ -28,6 +28,15 @@ opacity: 0; transition: all ease-in-out 0.18s; } + +/* Empty artwork state: gray background + visible image icon (no skeleton) */ +.artworkWrapper.artworkEmpty .artworkIcon { + opacity: 1; + background-color: transparent; +} +.artworkWrapper.artworkEmpty .artworkIcon path { + fill: var(--harmony-static-white); +} .artworkWrapper .artworkIcon > svg, .artworkWrapper .artworkIcon > div { position: absolute; diff --git a/packages/web/src/components/track/Artwork.tsx b/packages/web/src/components/track/Artwork.tsx index 3bf58292d3c..bf76600c950 100644 --- a/packages/web/src/components/track/Artwork.tsx +++ b/packages/web/src/components/track/Artwork.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { SquareSizes, ID } from '@audius/common/models' import { + IconImage, IconLock, IconPlaybackPlay as IconPlay, IconPlaybackPause as IconPause @@ -39,16 +40,20 @@ export const ArtworkIcon = ({ isPlaying, artworkIconClassName, hasStreamAccess, - isTrack + isTrack, + hasNoArtwork }: { isBuffering: boolean isPlaying: boolean artworkIconClassName?: string hasStreamAccess?: boolean isTrack?: boolean + hasNoArtwork?: boolean }) => { let artworkIcon - if (isTrack && !hasStreamAccess) { + if (hasNoArtwork) { + artworkIcon = + } else if (isTrack && !hasStreamAccess) { artworkIcon = } else if (isBuffering) { artworkIcon = @@ -72,6 +77,7 @@ type ArtworkProps = TileArtworkProps & { image: any label?: string isTrack?: boolean + hasNoArtwork?: boolean } const Artwork = memo( @@ -88,19 +94,22 @@ const Artwork = memo( label, hasStreamAccess, isTrack, - noShimmer + noShimmer, + hasNoArtwork }: ArtworkProps) => { const imageElement = ( {showArtworkIcon && ( )} @@ -131,19 +141,21 @@ const Artwork = memo( ) export const TrackArtwork = memo((props: TileArtworkProps) => { - const image = useTrackCoverArt({ + const { imageUrl: image, hasNoArtwork } = useTrackCoverArt({ trackId: props.id, size: SquareSizes.SIZE_150_BY_150 }) - return + return ( + + ) }) export const CollectionArtwork = memo((props: TileArtworkProps) => { - const image = useCollectionCoverArt({ + const { imageUrl: image, hasNoArtwork } = useCollectionCoverArt({ collectionId: props.id, size: SquareSizes.SIZE_150_BY_150 }) - return + return }) diff --git a/packages/web/src/components/track/GiantArtwork.module.css b/packages/web/src/components/track/GiantArtwork.module.css index b6d39b31845..fecd5f7cf34 100644 --- a/packages/web/src/components/track/GiantArtwork.module.css +++ b/packages/web/src/components/track/GiantArtwork.module.css @@ -15,6 +15,22 @@ height: 338px; } +/* Empty artwork: subdued image icon on gray background (no skeleton) */ +.emptyArtworkIcon { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} +.emptyArtworkIcon path { + fill: var(--harmony-static-white); +} + .iconLeftWrapper { position: absolute; top: 16px; diff --git a/packages/web/src/components/track/GiantArtwork.tsx b/packages/web/src/components/track/GiantArtwork.tsx index 1ca807df142..bee06018f24 100644 --- a/packages/web/src/components/track/GiantArtwork.tsx +++ b/packages/web/src/components/track/GiantArtwork.tsx @@ -2,7 +2,7 @@ import { memo, useEffect } from 'react' import { SquareSizes, Remix } from '@audius/common/models' import { Nullable } from '@audius/common/utils' -import { IconArrowLeft } from '@audius/harmony' +import { IconArrowLeft, IconImage } from '@audius/harmony' import DynamicImage from 'components/dynamic-image/DynamicImage' import TrackFlair from 'components/track-flair/TrackFlair' @@ -24,25 +24,31 @@ const messages = { const GiantArtwork = (props: GiantArtworkProps) => { const { trackId, callback, onIconLeftClick } = props - const image = useTrackCoverArt({ + const { imageUrl: image, hasNoArtwork } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_1000_BY_1000 }) useEffect(() => { - if (image) callback() - }, [image, callback]) + if (image || hasNoArtwork) callback() + }, [image, hasNoArtwork, callback]) const imageElement = ( - {onIconLeftClick && ( + {hasNoArtwork ? ( +
+ +
+ ) : null} + {onIconLeftClick && !hasNoArtwork ? (
- )} + ) : null}
) diff --git a/packages/web/src/components/track/LockedContentDetailsTile.tsx b/packages/web/src/components/track/LockedContentDetailsTile.tsx index 74c65198726..f744798813b 100644 --- a/packages/web/src/components/track/LockedContentDetailsTile.tsx +++ b/packages/web/src/components/track/LockedContentDetailsTile.tsx @@ -53,11 +53,11 @@ export const LockedContentDetailsTile = ({ const title = isAlbum ? metadata.playlist_name : metadata.title const isDownloadGated = !isAlbum && metadata.is_download_gated - const trackArt = useTrackCoverArt({ + const { imageUrl: trackArt } = useTrackCoverArt({ trackId: contentId, size: SquareSizes.SIZE_150_BY_150 }) - const albumArt = useCollectionCoverArt({ + const { imageUrl: albumArt } = useCollectionCoverArt({ collectionId: contentId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/track/TrackArtwork.tsx b/packages/web/src/components/track/TrackArtwork.tsx index 89038be9840..79a98bec7df 100644 --- a/packages/web/src/components/track/TrackArtwork.tsx +++ b/packages/web/src/components/track/TrackArtwork.tsx @@ -23,7 +23,7 @@ export const TrackArtwork = (props: TrackArtworkProps) => { ...other } = props - const imageSource = useTrackCoverArt({ trackId, size }) + const { imageUrl: imageSource } = useTrackCoverArt({ trackId, size }) const artworkElement = ( diff --git a/packages/web/src/components/track/mobile/TrackListItem.tsx b/packages/web/src/components/track/mobile/TrackListItem.tsx index 9494a52a6e6..7a32e9552a8 100644 --- a/packages/web/src/components/track/mobile/TrackListItem.tsx +++ b/packages/web/src/components/track/mobile/TrackListItem.tsx @@ -69,7 +69,7 @@ const Artwork = ({ isLoading, coverArtSizes }: ArtworkProps) => { - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/track/mobile/TrackTileArt.module.css b/packages/web/src/components/track/mobile/TrackTileArt.module.css index 72eabf97927..84e472fc078 100644 --- a/packages/web/src/components/track/mobile/TrackTileArt.module.css +++ b/packages/web/src/components/track/mobile/TrackTileArt.module.css @@ -7,3 +7,12 @@ border-radius: 4px; overflow: hidden; } + +/* Empty artwork: image icon always visible on gray (no skeleton) */ +.artworkIconVisible { + opacity: 1 !important; + background-color: transparent !important; +} +.artworkIconVisible path { + fill: var(--harmony-static-white); +} diff --git a/packages/web/src/components/track/mobile/TrackTileArt.tsx b/packages/web/src/components/track/mobile/TrackTileArt.tsx index 6b00be99d1f..611f13186d8 100644 --- a/packages/web/src/components/track/mobile/TrackTileArt.tsx +++ b/packages/web/src/components/track/mobile/TrackTileArt.tsx @@ -40,7 +40,7 @@ const TrackTileArt = ({ artworkIconClassName, callback }: TrackTileArtProps) => { - const image = useTrackCoverArt({ + const { imageUrl: image, hasNoArtwork } = useTrackCoverArt({ trackId: id, size: SquareSizes.SIZE_150_BY_150 }) @@ -48,9 +48,15 @@ const TrackTileArt = ({ const imageProps = { image: showSkeleton ? '' : image, noShimmer, + useSkeleton: !hasNoArtwork, wrapperClassName: coSign ? styles.imageWrapper - : cn(styles.container, styles.imageWrapper, className), + : cn( + styles.container, + styles.imageWrapper, + className, + hasNoArtwork && styles.artworkEmpty + ), 'aria-label': label, onLoad: callback } @@ -60,7 +66,11 @@ const TrackTileArt = ({ ) @@ -88,7 +98,7 @@ const CollectionTileArt = ({ artworkIconClassName, callback }: TrackTileArtProps) => { - const image = useCollectionCoverArt({ + const { imageUrl: image, hasNoArtwork } = useCollectionCoverArt({ collectionId: id, size: SquareSizes.SIZE_150_BY_150 }) @@ -96,9 +106,15 @@ const CollectionTileArt = ({ const imageProps = { image: showSkeleton ? '' : image, noShimmer, + useSkeleton: !hasNoArtwork, wrapperClassName: coSign ? styles.imageWrapper - : cn(styles.container, styles.imageWrapper, className), + : cn( + styles.container, + styles.imageWrapper, + className, + hasNoArtwork && styles.artworkEmpty + ), 'aria-label': label, onLoad: callback } @@ -108,7 +124,11 @@ const CollectionTileArt = ({ ) diff --git a/packages/web/src/components/usdc-purchase-details-modal/components/AlbumPurchaseModalContent.tsx b/packages/web/src/components/usdc-purchase-details-modal/components/AlbumPurchaseModalContent.tsx index bbd853ef7bb..cae4e642ca7 100644 --- a/packages/web/src/components/usdc-purchase-details-modal/components/AlbumPurchaseModalContent.tsx +++ b/packages/web/src/components/usdc-purchase-details-modal/components/AlbumPurchaseModalContent.tsx @@ -23,7 +23,7 @@ export const AlbumPurchaseModalContent = ({ select: (collection) => pick(collection, ['playlist_name', 'permalink']) }) const { playlist_name, permalink } = partialAlbum ?? {} - const albumArtwork = useCollectionCoverArt({ + const { imageUrl: albumArtwork } = useCollectionCoverArt({ collectionId: contentId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/usdc-purchase-details-modal/components/AlbumSaleModalContent.tsx b/packages/web/src/components/usdc-purchase-details-modal/components/AlbumSaleModalContent.tsx index eba00518001..049e1b2638d 100644 --- a/packages/web/src/components/usdc-purchase-details-modal/components/AlbumSaleModalContent.tsx +++ b/packages/web/src/components/usdc-purchase-details-modal/components/AlbumSaleModalContent.tsx @@ -23,7 +23,7 @@ export const AlbumSaleModalContent = ({ select: (collection) => pick(collection, ['playlist_name', 'permalink']) }) const { playlist_name, permalink } = partialAlbum ?? {} - const albumArtwork = useCollectionCoverArt({ + const { imageUrl: albumArtwork } = useCollectionCoverArt({ collectionId: contentId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/usdc-purchase-details-modal/components/TrackPurchaseModalContent.tsx b/packages/web/src/components/usdc-purchase-details-modal/components/TrackPurchaseModalContent.tsx index fd650ce4372..8bc2459dc59 100644 --- a/packages/web/src/components/usdc-purchase-details-modal/components/TrackPurchaseModalContent.tsx +++ b/packages/web/src/components/usdc-purchase-details-modal/components/TrackPurchaseModalContent.tsx @@ -21,7 +21,7 @@ export const TrackPurchaseModalContent = ({ const { data: partialTrack } = useTrack(contentId, { select: (track) => pick(track, ['title', 'permalink']) }) - const trackArtwork = useTrackCoverArt({ + const { imageUrl: trackArtwork } = useTrackCoverArt({ trackId: contentId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/components/usdc-purchase-details-modal/components/TrackSaleModalContent.tsx b/packages/web/src/components/usdc-purchase-details-modal/components/TrackSaleModalContent.tsx index a46d9e99ccb..9a94e78295d 100644 --- a/packages/web/src/components/usdc-purchase-details-modal/components/TrackSaleModalContent.tsx +++ b/packages/web/src/components/usdc-purchase-details-modal/components/TrackSaleModalContent.tsx @@ -21,7 +21,7 @@ export const TrackSaleModalContent = ({ const { data: partialTrack } = useTrack(contentId, { select: (track) => pick(track, ['title', 'permalink']) }) - const trackArtwork = useTrackCoverArt({ + const { imageUrl: trackArtwork } = useTrackCoverArt({ trackId: contentId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/hooks/useCollectionCoverArt.ts b/packages/web/src/hooks/useCollectionCoverArt.ts index 2f987392f7b..733bc2d4f58 100644 --- a/packages/web/src/hooks/useCollectionCoverArt.ts +++ b/packages/web/src/hooks/useCollectionCoverArt.ts @@ -6,6 +6,13 @@ import { Maybe } from '@audius/common/utils' import { preload } from 'utils/image' +const hasValidArtwork = (artwork: unknown): boolean => + !!artwork && + typeof artwork === 'object' && + Object.entries(artwork).some( + ([k, v]) => k !== 'mirrors' && typeof v === 'string' && v.length > 0 + ) + export const useCollectionCoverArt = ({ collectionId, size, @@ -15,9 +22,17 @@ export const useCollectionCoverArt = ({ size: SquareSizes defaultImage?: string }) => { - const { data: artwork } = useCollection(collectionId, { - select: (collection) => collection.artwork + const { data: artworkData } = useCollection(collectionId, { + select: (collection) => + collection != null + ? { + artwork: collection.artwork, + hasNoArtwork: !hasValidArtwork(collection.artwork) + } + : undefined }) + const artwork = artworkData?.artwork + const hasNoArtwork = artworkData?.hasNoArtwork ?? false const { imageUrl } = useImageSize({ artwork, targetSize: size, @@ -26,10 +41,10 @@ export const useCollectionCoverArt = ({ }) // Return edited artwork from this session, if it exists - // TODO(PAY-3588) Update field once we've switched to another property name - // for local changes to artwork - // @ts-ignore - if (artwork?.url) return artwork.url + if (artwork?.url) return { imageUrl: artwork.url, hasNoArtwork: false } - return imageUrl + return { + imageUrl, + hasNoArtwork: hasNoArtwork && !artwork?.url + } } diff --git a/packages/web/src/hooks/useRequiresAccount.ts b/packages/web/src/hooks/useRequiresAccount.ts index faf3cf70aa0..fc52df55bf4 100644 --- a/packages/web/src/hooks/useRequiresAccount.ts +++ b/packages/web/src/hooks/useRequiresAccount.ts @@ -16,6 +16,8 @@ import { updateRouteOnExit } from 'common/store/pages/signon/actions' +let hasShownRequiresAccountToastThisSession = false + export type RestrictionType = 'none' | 'guest' | 'account' const canAccess = ( @@ -72,11 +74,14 @@ export const useRequiresAccountCallback = any>( dispatch(updateRouteOnExit(returnRoute)) dispatch(updateRouteOnCompletion(returnRoute)) dispatch(openSignOn(/** signIn */ false)) - dispatch( - showRequiresAccountToast( - accountStatus === Status.SUCCESS && !isAccountComplete + if (!hasShownRequiresAccountToastThisSession) { + hasShownRequiresAccountToastThisSession = true + dispatch( + showRequiresAccountToast( + accountStatus === Status.SUCCESS && !isAccountComplete + ) ) - ) + } onOpenAuthModal?.() return } diff --git a/packages/web/src/hooks/useTrackCoverArt.ts b/packages/web/src/hooks/useTrackCoverArt.ts index e58e9b437ad..b597f0be3fd 100644 --- a/packages/web/src/hooks/useTrackCoverArt.ts +++ b/packages/web/src/hooks/useTrackCoverArt.ts @@ -14,6 +14,13 @@ import { preload } from 'utils/image' import { dominantColor } from 'utils/imageProcessingUtil' import { useSelector } from 'utils/reducer' +const hasValidArtwork = (artwork: unknown): boolean => + !!artwork && + typeof artwork === 'object' && + Object.entries(artwork).some( + ([k, v]) => k !== 'mirrors' && typeof v === 'string' && v.length > 0 + ) + export const useTrackCoverArt = ({ trackId, size, @@ -23,9 +30,17 @@ export const useTrackCoverArt = ({ size: SquareSizes defaultImage?: string }) => { - const { data: artwork } = useTrack(trackId, { - select: (track) => track?.artwork + const { data: artworkData } = useTrack(trackId, { + select: (track) => + track != null + ? { + artwork: track.artwork, + hasNoArtwork: !hasValidArtwork(track.artwork) + } + : undefined }) + const artwork = artworkData?.artwork + const hasNoArtwork = artworkData?.hasNoArtwork ?? false const { imageUrl } = useImageSize({ artwork, targetSize: size, @@ -36,10 +51,19 @@ export const useTrackCoverArt = ({ // Return edited artwork from this session, if it exists // TODO(PAY-3588) Update field once we've switched to another property name // for local changes to artwork - // @ts-ignore - if (artwork?.url) return artwork.url + // @ts-expect-error - url is added for in-session edits, not on CoverArtSizesWithMirror type + if (artwork?.url) return { imageUrl: artwork.url, hasNoArtwork: false } - return imageUrl + // @ts-expect-error - url is added for in-session edits + const noArtwork = hasNoArtwork && !artwork?.url + // Don't pass a URL when track has no artwork, or when track data isn't loaded yet + // (artworkData undefined), so we never show the previous track's image. + const safeImageUrl = + noArtwork || artworkData === undefined ? undefined : imageUrl + return { + imageUrl: safeImageUrl, + hasNoArtwork: noArtwork + } } export const useTrackCoverArtDominantColors = ({ @@ -49,7 +73,7 @@ export const useTrackCoverArtDominantColors = ({ }) => { const dispatch = useDispatch() - const trackCoverArtImage = useTrackCoverArt({ + const { imageUrl: trackCoverArtImage } = useTrackCoverArt({ trackId: trackId ?? undefined, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/pages/chat-page/components/ChatBlastHeader.tsx b/packages/web/src/pages/chat-page/components/ChatBlastHeader.tsx index 363718912bb..60a10a17291 100644 --- a/packages/web/src/pages/chat-page/components/ChatBlastHeader.tsx +++ b/packages/web/src/pages/chat-page/components/ChatBlastHeader.tsx @@ -23,11 +23,11 @@ export const ChatBlastHeader = ({ chat }: { chat: ChatBlast }) => { chat }) const decodedId = OptionalHashId.parse(audienceContentId) - const albumArtwork = useCollectionCoverArt({ + const { imageUrl: albumArtwork } = useCollectionCoverArt({ collectionId: decodedId, size: SquareSizes.SIZE_150_BY_150 }) - const trackArtwork = useTrackCoverArt({ + const { imageUrl: trackArtwork } = useTrackCoverArt({ trackId: decodedId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/pages/chat-page/components/ComposePreviewInfo.tsx b/packages/web/src/pages/chat-page/components/ComposePreviewInfo.tsx index a875efa1cc0..f80ff3283b7 100644 --- a/packages/web/src/pages/chat-page/components/ComposePreviewInfo.tsx +++ b/packages/web/src/pages/chat-page/components/ComposePreviewInfo.tsx @@ -44,7 +44,7 @@ type ComposerTrackInfoProps = { export const ComposerTrackInfo = (props: ComposerTrackInfoProps) => { const { trackId } = props - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_150_BY_150 }) @@ -73,7 +73,7 @@ type ComposerCollectionInfoProps = { export const ComposerCollectionInfo = (props: ComposerCollectionInfoProps) => { const { collectionId } = props - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/pages/deleted-page/components/desktop/DeletedPage.tsx b/packages/web/src/pages/deleted-page/components/desktop/DeletedPage.tsx index ed64cccb528..6bbdc651b8d 100644 --- a/packages/web/src/pages/deleted-page/components/desktop/DeletedPage.tsx +++ b/packages/web/src/pages/deleted-page/components/desktop/DeletedPage.tsx @@ -52,7 +52,7 @@ const messages = { } const TrackArt = ({ trackId }: { trackId: ID }) => { - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_480_BY_480 }) @@ -60,7 +60,7 @@ const TrackArt = ({ trackId }: { trackId: ID }) => { } const CollectionArt = ({ collectionId }: { collectionId: ID }) => { - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId, size: SquareSizes.SIZE_480_BY_480 }) diff --git a/packages/web/src/pages/deleted-page/components/mobile/DeletedPage.tsx b/packages/web/src/pages/deleted-page/components/mobile/DeletedPage.tsx index 75e3ab5f8fc..c80391c09d0 100644 --- a/packages/web/src/pages/deleted-page/components/mobile/DeletedPage.tsx +++ b/packages/web/src/pages/deleted-page/components/mobile/DeletedPage.tsx @@ -48,7 +48,7 @@ const messages = { } const TrackArt = ({ trackId }: { trackId: ID }) => { - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId, size: SquareSizes.SIZE_480_BY_480 }) @@ -56,7 +56,7 @@ const TrackArt = ({ trackId }: { trackId: ID }) => { } const CollectionArt = ({ collectionId }: { collectionId: ID }) => { - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId, size: SquareSizes.SIZE_480_BY_480 }) diff --git a/packages/web/src/pages/edit-collection-page/desktop/EditCollectionPage.tsx b/packages/web/src/pages/edit-collection-page/desktop/EditCollectionPage.tsx index 2c4ea97ed86..b3f475c060e 100644 --- a/packages/web/src/pages/edit-collection-page/desktop/EditCollectionPage.tsx +++ b/packages/web/src/pages/edit-collection-page/desktop/EditCollectionPage.tsx @@ -53,7 +53,7 @@ export const EditCollectionPage = () => { const { data: tracks, isLoading: isTracksLoading } = useCollectionTracks(playlist_id) - const artworkUrl = useCollectionCoverArt({ + const { imageUrl: artworkUrl } = useCollectionCoverArt({ collectionId: playlist_id, size: SquareSizes.SIZE_1000_BY_1000 }) diff --git a/packages/web/src/pages/edit-collection-page/mobile/EditCollectionPage.tsx b/packages/web/src/pages/edit-collection-page/mobile/EditCollectionPage.tsx index 14977ef7301..d93d1a60043 100644 --- a/packages/web/src/pages/edit-collection-page/mobile/EditCollectionPage.tsx +++ b/packages/web/src/pages/edit-collection-page/mobile/EditCollectionPage.tsx @@ -121,7 +121,7 @@ const EditCollectionPage = g( } }, [setReorderedTracks, reorderedTracks, tracks]) - const artworkUrl = useCollectionCoverArt({ + const { imageUrl: artworkUrl } = useCollectionCoverArt({ collectionId: playlist_id, size: SquareSizes.SIZE_1000_BY_1000 }) diff --git a/packages/web/src/pages/edit-page/EditTrackPage.tsx b/packages/web/src/pages/edit-page/EditTrackPage.tsx index efd7e715d86..5acddd11166 100644 --- a/packages/web/src/pages/edit-page/EditTrackPage.tsx +++ b/packages/web/src/pages/edit-page/EditTrackPage.tsx @@ -93,7 +93,7 @@ export const EditTrackPage = (props: EditPageProps) => { } } - const coverArtUrl = useTrackCoverArt({ + const { imageUrl: coverArtUrl } = useTrackCoverArt({ trackId: track?.track_id, size: SquareSizes.SIZE_1000_BY_1000 }) diff --git a/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx b/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx index cc99296598c..c44ffa36e83 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx @@ -24,11 +24,11 @@ export const TrackNameWithArtwork = ({ enabled: !isTrack, select: (collection) => collection.playlist_name }) - const trackArtwork = useTrackCoverArt({ + const { imageUrl: trackArtwork } = useTrackCoverArt({ trackId: id, size: SquareSizes.SIZE_150_BY_150 }) - const albumArtwork = useCollectionCoverArt({ + const { imageUrl: albumArtwork } = useCollectionCoverArt({ collectionId: id, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/pages/search-page/RecentSearches.tsx b/packages/web/src/pages/search-page/RecentSearches.tsx index 2a08e33d55c..4e5bc0a36fd 100644 --- a/packages/web/src/pages/search-page/RecentSearches.tsx +++ b/packages/web/src/pages/search-page/RecentSearches.tsx @@ -112,7 +112,7 @@ const RecentSearchTrack = (props: { searchItem: SearchItem }) => { }) const { data: user } = useUser(partialTrack?.owner_id) - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId: partialTrack?.track_id, size: SquareSizes.SIZE_150_BY_150 }) @@ -185,7 +185,7 @@ const RecentSearchCollection = (props: { searchItem: SearchItem }) => { const { playlist_id, playlist_name, permalink, playlist_owner_id, is_album } = partialCollection ?? {} - const image = useCollectionCoverArt({ + const { imageUrl: image } = useCollectionCoverArt({ collectionId: playlist_id, size: SquareSizes.SIZE_150_BY_150 }) diff --git a/packages/web/src/pages/track-page/DesktopServerTrackPage.tsx b/packages/web/src/pages/track-page/DesktopServerTrackPage.tsx index 5a880e1a96c..f7f26e427c0 100644 --- a/packages/web/src/pages/track-page/DesktopServerTrackPage.tsx +++ b/packages/web/src/pages/track-page/DesktopServerTrackPage.tsx @@ -19,6 +19,7 @@ import { Paper } from '@audius/harmony/src/components/layout/Paper' import { Tag } from '@audius/harmony/src/components/tag' import { Text } from '@audius/harmony/src/components/text' import { TextLink } from '@audius/harmony/src/components/text-link' +import { IconImage } from '@audius/harmony/src/icons/individual/IconImage' import { Link } from 'react-router' import { ServerUserGeneratedText } from 'components/user-generated-text/ServerUserGeneratedText' @@ -79,6 +80,13 @@ export const DesktopServerTrackPage = ({ field_visibility, artwork } = track + + const hasArtwork = + artwork && + Object.entries(artwork).some( + ([k, v]) => k !== 'mirrors' && typeof v === 'string' && v.length > 0 + ) + const artworkSrc = hasArtwork ? artwork['480x480'] : undefined const { handle, name, cover_photo, profile_picture } = user // Use user cover photo as primary, fallback to profile picture with blur @@ -130,12 +138,25 @@ export const DesktopServerTrackPage = ({ > - + {artworkSrc ? ( + + ) : ( + + + + )} Track diff --git a/packages/web/src/pages/track-page/MobileServerTrackPage.tsx b/packages/web/src/pages/track-page/MobileServerTrackPage.tsx index eb30ad76506..28274932b45 100644 --- a/packages/web/src/pages/track-page/MobileServerTrackPage.tsx +++ b/packages/web/src/pages/track-page/MobileServerTrackPage.tsx @@ -19,6 +19,7 @@ import { Paper } from '@audius/harmony/src/components/layout/Paper' import { Tag } from '@audius/harmony/src/components/tag' import { Text } from '@audius/harmony/src/components/text' import { TextLink } from '@audius/harmony/src/components/text-link' +import { IconImage } from '@audius/harmony/src/icons/individual/IconImage' import { Link } from 'react-router' import { ServerUserGeneratedText } from 'components/user-generated-text/ServerUserGeneratedText' @@ -79,6 +80,13 @@ export const MobileServerTrackPage = ({ field_visibility, artwork } = track + + const hasArtwork = + artwork && + Object.entries(artwork).some( + ([k, v]) => k !== 'mirrors' && typeof v === 'string' && v.length > 0 + ) + const artworkSrc = hasArtwork ? artwork['480x480'] : undefined const { handle, name, cover_photo, profile_picture } = user // Use user cover photo as primary, fallback to profile picture with blur @@ -113,12 +121,25 @@ export const MobileServerTrackPage = ({ Track - + {artworkSrc ? ( + + ) : ( + + + + )} {title} diff --git a/packages/web/src/pages/visualizer/VisualizerProvider.tsx b/packages/web/src/pages/visualizer/VisualizerProvider.tsx index 58a9884dcde..ac6ccd87e92 100644 --- a/packages/web/src/pages/visualizer/VisualizerProvider.tsx +++ b/packages/web/src/pages/visualizer/VisualizerProvider.tsx @@ -42,7 +42,7 @@ const { getTheme } = themeSelectors const Artwork = ({ track }: { track?: Track | null }) => { const { track_id } = track || {} - const image = useTrackCoverArt({ + const { imageUrl: image } = useTrackCoverArt({ trackId: track_id, size: SquareSizes.SIZE_480_BY_480 }) diff --git a/packages/web/src/public-site/PublicSite.tsx b/packages/web/src/public-site/PublicSite.tsx index 78ed77ee92c..4188457d994 100644 --- a/packages/web/src/public-site/PublicSite.tsx +++ b/packages/web/src/public-site/PublicSite.tsx @@ -56,11 +56,12 @@ const ExternalRedirect = ({ to }: { to: string }) => { type PublicSiteProps = { isMobile: boolean + isAuthenticated: boolean setRenderPublicSite: (shouldRender: boolean) => void } export const PublicSite = (props: PublicSiteProps) => { - const { isMobile, setRenderPublicSite } = props + const { isMobile, isAuthenticated, setRenderPublicSite } = props const [isMobileOrNarrow, setIsMobileOrNarrow] = useState(isMobile) const handleMobileMediaQuery = useCallback(() => { if (MOBILE_WIDTH_MEDIA_QUERY.matches) setIsMobileOrNarrow(true) @@ -189,6 +190,7 @@ export const PublicSite = (props: PublicSiteProps) => { element={ @@ -199,6 +201,7 @@ export const PublicSite = (props: PublicSiteProps) => { element={ diff --git a/packages/web/src/public-site/pages/download-page/DownloadPage.tsx b/packages/web/src/public-site/pages/download-page/DownloadPage.tsx index d1c80fea10d..c3d20fd4e3b 100644 --- a/packages/web/src/public-site/pages/download-page/DownloadPage.tsx +++ b/packages/web/src/public-site/pages/download-page/DownloadPage.tsx @@ -41,6 +41,7 @@ const messages = { type DownloadPageProps = { isMobile: boolean + isAuthenticated: boolean openNavScreen: () => void setRenderPublicSite: (shouldRender: boolean) => void } @@ -181,6 +182,7 @@ const DownloadPage = (props: DownloadPageProps) => { ) : null} diff --git a/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx b/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx index 5019809811f..244e37d393b 100644 --- a/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx +++ b/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx @@ -26,6 +26,7 @@ const MOBILE_MEDIA_QUERY = type LandingPage2026Props = { isMobile: boolean + isAuthenticated: boolean openNavScreen: () => void setRenderPublicSite: (shouldRender: boolean) => void } @@ -112,6 +113,7 @@ export const LandingPage2026 = (props: LandingPage2026Props) => { ) : null} diff --git a/packages/web/src/public-site/pages/landing-2026/components/CreateFutureCTA.tsx b/packages/web/src/public-site/pages/landing-2026/components/CreateFutureCTA.tsx index 0ef1984a732..2d9b2659029 100644 --- a/packages/web/src/public-site/pages/landing-2026/components/CreateFutureCTA.tsx +++ b/packages/web/src/public-site/pages/landing-2026/components/CreateFutureCTA.tsx @@ -9,11 +9,11 @@ import promoBg from '../assets/promo-bg.jpg' import styles from './CreateFutureCTA.module.css' -const { SIGN_UP_PAGE } = route +const { EXPLORE_PAGE } = route const messages = { headline: 'Create the future of music, together.', - getStarted: 'Get Started' + startExploring: 'Start Exploring' } type CreateFutureCTAProps = { @@ -24,8 +24,8 @@ type CreateFutureCTAProps = { export const CreateFutureCTA = (props: CreateFutureCTAProps) => { const navigate = useNavigate() - const onSignUp = (e: MouseEvent) => { - handleClickRoute(SIGN_UP_PAGE, props.setRenderPublicSite, navigate)(e) + const onStartExploring = (e: MouseEvent) => { + handleClickRoute(EXPLORE_PAGE, props.setRenderPublicSite, navigate)(e) } return ( @@ -40,8 +40,12 @@ export const CreateFutureCTA = (props: CreateFutureCTAProps) => {

{messages.headline}

-
diff --git a/packages/web/src/public-site/pages/landing-2026/components/Hero2026.tsx b/packages/web/src/public-site/pages/landing-2026/components/Hero2026.tsx index 58dd0a5ac58..61591e16869 100644 --- a/packages/web/src/public-site/pages/landing-2026/components/Hero2026.tsx +++ b/packages/web/src/public-site/pages/landing-2026/components/Hero2026.tsx @@ -9,7 +9,7 @@ import landingImg from '../assets/landing.png' import styles from './Hero2026.module.css' -const { SIGN_UP_PAGE } = route +const { TRENDING_PAGE } = route const messages = { line1: 'Find your people.', @@ -26,7 +26,7 @@ export const Hero2026 = (props: Hero2026Props) => { const navigate = useNavigate() const onGetStarted = (e: MouseEvent) => { - handleClickRoute(SIGN_UP_PAGE, props.setRenderPublicSite, navigate)(e) + handleClickRoute(TRENDING_PAGE, props.setRenderPublicSite, navigate)(e) } return ( diff --git a/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx b/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx index 4bf6ff64284..7ebef38cbfb 100644 --- a/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx +++ b/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx @@ -28,10 +28,11 @@ import IconHelpSupport from '../assets/icon-help-support.svg' import styles from './Nav2026.module.css' -const { SIGN_UP_PAGE, DOWNLOAD_LINK } = route +const { SIGN_UP_PAGE, TRENDING_PAGE, DOWNLOAD_LINK } = route const messages = { - getStarted: 'Get Started', + signUp: 'Sign Up', + launch: 'Launch', resources: 'Resources' } @@ -83,12 +84,13 @@ const SOCIAL_LINKS = [ type Nav2026Props = { isMobile: boolean + isAuthenticated: boolean openNavScreen: () => void setRenderPublicSite: (shouldRender: boolean) => void } export const Nav2026 = (props: Nav2026Props) => { - const { isMobile, setRenderPublicSite } = props + const { isMobile, isAuthenticated, setRenderPublicSite } = props const navigate = useNavigate() const [isDropdownOpen, setIsDropdownOpen] = useState(false) const [isDropdownClosing, setIsDropdownClosing] = useState(false) @@ -136,9 +138,10 @@ export const Nav2026 = (props: Nav2026Props) => { } }, [isMobileOverlayOpen]) - const onSignUp = (e: MouseEvent) => { + const onCtaClick = (e: MouseEvent) => { setIsMobileOverlayOpen(false) - handleClickRoute(SIGN_UP_PAGE, setRenderPublicSite, navigate)(e) + const routeToUse = isAuthenticated ? TRENDING_PAGE : SIGN_UP_PAGE + handleClickRoute(routeToUse, setRenderPublicSite, navigate)(e) } const onLogoClick = (e: MouseEvent) => { @@ -216,10 +219,10 @@ export const Nav2026 = (props: Nav2026Props) => { @@ -231,7 +234,8 @@ export const Nav2026 = (props: Nav2026Props) => { {isMobileOverlayOpen ? ( setIsMobileOverlayOpen(false)} - onSignUp={onSignUp} + onCtaClick={onCtaClick} + ctaLabel={isAuthenticated ? messages.launch : messages.signUp} onLogoClick={onLogoClick} /> ) : null} @@ -241,11 +245,13 @@ export const Nav2026 = (props: Nav2026Props) => { function MobileNavOverlay({ onClose, - onSignUp, + onCtaClick, + ctaLabel, onLogoClick }: { onClose: () => void - onSignUp: (e: MouseEvent) => void + onCtaClick: (e: MouseEvent) => void + ctaLabel: string onLogoClick: (e: MouseEvent) => void }) { const handleItemClick = (href: string) => () => { @@ -315,9 +321,9 @@ function MobileNavOverlay({