├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── release-docker.yml │ ├── release-linux.yml │ ├── release-mac.yml │ ├── release-win.yml │ ├── test-castlabs.yml │ └── test-notarytool.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── .gitignore ├── .vscode │ └── settings.json ├── assets │ ├── entitlements.mac.plist │ └── icons │ │ ├── ampcast-grayscale.png │ │ ├── ampcast.ico │ │ ├── ampcast.png │ │ ├── ampcast.svg │ │ ├── ampcast128x128.png │ │ ├── ampcast16x16.png │ │ ├── ampcast256x256.png │ │ ├── ampcast32x32.png │ │ ├── ampcast64x64.png │ │ ├── setup.gif │ │ ├── setup.ico │ │ └── setup.png ├── package-lock.json ├── package.json ├── prettier.config.js ├── scripts │ ├── afterPack.js │ ├── afterSign.js │ └── uninstaller.nsh ├── src │ ├── icon.png │ ├── main.js │ ├── menu.js │ ├── preload.js │ ├── server.js │ ├── splash.html │ ├── splash.png │ └── store.js └── www │ ├── 404.html │ ├── apple-touch-icon.png │ ├── auth │ ├── lastfm │ │ └── callback │ │ │ └── index.html │ ├── plex │ │ └── callback │ │ │ └── index.html │ ├── spotify │ │ └── callback │ │ │ └── index.html │ └── tidal │ │ └── callback │ │ └── index.html │ ├── favicon.ico │ ├── favicon.svg │ ├── icon-192.png │ ├── icon-512.png │ ├── manifest.json │ ├── preview.jpg │ └── privacy-policy.html ├── docker-compose.yml ├── libs ├── icecast-metadata-player-1.17.12.main.min.js ├── icecast-metadata-player-1.17.12.mediasource.min.js └── icecast-metadata-player-1.17.12.synaudio.min.js ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.js ├── proxy-login.js ├── server.js ├── src ├── assets │ ├── pixel.png.base64.ts │ └── silence.mp3.base64.ts ├── components │ ├── Actions │ │ ├── Actions.tsx │ │ ├── AddToPlaylistDialog.scss │ │ ├── AddToPlaylistDialog.tsx │ │ ├── CreatePlaylistDialog.scss │ │ ├── CreatePlaylistDialog.tsx │ │ ├── PlaylistActions.tsx │ │ ├── index.ts │ │ ├── performAction.ts │ │ ├── useEditablePlaylistsPager.ts │ │ └── usePlaylistItems.ts │ ├── App │ │ ├── App.tsx │ │ ├── AppContent.tsx │ │ ├── AppDragRegion.scss │ │ ├── AppDragRegion.tsx │ │ ├── AppTitle.scss │ │ ├── AppTitle.tsx │ │ ├── DesktopWarning.scss │ │ ├── DesktopWarning.tsx │ │ ├── PortUnavailable.scss │ │ ├── PortUnavailable.tsx │ │ ├── index.ts │ │ ├── useAppSettings.ts │ │ ├── useBrowser.ts │ │ ├── useConnectivity.ts │ │ ├── useGlobalActions.ts │ │ ├── useMediaSession.ts │ │ ├── usePreload.ts │ │ ├── usePreventDrop.ts │ │ └── usePseudoClasses.ts │ ├── Badges │ │ ├── Badge.scss │ │ ├── Badge.tsx │ │ ├── Badges.scss │ │ ├── Badges.tsx │ │ └── index.ts │ ├── Button │ │ ├── CopyButton.scss │ │ ├── CopyButton.tsx │ │ ├── IconButton.scss │ │ ├── IconButton.tsx │ │ ├── IconButtons.scss │ │ ├── IconButtons.tsx │ │ └── index.ts │ ├── CoverArt │ │ ├── CoverArt.scss │ │ ├── CoverArt.tsx │ │ └── index.ts │ ├── CoverArtVisualizer │ │ ├── CoverArtVisualizer.tsx │ │ └── index.ts │ ├── DatePicker │ │ ├── DatePicker.tsx │ │ ├── MonthPicker.tsx │ │ ├── YearPicker.tsx │ │ └── index.ts │ ├── Dialog │ │ ├── Dialog.scss │ │ ├── Dialog.tsx │ │ ├── DialogButtons.tsx │ │ ├── alert.scss │ │ ├── alert.tsx │ │ ├── confirm.scss │ │ ├── confirm.tsx │ │ ├── error.tsx │ │ ├── index.ts │ │ ├── prompt.scss │ │ ├── prompt.tsx │ │ └── showDialog.tsx │ ├── Errors │ │ ├── BSOD.scss │ │ ├── BSOD.tsx │ │ ├── ErrorBox.scss │ │ ├── ErrorBox.tsx │ │ ├── ErrorReport.scss │ │ ├── ErrorReport.tsx │ │ ├── HandledError.tsx │ │ └── UnhandledError.tsx │ ├── ExternalLink │ │ ├── ExternalLink.scss │ │ ├── ExternalLink.tsx │ │ └── index.ts │ ├── Icon │ │ ├── Flag.tsx │ │ ├── Icon.scss │ │ ├── Icon.tsx │ │ ├── SvgDefs.scss │ │ ├── SvgDefs.tsx │ │ └── index.ts │ ├── ListView │ │ ├── ColumnResizer.tsx │ │ ├── DetailsBox.scss │ │ ├── DetailsBox.tsx │ │ ├── ListBox.scss │ │ ├── ListBox.tsx │ │ ├── ListView.scss │ │ ├── ListView.tsx │ │ ├── ListViewBody.tsx │ │ ├── ListViewBodyCell.tsx │ │ ├── ListViewBodyRow.tsx │ │ ├── ListViewHead.tsx │ │ ├── ListViewHeadCell.tsx │ │ ├── index.ts │ │ ├── useColumns.ts │ │ └── useSelectedItems.ts │ ├── Login │ │ ├── ConnectionLogging.scss │ │ ├── ConnectionLogging.tsx │ │ ├── CredentialsButton.tsx │ │ ├── CredentialsRequired.scss │ │ ├── CredentialsRequired.tsx │ │ ├── DefaultLogin.tsx │ │ ├── HTTPDownloadLink.tsx │ │ ├── Login.scss │ │ ├── Login.tsx │ │ ├── LoginButton.tsx │ │ ├── LoginDialog.scss │ │ ├── LoginDialog.tsx │ │ ├── LoginRequired.tsx │ │ ├── RestrictedAccessWarning.scss │ │ ├── RestrictedAccessWarning.tsx │ │ ├── ServiceLink.tsx │ │ └── index.ts │ ├── Media │ │ ├── Interstitial.scss │ │ ├── Interstitial.tsx │ │ ├── Media.scss │ │ ├── Media.tsx │ │ ├── MediaButtons.tsx │ │ ├── PlaybackState.tsx │ │ ├── ProgressBar.scss │ │ ├── ProgressBar.tsx │ │ ├── Static.scss │ │ ├── Static.tsx │ │ ├── Visualizer.scss │ │ ├── VisualizerControls.scss │ │ ├── VisualizerControls.tsx │ │ ├── index.ts │ │ ├── useCanLockVisualizer.ts │ │ ├── useInterstitialState.ts │ │ ├── useLoadingState.ts │ │ └── useVideoSourceIcon.tsx │ ├── MediaBrowser │ │ ├── Albums.tsx │ │ ├── Artists.tsx │ │ ├── DefaultBrowser.tsx │ │ ├── ErrorScreen.tsx │ │ ├── FilterBrowser.tsx │ │ ├── FilterSelect.scss │ │ ├── FilterSelect.tsx │ │ ├── FolderBrowser.tsx │ │ ├── HistoryBrowser.tsx │ │ ├── MediaBrowser.scss │ │ ├── MediaBrowser.tsx │ │ ├── MediaItems.tsx │ │ ├── MediaSourceSelector.tsx │ │ ├── PageHeader.scss │ │ ├── PageHeader.tsx │ │ ├── PagedItems.tsx │ │ ├── PinnedPlaylist.scss │ │ ├── PinnedPlaylist.tsx │ │ ├── Playlists.tsx │ │ ├── RecentlyPlayedBrowser.tsx │ │ ├── index.ts │ │ ├── showMediaSourceOptions.tsx │ │ ├── useAlbumTracksLayout.ts │ │ ├── useErrorScreen.tsx │ │ ├── useHistoryPager.ts │ │ ├── useNoInternetError.tsx │ │ └── useRecentlyPlayedPager.ts │ ├── MediaControls │ │ ├── MediaButton.scss │ │ ├── MediaButton.tsx │ │ ├── MediaControls.scss │ │ ├── MediaControls.tsx │ │ ├── PlaylistMenu.tsx │ │ ├── VolumeControl.scss │ │ ├── VolumeControl.tsx │ │ ├── index.ts │ │ └── usePlaylistMenu.tsx │ ├── MediaInfo │ │ ├── CurrentlyPlaying.tsx │ │ ├── CurrentlyPlayingDialog.tsx │ │ ├── CurrentlyPlayingTabs.tsx │ │ ├── MediaDetails.scss │ │ ├── MediaDetails.tsx │ │ ├── MediaInfo.scss │ │ ├── MediaInfo.tsx │ │ ├── MediaInfoDialog.scss │ │ ├── MediaInfoDialog.tsx │ │ ├── MediaInfoTabs.scss │ │ ├── MediaInfoTabs.tsx │ │ ├── VisualizerInfo.scss │ │ ├── VisualizerInfo.tsx │ │ ├── useActiveItem.ts │ │ └── useMediaInfoDialog.ts │ ├── MediaLibrary │ │ ├── MediaLibrary.scss │ │ ├── MediaLibrary.tsx │ │ └── index.ts │ ├── MediaList │ │ ├── AlbumList.tsx │ │ ├── ArtistList.tsx │ │ ├── FolderItemList.tsx │ │ ├── MediaItemList.tsx │ │ ├── MediaList.scss │ │ ├── MediaList.tsx │ │ ├── MediaListStatusBar.scss │ │ ├── MediaListStatusBar.tsx │ │ ├── PlaylistList.tsx │ │ ├── ProgressRing.scss │ │ ├── ProgressRing.tsx │ │ ├── index.ts │ │ ├── showActionsMenu.tsx │ │ ├── useIsPlaylistPlayable.ts │ │ ├── useMediaListLayout.tsx │ │ ├── useOnDragStart.ts │ │ └── useViewClassName.ts │ ├── MediaPlayback │ │ ├── MediaPlayback.scss │ │ ├── MediaPlayback.tsx │ │ └── index.ts │ ├── MediaSources │ │ ├── MediaServiceLabel.tsx │ │ ├── MediaSourceLabel.scss │ │ ├── MediaSourceLabel.tsx │ │ ├── MediaSources.tsx │ │ ├── ProvidedBy.tsx │ │ ├── index.ts │ │ └── useMediaSources.tsx │ ├── MiniPlayer │ │ ├── MiniPlayer.scss │ │ ├── MiniPlayer.tsx │ │ └── index.ts │ ├── Playlist │ │ ├── Playlist.scss │ │ ├── Playlist.tsx │ │ ├── index.ts │ │ ├── showActionsMenu.tsx │ │ ├── usePlaylistInject.tsx │ │ └── usePlaylistLayout.tsx │ ├── PopupMenu │ │ ├── PopupMenu.scss │ │ ├── PopupMenu.tsx │ │ ├── PopupMenuItem.tsx │ │ ├── PopupMenuItemCheckbox.tsx │ │ ├── PopupMenuSeparator.tsx │ │ ├── index.ts │ │ └── showPopupMenu.tsx │ ├── Scrollable │ │ ├── FixedHeader.tsx │ │ ├── Scrollable.scss │ │ ├── Scrollable.tsx │ │ ├── Scrollbar.scss │ │ ├── Scrollbar.tsx │ │ ├── index.ts │ │ ├── scrollbarReducer.ts │ │ └── useScrollbarState.ts │ ├── SearchBar │ │ ├── SearchBar.scss │ │ ├── SearchBar.tsx │ │ └── index.ts │ ├── Settings │ │ ├── AdvancedSettings │ │ │ ├── AdvancedSettings.scss │ │ │ ├── AdvancedSettings.tsx │ │ │ ├── Backup.scss │ │ │ ├── Backup.tsx │ │ │ ├── Logs.scss │ │ │ ├── Logs.tsx │ │ │ ├── Troubleshooting.tsx │ │ │ ├── index.ts │ │ │ ├── useBackupEntries.ts │ │ │ └── useLogs.ts │ │ ├── AppSettings │ │ │ ├── AppPreferences.tsx │ │ │ ├── AppSettings.scss │ │ │ ├── AppSettings.tsx │ │ │ ├── AppSettingsGeneral.tsx │ │ │ └── index.ts │ │ ├── AppearanceSettings │ │ │ ├── AppearanceSettings.tsx │ │ │ ├── AppearanceSettingsGeneral.scss │ │ │ ├── AppearanceSettingsGeneral.tsx │ │ │ ├── ThemeEditor │ │ │ │ ├── SaveThemeDialog.scss │ │ │ │ ├── SaveThemeDialog.tsx │ │ │ │ ├── ThemeColor.tsx │ │ │ │ ├── ThemeColorPair.tsx │ │ │ │ ├── ThemeEditor.scss │ │ │ │ ├── ThemeEditor.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── saveTheme.tsx │ │ │ │ └── useSuggestedColors.ts │ │ │ ├── UserThemes.scss │ │ │ ├── UserThemes.tsx │ │ │ ├── confirmDeleteTheme.ts │ │ │ ├── confirmOverwriteTheme.ts │ │ │ ├── importThemeFromFile.ts │ │ │ ├── index.ts │ │ │ ├── useCurrentTheme.ts │ │ │ ├── useDefaultThemes.ts │ │ │ └── useUserThemes.ts │ │ ├── MediaLibrarySettings │ │ │ ├── AudioSettings.scss │ │ │ ├── AudioSettings.tsx │ │ │ ├── CredentialsDialog.scss │ │ │ ├── CredentialsDialog.tsx │ │ │ ├── CredentialsInput.tsx │ │ │ ├── CredentialsRegistration.tsx │ │ │ ├── DisconnectButton.scss │ │ │ ├── DisconnectButton.tsx │ │ │ ├── LookupSettings.tsx │ │ │ ├── MediaServiceCredentials.scss │ │ │ ├── MediaServiceCredentials.tsx │ │ │ ├── MediaServiceList.scss │ │ │ ├── MediaServiceList.tsx │ │ │ ├── MediaServiceSettings.tsx │ │ │ ├── MediaServiceSettingsGeneral.scss │ │ │ ├── MediaServiceSettingsGeneral.tsx │ │ │ ├── MediaServicesSettings.tsx │ │ │ ├── MediaServicesSettingsGeneral.tsx │ │ │ ├── PersonalMediaServerInfo.scss │ │ │ ├── PersonalMediaServerInfo.tsx │ │ │ ├── PersonalMediaServerSettings.scss │ │ │ ├── PersonalMediaServerSettings.tsx │ │ │ ├── PinnedSettings.scss │ │ │ ├── PinnedSettings.tsx │ │ │ ├── ScrobblingSettings.tsx │ │ │ ├── confirmDisconnectServices.scss │ │ │ ├── confirmDisconnectServices.tsx │ │ │ ├── useAudioLibraries.ts │ │ │ ├── usePinsForService.ts │ │ │ └── useServerInfo.ts │ │ ├── SettingsDialog.scss │ │ ├── SettingsDialog.tsx │ │ ├── VisualizerSettings │ │ │ ├── AmbientVideoSettings.tsx │ │ │ ├── AmpshaderSettings.tsx │ │ │ ├── ButterchurnSettings.scss │ │ │ ├── ButterchurnSettings.tsx │ │ │ ├── CoverArtSettings.tsx │ │ │ ├── VisualizerFavorites.scss │ │ │ ├── VisualizerFavorites.tsx │ │ │ ├── VisualizerRandomness.scss │ │ │ ├── VisualizerRandomness.tsx │ │ │ ├── VisualizerSettings.scss │ │ │ ├── VisualizerSettings.tsx │ │ │ ├── VisualizerSettingsDialog.tsx │ │ │ ├── VisualizerSettingsGeneral.tsx │ │ │ ├── index.ts │ │ │ ├── useVisualizerFavorites.ts │ │ │ └── useVisualizerRandomness.ts │ │ ├── index.ts │ │ └── useSettingsSources.tsx │ ├── Splitter │ │ ├── Splitter.scss │ │ ├── Splitter.tsx │ │ ├── index.ts │ │ ├── layout.scss │ │ └── layoutSettings.ts │ ├── StarRating │ │ ├── StarRating.scss │ │ ├── StarRating.tsx │ │ └── index.ts │ ├── StartupWizard │ │ ├── StartupWizard.scss │ │ ├── StartupWizard.tsx │ │ └── index.ts │ ├── StatusBar │ │ ├── StatusBar.scss │ │ ├── StatusBar.tsx │ │ └── index.ts │ ├── SunClock │ │ ├── SunClock.scss │ │ ├── SunClock.tsx │ │ └── index.ts │ ├── TabList │ │ ├── Tab.tsx │ │ ├── TabList.scss │ │ ├── TabList.tsx │ │ ├── TabPanel.tsx │ │ └── index.ts │ ├── TextBox │ │ ├── TextBox.scss │ │ ├── TextBox.tsx │ │ └── index.ts │ ├── Time │ │ ├── Time.tsx │ │ └── index.ts │ └── TreeView │ │ ├── TreeView.scss │ │ ├── TreeView.tsx │ │ ├── TreeViewNode.tsx │ │ ├── index.ts │ │ ├── useNodeIds.ts │ │ └── useTreeViewState.ts ├── hooks │ ├── useBaseFontSize.ts │ ├── useCurrentTime.ts │ ├── useCurrentTrack.ts │ ├── useCurrentVisualizer.ts │ ├── useCurrentlyPlaying.ts │ ├── useDebouncedValue.ts │ ├── useDuration.ts │ ├── useFactoryReset.ts │ ├── useFirstValue.ts │ ├── useFontSize.ts │ ├── useIsLoggedIn.ts │ ├── useIsOnLine.ts │ ├── useIsPlaying.ts │ ├── useKeyboardBusy.ts │ ├── useMediaServices.ts │ ├── useMiniPlayerActive.ts │ ├── useMouseBusy.ts │ ├── useObservable.ts │ ├── useOnResize.ts │ ├── usePager.tsx │ ├── usePaused.ts │ ├── usePlaybackState.ts │ ├── usePreferences.ts │ ├── usePrevious.ts │ ├── useSearch.ts │ ├── useSorting.ts │ ├── useSource.ts │ ├── useSubject.ts │ ├── useThrottledValue.ts │ ├── useVisualizerProviders.ts │ ├── useVisualizerSettings.ts │ └── useYouTubeVideoInfo.ts ├── html │ └── index.html ├── index.tsx ├── registerServiceWorker.tsx ├── service-worker.js ├── services │ ├── actions │ │ └── actionsStore.ts │ ├── ampcastElectron.ts │ ├── apple │ │ ├── AppleBitrate.ts │ │ ├── MusicKit.d.ts │ │ ├── MusicKitPager.ts │ │ ├── apple.ts │ │ ├── appleAuth.ts │ │ ├── appleSettings.ts │ │ ├── bootstrap.ts │ │ ├── components │ │ │ ├── AppleCredentials.tsx │ │ │ ├── AppleLogin.tsx │ │ │ ├── AppleStreamingSettings.tsx │ │ │ ├── useCredentials.ts │ │ │ └── useMusicKit.ts │ │ ├── index.ts │ │ ├── musicKitPlayer.ts │ │ └── musicKitUtils.ts │ ├── audio │ │ ├── OmniAnalyserNode.ts │ │ ├── OmniAudioContext.ts │ │ ├── audio.ts │ │ ├── audioSettings.ts │ │ └── index.ts │ ├── buildConfig.ts │ ├── constants.ts │ ├── emby │ │ ├── EmbyPager.ts │ │ ├── components │ │ │ └── EmbyLoginDialog.tsx │ │ ├── emby.ts │ │ ├── embyApi.ts │ │ ├── embyAuth.ts │ │ ├── embyReporting.ts │ │ ├── embyScrobbler.ts │ │ ├── embySettings.ts │ │ └── index.ts │ ├── errors.ts │ ├── globalDrag.ts │ ├── i18n.ts │ ├── jellyfin │ │ ├── JellyfinPager.ts │ │ ├── index.ts │ │ ├── jellyfin.ts │ │ ├── jellyfinApi.ts │ │ ├── jellyfinAuth.ts │ │ └── jellyfinSettings.ts │ ├── lastfm │ │ ├── LastFmHistoryPager.ts │ │ ├── LastFmPager.ts │ │ ├── components │ │ │ ├── LastFmCredentials.tsx │ │ │ ├── LastFmHistoryBrowser.tsx │ │ │ ├── LastFmLogin.tsx │ │ │ ├── LastFmScrobblesBrowser.tsx │ │ │ ├── useCredentials.ts │ │ │ └── useHistoryStart.ts │ │ ├── index.ts │ │ ├── lastfm.ts │ │ ├── lastfmApi.ts │ │ ├── lastfmAuth.ts │ │ ├── lastfmScrobbler.ts │ │ ├── lastfmSettings.ts │ │ └── types.d.ts │ ├── listenbrainz │ │ ├── ListenBrainzHistoryPager.ts │ │ ├── ListenBrainzLikesPager.ts │ │ ├── ListenBrainzNewAlbumsPager.ts │ │ ├── ListenBrainzPlaylistItemsPager.ts │ │ ├── ListenBrainzPlaylistsPager.ts │ │ ├── ListenBrainzStatsPager.ts │ │ ├── components │ │ │ ├── ListenBrainzHistoryBrowser.tsx │ │ │ ├── ListenBrainzLoginDialog.scss │ │ │ ├── ListenBrainzLoginDialog.tsx │ │ │ ├── ListenBrainzScrobblesBrowser.tsx │ │ │ └── useHistoryStart.ts │ │ ├── index.ts │ │ ├── listenbrainz.ts │ │ ├── listenbrainzApi.ts │ │ ├── listenbrainzAuth.ts │ │ ├── listenbrainzScrobbler.ts │ │ ├── listenbrainzSettings.ts │ │ └── types.d.ts │ ├── localdb │ │ └── listens.ts │ ├── lookup │ │ ├── index.ts │ │ ├── lookup.ts │ │ ├── lookupEvents.ts │ │ └── lookupSettings.ts │ ├── mediaPlayback │ │ ├── defaultPlaybackState.ts │ │ ├── index.ts │ │ ├── mediaPlayback.ts │ │ ├── mediaPlaybackSettings.ts │ │ ├── mediaPlayer.ts │ │ ├── miniPlayer.ts │ │ ├── miniPlayerRemote.ts │ │ ├── playback.ts │ │ ├── players │ │ │ ├── DualAudioPlayer.ts │ │ │ ├── HLSPlayer.ts │ │ │ ├── HTML5Player.ts │ │ │ ├── OmniPlayer.ts │ │ │ ├── hlsMetadataPlayer.ts │ │ │ ├── icecastPlayer.ts │ │ │ └── observeNearEnd.ts │ │ ├── scrobbler.ts │ │ └── visualizerPlayer.ts │ ├── mediaServices │ │ ├── all.ts │ │ ├── index.ts │ │ ├── mediaServices.ts │ │ ├── noAuth.ts │ │ └── servicesSettings.ts │ ├── metadata │ │ ├── index.ts │ │ ├── matcher.ts │ │ ├── metadata.ts │ │ ├── metadataChanges.ts │ │ ├── music-metadata-js.ts │ │ ├── playlistParser.ts │ │ ├── thumbnails.ts │ │ └── userData.ts │ ├── mixcloud │ │ ├── index.ts │ │ ├── mixcloud.ts │ │ ├── mixcloudApi.ts │ │ ├── mixcloudPlayer.ts │ │ └── types.d.ts │ ├── musicbrainz │ │ ├── MusicBrainzAlbumTracksPager.ts │ │ ├── coverart.ts │ │ ├── digitalFormats.ts │ │ ├── index.ts │ │ ├── musicbrainzApi.ts │ │ └── types.d.ts │ ├── navidrome │ │ ├── NavidromePager.ts │ │ ├── components │ │ │ ├── NavidromeLoginDialog.tsx │ │ │ └── NavidromeServerSettings.tsx │ │ ├── index.ts │ │ ├── navidrome.ts │ │ ├── navidromeApi.ts │ │ ├── navidromeAuth.ts │ │ ├── navidromeSettings.ts │ │ ├── subsonicApi.ts │ │ └── types.d.ts │ ├── online.ts │ ├── pagers │ │ ├── AbstractPager.ts │ │ ├── ErrorPager.ts │ │ ├── OffsetPager.ts │ │ ├── SequentialPager.ts │ │ ├── SimpleMediaPager.ts │ │ ├── SimplePager.ts │ │ ├── SubjectPager.ts │ │ ├── WrappedPager.ts │ │ ├── fetchAllTracks.ts │ │ └── fetchFirstPage.ts │ ├── pins │ │ └── pinStore.ts │ ├── playlist │ │ ├── index.ts │ │ └── playlist.ts │ ├── plex │ │ ├── PlexPager.ts │ │ ├── bootstrap.ts │ │ ├── components │ │ │ ├── PlexHost.tsx │ │ │ ├── PlexLogin.tsx │ │ │ ├── PlexServerSettings.tsx │ │ │ ├── usePinRefresher.ts │ │ │ └── usePlexMediaServers.ts │ │ ├── index.ts │ │ ├── plex.ts │ │ ├── plexApi.ts │ │ ├── plexAuth.ts │ │ ├── plexItemType.ts │ │ ├── plexMediaType.ts │ │ ├── plexRadioPlayer.ts │ │ ├── plexReporting.ts │ │ ├── plexScrobbler.ts │ │ ├── plexSettings.ts │ │ ├── plexUtils.ts │ │ └── types.d.ts │ ├── preferences.ts │ ├── recentPlaylists.ts │ ├── reporting.ts │ ├── scrobbleSettings.ts │ ├── session.ts │ ├── soundcloud │ │ ├── index.ts │ │ ├── soundcloud.ts │ │ ├── soundcloudApi.ts │ │ └── soundcloudPlayer.ts │ ├── spotify │ │ ├── SpotifyPager.ts │ │ ├── bootstrap.ts │ │ ├── components │ │ │ ├── SpotifyCredentials.tsx │ │ │ ├── SpotifyLogin.tsx │ │ │ └── useCredentials.ts │ │ ├── index.ts │ │ ├── samplePitches.ts │ │ ├── spotify.ts │ │ ├── spotifyApi.ts │ │ ├── spotifyAudioAnalyser.ts │ │ ├── spotifyAuth.ts │ │ ├── spotifyPlayer.ts │ │ └── spotifySettings.ts │ ├── subsonic │ │ ├── airsonic.ts │ │ ├── ampache.ts │ │ ├── factory │ │ │ ├── SubsonicApi.ts │ │ │ ├── SubsonicAuth.ts │ │ │ ├── SubsonicPager.ts │ │ │ ├── SubsonicService.ts │ │ │ ├── SubsonicSettings.ts │ │ │ ├── components │ │ │ │ ├── SubsonicLoginDialog.scss │ │ │ │ └── SubsonicLoginDialog.tsx │ │ │ └── subsonicScrobbler.ts │ │ ├── gonic.ts │ │ ├── index.ts │ │ ├── subsonic.ts │ │ └── types.d.ts │ ├── theme │ │ ├── fonts.ts │ │ ├── index.ts │ │ ├── theme.ts │ │ ├── themeStore.ts │ │ └── themes │ │ │ ├── astronaut.json │ │ │ ├── blackgold.json │ │ │ ├── carbon.json │ │ │ ├── contrast.json │ │ │ ├── debug.json │ │ │ ├── default.json │ │ │ ├── glacier.json │ │ │ ├── index.ts │ │ │ ├── indigo.json │ │ │ ├── jeep.json │ │ │ ├── lego.json │ │ │ ├── mellowyellow.json │ │ │ ├── moodyblue.json │ │ │ ├── neon.json │ │ │ ├── notebook.json │ │ │ ├── palepink.json │ │ │ ├── potpourri.json │ │ │ ├── proton.json │ │ │ ├── purplelicious.json │ │ │ ├── radioactive.json │ │ │ ├── saddle.json │ │ │ ├── treasure.json │ │ │ ├── velvet.json │ │ │ ├── winamp-classic.json │ │ │ └── winamp-modern.json │ ├── tidal │ │ ├── TidalPager.ts │ │ ├── bootstrap.ts │ │ ├── components │ │ │ ├── TidalCredentials.tsx │ │ │ ├── TidalLogin.tsx │ │ │ └── useCredentials.ts │ │ ├── index.ts │ │ ├── tidal.ts │ │ ├── tidalApi.ts │ │ ├── tidalAuth.ts │ │ ├── tidalPlayer.ts │ │ └── tidalSettings.ts │ ├── visualizer │ │ ├── AbstractVisualizerPlayer.ts │ │ ├── ambientvideo │ │ │ ├── AmbientVideoPlayer.ts │ │ │ ├── ambientvideo.ts │ │ │ ├── index.ts │ │ │ └── visualizers │ │ │ │ ├── defaultAmbientVideos.ts │ │ │ │ └── index.ts │ │ ├── ampshader │ │ │ ├── AmpShaderPlayer.ts │ │ │ ├── ampshader.ts │ │ │ ├── footer.frag │ │ │ ├── header.frag │ │ │ ├── index.ts │ │ │ └── visualizers │ │ │ │ ├── 20221105_inercia.frag │ │ │ │ ├── 25boxes.frag │ │ │ │ ├── 3dAudioVisualizer2.frag │ │ │ │ ├── abstractMusic.frag │ │ │ │ ├── abstract_audio_react.frag │ │ │ │ ├── allNight.frag │ │ │ │ ├── ambilight.frag │ │ │ │ ├── attemptAtVdjEffects.frag │ │ │ │ ├── audioFlightV2.frag │ │ │ │ ├── audioPulsar.frag │ │ │ │ ├── audioReactiveFractal.frag │ │ │ │ ├── audioReactiveScene1.frag │ │ │ │ ├── audioSpectrum.frag │ │ │ │ ├── audioVisualizer.frag │ │ │ │ ├── audioVizWavePsyTrance.frag │ │ │ │ ├── audioWaveformVisualizerV4.frag │ │ │ │ ├── av4Lasers.frag │ │ │ │ ├── avRainbow.frag │ │ │ │ ├── avRaymarching.frag │ │ │ │ ├── barebones.frag │ │ │ │ ├── basicAudioVisualizerModified.frag │ │ │ │ ├── beatOfBrokenHearts.frag │ │ │ │ ├── brokowi .frag │ │ │ │ ├── bubbles.frag │ │ │ │ ├── burnSoundWave.frag │ │ │ │ ├── carelsAudioVisualizer.frag │ │ │ │ ├── cavitation.frag │ │ │ │ ├── chromaticResonance.frag │ │ │ │ ├── circuits.frag │ │ │ │ ├── cityAtNight.frag │ │ │ │ ├── clairDeLune.frag │ │ │ │ ├── convertedPlasma.frag │ │ │ │ ├── creation.frag │ │ │ │ ├── cylonsJam.frag │ │ │ │ ├── dancingDots.frag │ │ │ │ ├── dancingGlowLights.frag │ │ │ │ ├── dancingJellyfish.frag │ │ │ │ ├── dancingOctopus.frag │ │ │ │ ├── disco2000.frag │ │ │ │ ├── diveIntoGeometry.frag │ │ │ │ ├── eyeOfHajiSauron.frag │ │ │ │ ├── fft-ifs.frag │ │ │ │ ├── firstShaderTest.frag │ │ │ │ ├── firstVisualiser_globaldusk.frag │ │ │ │ ├── forkFractal77.frag │ │ │ │ ├── forkSoundEclipNobody012.frag │ │ │ │ ├── forkSoundEclipReverland340.frag │ │ │ │ ├── forkWaves.frag │ │ │ │ ├── fractalAudio01.frag │ │ │ │ ├── fractalBrownian.frag │ │ │ │ ├── fractalLand.frag │ │ │ │ ├── gameboy.frag │ │ │ │ ├── gatoNegroPasa.frag │ │ │ │ ├── gauges.frag │ │ │ │ ├── goatranceTrip.frag │ │ │ │ ├── hilbertColor.frag │ │ │ │ ├── index.ts │ │ │ │ ├── io.frag │ │ │ │ ├── issues.frag │ │ │ │ ├── lelabah.frag │ │ │ │ ├── lightningStorm.frag │ │ │ │ ├── mandelKoch.frag │ │ │ │ ├── mellowRainbowBlob.frag │ │ │ │ ├── morph.frag │ │ │ │ ├── musicMandelBoxColour.frag │ │ │ │ ├── musicVisualiser.frag │ │ │ │ ├── musicVisualizer3.frag │ │ │ │ ├── music_Spheres.frag │ │ │ │ ├── musicalDandelions.frag │ │ │ │ ├── myLightShow.frag │ │ │ │ ├── nanoKontrol2.frag │ │ │ │ ├── nautilus.frag │ │ │ │ ├── neonPyramid.frag │ │ │ │ ├── neonRiverVisualizer.frag │ │ │ │ ├── noiseNoiseRaymarching.frag │ │ │ │ ├── oscEqualizer.frag │ │ │ │ ├── otherworldy.frag │ │ │ │ ├── particlesDance.frag │ │ │ │ ├── plasmaGlobe.frag │ │ │ │ ├── playingAroundWithSpirals.frag │ │ │ │ ├── popShift.frag │ │ │ │ ├── presets.ts │ │ │ │ ├── psychedelicEye.frag │ │ │ │ ├── psychedelicLines.frag │ │ │ │ ├── purpleSpaghetti.frag │ │ │ │ ├── radialSoundVisualizer.frag │ │ │ │ ├── rainbow.frag │ │ │ │ ├── russianRoulette.frag │ │ │ │ ├── sailingBeyond.frag │ │ │ │ ├── shadowDancing.frag │ │ │ │ ├── shimmy.frag │ │ │ │ ├── simpleRainbow.frag │ │ │ │ ├── skulls.frag │ │ │ │ ├── soapBubble.frag │ │ │ │ ├── solarDance.frag │ │ │ │ ├── solumObject.frag │ │ │ │ ├── soundEclipseRpm.frag │ │ │ │ ├── soundSinusWave.frag │ │ │ │ ├── spaceWithMusic.frag │ │ │ │ ├── spaceshipConsole.frag │ │ │ │ ├── spaghettis.frag │ │ │ │ ├── speaker.frag │ │ │ │ ├── symmetricalSoundVisualiser.frag │ │ │ │ ├── technoCore.frag │ │ │ │ ├── test.frag │ │ │ │ ├── test2.frag │ │ │ │ ├── uctumi.frag │ │ │ │ ├── violentEyeSimulator314.frag │ │ │ │ ├── voyager.frag │ │ │ │ ├── wavesRemix.frag │ │ │ │ └── yearOfTruchets018.frag │ │ ├── audiomotion │ │ │ ├── AudioMotionPlayer.ts │ │ │ ├── audiomotion.ts │ │ │ ├── index.ts │ │ │ └── visualizers │ │ │ │ └── index.ts │ │ ├── butterchurn │ │ │ ├── ButterchurnPlayer.ts │ │ │ ├── butterchurn.ts │ │ │ ├── index.ts │ │ │ ├── types.d.ts │ │ │ └── visualizers │ │ │ │ └── index.ts │ │ ├── coverart │ │ │ ├── AnimatedBackgroundPlayer.ts │ │ │ ├── CovertArtPlayer.ts │ │ │ ├── animatedBackground.frag │ │ │ ├── components │ │ │ │ ├── CoverArtVisualizer.scss │ │ │ │ ├── CoverArtVisualizer.tsx │ │ │ │ ├── CurrentlyPlaying.tsx │ │ │ │ └── useNextTrack.ts │ │ │ ├── coverart.ts │ │ │ ├── index.ts │ │ │ └── visualizers │ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── spotifyviz │ │ │ ├── SpotifyVizPlayer.ts │ │ │ ├── index.ts │ │ │ ├── spotifyviz.ts │ │ │ └── visualizers │ │ │ │ ├── example.ts │ │ │ │ └── index.ts │ │ ├── visualizer.ts │ │ ├── visualizerProviders.ts │ │ ├── visualizerSettings.ts │ │ ├── visualizerStore.ts │ │ ├── visualizers.ts │ │ └── waveform │ │ │ ├── BeatsPlayer.ts │ │ │ ├── WaveformPlayer.ts │ │ │ ├── index.ts │ │ │ ├── visualizers │ │ │ ├── index.ts │ │ │ └── test.ts │ │ │ └── waveform.ts │ └── youtube │ │ ├── YouTubePager.ts │ │ ├── YouTubePlayer.ts │ │ ├── YouTubePlaylistLoader.ts │ │ ├── components │ │ ├── YouTubeCredentials.tsx │ │ ├── YouTubeLogin.tsx │ │ ├── useCredentials.ts │ │ └── useGoogleClientLibrary.ts │ │ ├── index.ts │ │ ├── youtube.ts │ │ ├── youtubeApi.ts │ │ ├── youtubeAuth.ts │ │ ├── youtubeCache.ts │ │ └── youtubeSettings.ts ├── styles │ ├── _effects.scss │ ├── base.scss │ ├── forms.scss │ ├── index.scss │ ├── libs │ │ └── minireset.scss │ ├── reset.scss │ └── theming.scss ├── types │ ├── Action.ts │ ├── AmpcastElectron.d.ts │ ├── AudioManager.d.ts │ ├── AudioSettings.d.ts │ ├── Auth.d.ts │ ├── BackupFile.d.ts │ ├── BaseMediaObject.d.ts │ ├── BaseMediaService.d.ts │ ├── BaseVisualizer.d.ts │ ├── Browsable.d.ts │ ├── BuildConfig.d.ts │ ├── ChildOf.d.ts │ ├── CreatePlaylistOptions.d.ts │ ├── DRMInfo.d.ts │ ├── DRMKeySystem.d.ts │ ├── DRMType.d.ts │ ├── DataService.ts │ ├── ErrorReport.d.ts │ ├── FilterType.ts │ ├── ItemType.ts │ ├── ItemsByService.d.ts │ ├── LibraryAction.d.ts │ ├── LinearType.ts │ ├── Listen.d.ts │ ├── LookupStatus.ts │ ├── MediaAlbum.d.ts │ ├── MediaArtist.d.ts │ ├── MediaFilter.d.ts │ ├── MediaFolder.d.ts │ ├── MediaFolderItem.d.ts │ ├── MediaItem.d.ts │ ├── MediaObject.d.ts │ ├── MediaPlayback.d.ts │ ├── MediaPlaylist.d.ts │ ├── MediaSearchParams.d.ts │ ├── MediaService.d.ts │ ├── MediaServiceId.d.ts │ ├── MediaSource.d.ts │ ├── MediaSourceLayout.d.ts │ ├── MediaType.ts │ ├── MetadataChange.d.ts │ ├── NextVisualizerReason.d.ts │ ├── Pager.d.ts │ ├── ParentOf.d.ts │ ├── PersonalMediaLibrary.d.ts │ ├── PersonalMediaServerSettings.d.ts │ ├── PersonalMediaService.d.ts │ ├── Pin.d.ts │ ├── PlayAction.d.ts │ ├── PlayableItem.d.ts │ ├── Playback.d.ts │ ├── PlaybackState.d.ts │ ├── PlaybackType.ts │ ├── Player.d.ts │ ├── Playlist.d.ts │ ├── PlaylistItem.d.ts │ ├── Preferences.d.ts │ ├── PublicMediaService.d.ts │ ├── ReplayGainMode.d.ts │ ├── Report.d.ts │ ├── ServiceType.ts │ ├── Snapshot.d.ts │ ├── Theme.d.ts │ ├── Thumbnail.d.ts │ ├── UserData.d.ts │ ├── UserTheme.d.ts │ ├── Visualizer.d.ts │ ├── VisualizerFavorite.d.ts │ ├── VisualizerProvider.d.ts │ ├── VisualizerProviderId.d.ts │ ├── VisualizerSettings.d.ts │ └── global.d.ts └── utils │ ├── LiteStorage │ ├── LiteStorage.ts │ ├── index.ts │ └── memoryStorage.ts │ ├── Logger.ts │ ├── RateLimiter.ts │ ├── array.ts │ ├── browser.ts │ ├── date.ts │ ├── dom.ts │ ├── event.ts │ ├── fetch.ts │ ├── index.ts │ ├── media.ts │ ├── number.ts │ ├── string.ts │ └── utils.ts ├── tsconfig.json └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | # ignore everything 2 | * 3 | 4 | # except: 5 | !src 6 | !src/** 7 | -------------------------------------------------------------------------------- /.github/workflows/test-castlabs.yml: -------------------------------------------------------------------------------- 1 | name: Test castlabs integration 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | test: 7 | name: Test castlabs integration (windows) 8 | runs-on: windows-latest 9 | timeout-minutes: 10 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: "20.x" 16 | 17 | - name: Install castlabs-evs 18 | working-directory: ./app 19 | run: python -m pip install --upgrade castlabs-evs 20 | 21 | - name: Test castlabs-evs 22 | working-directory: ./app 23 | run: python -m castlabs_evs.account --version 24 | 25 | - name: Authorize 26 | working-directory: ./app 27 | run: python -m castlabs_evs.account reauth -A ${{secrets.CASTLABS_ACCOUNT_NAME}} -P ${{secrets.CASTLABS_PASSWORD}} 28 | -------------------------------------------------------------------------------- /.github/workflows/test-notarytool.yml: -------------------------------------------------------------------------------- 1 | name: Test mac notarytool 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | test: 7 | name: Run mac notarytool 8 | runs-on: macos-latest 9 | timeout-minutes: 10 10 | 11 | steps: 12 | - name: notarytool history 13 | run: xcrun notarytool history --apple-id ${{secrets.APPLE_ID}} --password ${{secrets.APPLE_ID_PASSWORD}} --team-id ${{secrets.APPLE_TEAM_ID}} 14 | 15 | #- name: notarytool last invalid run 16 | # run: xcrun notarytool log 59eda425-049f-48f0-ad42-0524b1cdc78f --apple-id ${{secrets.APPLE_ID}} --password ${{secrets.APPLE_ID_PASSWORD}} --team-id ${{secrets.APPLE_TEAM_ID}} 17 | 18 | #- name: notarytool first inprogress run 19 | # run: xcrun notarytool log 6d75594c-f8d3-445c-903c-9e18f40e7ae6 --apple-id ${{secrets.APPLE_ID}} --password ${{secrets.APPLE_ID_PASSWORD}} --team-id ${{secrets.APPLE_TEAM_ID}} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | node_modules 4 | www-dev 5 | .env 6 | .env.* 7 | !.env.example 8 | stats.json 9 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | node_modules 4 | dist 5 | www/index.html 6 | www/*.js 7 | www/*.css 8 | www/*.zip 9 | www/v*/* 10 | -------------------------------------------------------------------------------- /app/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "ampcast", 4 | "asar", 5 | "castlabs", 6 | "Emby", 7 | "Jellyfin", 8 | "ListenBrainz", 9 | "Milkdrop", 10 | "Navidrome", 11 | "portfinder", 12 | "rekkyrosso", 13 | "togglefullscreen", 14 | "Winamp", 15 | "wvcus" 16 | ] 17 | } -------------------------------------------------------------------------------- /app/assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/assets/icons/ampcast-grayscale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast-grayscale.png -------------------------------------------------------------------------------- /app/assets/icons/ampcast.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast.ico -------------------------------------------------------------------------------- /app/assets/icons/ampcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast.png -------------------------------------------------------------------------------- /app/assets/icons/ampcast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/icons/ampcast128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast128x128.png -------------------------------------------------------------------------------- /app/assets/icons/ampcast16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast16x16.png -------------------------------------------------------------------------------- /app/assets/icons/ampcast256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast256x256.png -------------------------------------------------------------------------------- /app/assets/icons/ampcast32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast32x32.png -------------------------------------------------------------------------------- /app/assets/icons/ampcast64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/ampcast64x64.png -------------------------------------------------------------------------------- /app/assets/icons/setup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/setup.gif -------------------------------------------------------------------------------- /app/assets/icons/setup.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/setup.ico -------------------------------------------------------------------------------- /app/assets/icons/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/assets/icons/setup.png -------------------------------------------------------------------------------- /app/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | tabWidth: 4, 4 | printWidth: 100, 5 | endOfLine: 'lf', 6 | bracketSpacing: false, 7 | }; 8 | -------------------------------------------------------------------------------- /app/scripts/afterPack.js: -------------------------------------------------------------------------------- 1 | exports.default = function (context) { 2 | // Skip if not mac 3 | if (process.platform !== 'darwin') { 4 | return; 5 | } 6 | 7 | // VMP sign via EVS 8 | const {execSync} = require('child_process'); 9 | console.log(' • VMP signing start'); 10 | execSync('python -m castlabs_evs.vmp sign-pkg ' + context.appOutDir); 11 | console.log(' • VMP signing complete'); 12 | }; 13 | -------------------------------------------------------------------------------- /app/scripts/uninstaller.nsh: -------------------------------------------------------------------------------- 1 | !macro customUnInstall 2 | MessageBox MB_YESNO "Remove all data associated with this app?" \ 3 | /SD IDNO IDNO Skipped IDYES Accepted 4 | 5 | Accepted: 6 | RMDir /r "$APPDATA\${APP_FILENAME}" 7 | !ifdef APP_PRODUCT_FILENAME 8 | RMDir /r "$APPDATA\${APP_PRODUCT_FILENAME}" 9 | !endif 10 | Goto done 11 | Skipped: 12 | Goto done 13 | done: 14 | !macroend -------------------------------------------------------------------------------- /app/src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/src/icon.png -------------------------------------------------------------------------------- /app/src/preload.js: -------------------------------------------------------------------------------- 1 | const {contextBridge, ipcRenderer} = require('electron'); 2 | 3 | contextBridge.exposeInMainWorld('ampcastElectron', { 4 | quit: () => ipcRenderer.send('quit'), 5 | getCredential: (key) => ipcRenderer.invoke('getCredential', key), 6 | setCredential: (key, value) => ipcRenderer.invoke('setCredential', key, value), 7 | clearCredentials: () => ipcRenderer.invoke('clearCredentials'), 8 | setFontSize: (fontSize) => ipcRenderer.send('setFontSize', fontSize), 9 | setFrameColor: (color) => ipcRenderer.send('setFrameColor', color), 10 | setFrameTextColor: (color) => ipcRenderer.send('setFrameTextColor', color), 11 | getPreferredPort: () => ipcRenderer.invoke('getPreferredPort'), 12 | setPreferredPort: (port) => ipcRenderer.invoke('setPreferredPort', port), 13 | }); 14 | -------------------------------------------------------------------------------- /app/src/splash.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ampcast 4 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/src/splash.png -------------------------------------------------------------------------------- /app/src/store.js: -------------------------------------------------------------------------------- 1 | const Store = require('electron-store'); 2 | 3 | const store = new Store({ 4 | port: {type: 'number', default: 0}, 5 | }); 6 | 7 | module.exports = { 8 | get port() { 9 | return store.get('port'); 10 | }, 11 | 12 | set port(port) { 13 | store.set('port', Number(port) || 0); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /app/www/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 404 5 | 6 |
7 |

Not Found

8 |
9 | -------------------------------------------------------------------------------- /app/www/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/www/apple-touch-icon.png -------------------------------------------------------------------------------- /app/www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/www/favicon.ico -------------------------------------------------------------------------------- /app/www/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/www/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/www/icon-192.png -------------------------------------------------------------------------------- /app/www/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/www/icon-512.png -------------------------------------------------------------------------------- /app/www/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ampcast", 3 | "short_name": "ampcast", 4 | "start_url": "/", 5 | "display_override": [ 6 | "window-controls-overlay", 7 | "minimal-ui" 8 | ], 9 | "display": "standalone", 10 | "description": "Ampcast music player", 11 | "background_color": "#32312f", 12 | "theme_color": "#32312f", 13 | "icons": [ 14 | { 15 | "src": "icon-192.png", 16 | "type": "image/png", 17 | "sizes": "192x192" 18 | }, 19 | { 20 | "src": "icon-512.png", 21 | "type": "image/png", 22 | "sizes": "512x512" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /app/www/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rekkyrosso/ampcast/36978b327a478ac2b180a3c85b9aa7fb6cebb86c/app/www/preview.jpg -------------------------------------------------------------------------------- /app/www/privacy-policy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Privacy Policy 7 | 8 | 9 | 10 |

Privacy Policy for ampcast

11 |

This app does not track you, and it doesn't collect your data.

12 | 13 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | // PostCSS configuration 2 | 3 | module.exports = () => { 4 | return { 5 | plugins: [ 6 | require('autoprefixer')(), 7 | ], 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | tabWidth: 4, 4 | printWidth: 100, 5 | endOfLine: 'lf', 6 | bracketSpacing: false, 7 | }; 8 | -------------------------------------------------------------------------------- /src/assets/pixel.png.base64.ts: -------------------------------------------------------------------------------- 1 | // https://png-pixel.com/ 2 | export default `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1H 3 | AwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=`; 4 | -------------------------------------------------------------------------------- /src/assets/silence.mp3.base64.ts: -------------------------------------------------------------------------------- 1 | // https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa#gistcomment-1551393 2 | export default `data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2L 3 | jEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDA 4 | wMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6ur 5 | q6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJA 6 | AAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAA 7 | AGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gA 8 | AAAANVVV`; 9 | -------------------------------------------------------------------------------- /src/components/Actions/AddToPlaylistDialog.scss: -------------------------------------------------------------------------------- 1 | .add-to-playlist-dialog { 2 | width: 28em; 3 | 4 | .select-service { 5 | display: flex; 6 | align-items: center; 7 | 8 | select { 9 | flex: auto; 10 | } 11 | } 12 | 13 | .playlists { 14 | position: relative; 15 | height: 14em; 16 | width: 100%; 17 | inset: auto; 18 | } 19 | 20 | .scrollable { 21 | --scrollbar-size: max(10px, calc(1.25em * var(--scrollbar-thickness))); 22 | --scrollbar-color-hi: var(--scrollbar-color); 23 | --scrollbar-color-lo: var(--scrollbar-color); 24 | } 25 | 26 | .status-bar { 27 | display: none; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Actions/CreatePlaylistDialog.scss: -------------------------------------------------------------------------------- 1 | .create-playlist-dialog { 2 | textarea { 3 | min-width: 13.25em; 4 | } 5 | 6 | &.service-emby &-public label, 7 | &.service-jellyfin &-public label, 8 | &.service-plex &-public label { 9 | opacity: 0.5; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Actions/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Actions'; 2 | export * from './Actions'; 3 | export * from './PlaylistActions'; 4 | export {default as performAction} from './performAction'; 5 | -------------------------------------------------------------------------------- /src/components/Actions/useEditablePlaylistsPager.ts: -------------------------------------------------------------------------------- 1 | import MediaPlaylist from 'types/MediaPlaylist'; 2 | import MediaService from 'types/MediaService'; 3 | import Pager from 'types/Pager'; 4 | import useSource from 'hooks/useSource'; 5 | 6 | export default function useEditablePlaylistsPager( 7 | service: MediaService | null 8 | ): Pager | null { 9 | return useSource(service?.editablePlaylists || null); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/App/AppDragRegion.scss: -------------------------------------------------------------------------------- 1 | .app-drag-region { 2 | position: fixed; 3 | top: 0; 4 | width: 100%; 5 | height: 1.5rem; 6 | -webkit-app-region: drag; 7 | user-select: none; 8 | display: none; 9 | 10 | @mixin chromeless { 11 | display: block; 12 | } 13 | 14 | @media (display-mode: window-controls-overlay) { 15 | @include chromeless; 16 | } 17 | 18 | .electron & { 19 | @include chromeless; 20 | height: env(titlebar-area-height, 1.5rem); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/App/AppDragRegion.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {preventDefault} from 'utils'; 3 | import './AppDragRegion.scss'; 4 | 5 | export default function AppDragRegion() { 6 | return
; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/App/AppTitle.scss: -------------------------------------------------------------------------------- 1 | .app-title { 2 | font-family: Arial, sans-serif; /* independent of theme */ 3 | 4 | .app-icon { 5 | margin-right: 0.25em; 6 | } 7 | 8 | .app-version { 9 | margin-left: 0.25em; 10 | padding: 0.125em 0.3125em; 11 | font-size: max(0.5625em, 8px); 12 | vertical-align: super; 13 | background-color: var(--frame2-color); 14 | border-radius: var(--app-border-radius); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/App/AppTitle.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import Icon from 'components/Icon'; 3 | import './AppTitle.scss'; 4 | 5 | export default memo(function AppTitle() { 6 | return ( 7 |

8 | 9 | 10 | {__app_name__} 11 | {' '} 12 | {__app_version__} 13 |

14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/App/DesktopWarning.scss: -------------------------------------------------------------------------------- 1 | .desktop-warning { 2 | position: absolute; 3 | inset: 0; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | text-align: center; 9 | padding: 2rem; 10 | background-color: var(--background-color); 11 | color: var(--text-color); 12 | font-size: 1.5rem; 13 | 14 | .app-title { 15 | margin-bottom: 4rem; 16 | font-size: 1rem; 17 | } 18 | 19 | p + p { 20 | margin-top: 5rem; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/App/DesktopWarning.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppTitle from './AppTitle'; 3 | import './DesktopWarning.scss'; 4 | 5 | export interface DesktopWarningProps { 6 | onDismiss: () => void; 7 | } 8 | 9 | export default function DesktopWarning({onDismiss}: DesktopWarningProps) { 10 | return ( 11 |
12 | 13 |

This application is intended for a desktop browser.

14 |

15 | 16 |

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/App/PortUnavailable.scss: -------------------------------------------------------------------------------- 1 | .port-unavailable { 2 | font-size: min(3vh, 24px); 3 | padding: 2em; 4 | 5 | .app-title { 6 | margin: 0; 7 | } 8 | 9 | h2 { 10 | margin: 1em 0; 11 | font-weight: bold; 12 | font-size: 1.125em; 13 | } 14 | 15 | p + p { 16 | margin-top: 1em; 17 | } 18 | 19 | ul { 20 | margin-top: 0.5em; 21 | 22 | & + p { 23 | margin-top: 2em; 24 | } 25 | } 26 | 27 | li { 28 | line-height: 1.8; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/App/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './App'; 2 | export * from './App'; 3 | -------------------------------------------------------------------------------- /src/components/App/useAppSettings.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {observePreferences} from 'services/preferences'; 3 | 4 | export default function useAppSettings(): void { 5 | useEffect(() => { 6 | const subscription = observePreferences().subscribe( 7 | ({disableExplicitContent, markExplicitContent}) => { 8 | const {classList} = document.body; 9 | classList.toggle('disable-explicit-content', disableExplicitContent); 10 | classList.toggle('mark-explicit-content', markExplicitContent); 11 | } 12 | ); 13 | return () => subscription.unsubscribe(); 14 | }, []); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/App/useBrowser.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {browser} from 'utils'; 3 | 4 | export default function useBrowser(): void { 5 | useEffect(() => { 6 | const classList = document.body.classList; 7 | classList.add(`browser-${browser.name.replace(/\s+/g, '-')}`); 8 | if (browser.isElectron) { 9 | classList.add('electron'); 10 | if (browser.os === 'Mac OS') { 11 | classList.add('electron-mac'); 12 | } 13 | } 14 | }, []); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/App/usePreload.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {isMiniPlayer} from 'utils'; 3 | import {loadMediaServices} from 'services/mediaServices'; 4 | import {loadVisualizers} from 'services/visualizer/visualizerProviders'; 5 | 6 | export default function usePreload(): void { 7 | useEffect(() => { 8 | loadMediaServices(); 9 | if (isMiniPlayer) { 10 | loadVisualizers(); 11 | } 12 | }, []); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Badges/Badges.scss: -------------------------------------------------------------------------------- 1 | .badges { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | font-size: max(0.5em, 9px); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Badges/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Badges'; 2 | export * from './Badges'; 3 | export * from './Badge'; 4 | -------------------------------------------------------------------------------- /src/components/Button/CopyButton.scss: -------------------------------------------------------------------------------- 1 | .copy-button { 2 | display: inline-flex; 3 | align-items: center; 4 | text-align: left; 5 | font-size: max(0.75em, 10px); 6 | padding: 0.25em 1em; 7 | min-width: 7em; 8 | cursor: pointer; 9 | 10 | .icon { 11 | margin-right: 0.5em; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Button/IconButton.scss: -------------------------------------------------------------------------------- 1 | .icon-button { 2 | all: unset; 3 | display: inline-flex; 4 | flex-direction: column; 5 | align-items: center; 6 | justify-content: center; 7 | text-align: center; 8 | width: 1em; 9 | height: 1em; 10 | opacity: 0.75; 11 | border-radius: 50%; 12 | 13 | &:disabled { 14 | color: var(--grey1-color); 15 | } 16 | 17 | &:enabled { 18 | cursor: pointer; 19 | 20 | .focus-visible &:focus, 21 | &:hover { 22 | opacity: 1; 23 | } 24 | } 25 | 26 | &:active { 27 | transform: none; 28 | } 29 | 30 | .icon { 31 | width: inherit; 32 | height: inherit; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Button/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Except} from 'type-fest'; 3 | import Icon, {IconName} from 'components/Icon'; 4 | import {cancelEvent, stopPropagation} from 'utils'; 5 | import './IconButton.scss'; 6 | 7 | export interface IconButtonProps 8 | extends Except, 'children'> { 9 | icon: IconName; 10 | } 11 | 12 | export default function IconButton({icon, className = '', ...props}: IconButtonProps) { 13 | return ( 14 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Button/IconButtons.scss: -------------------------------------------------------------------------------- 1 | .icon-buttons { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | padding: 1px; 6 | 7 | .icon-button { 8 | & ~ .icon-button { 9 | margin-left: 0.25em; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Button/IconButtons.tsx: -------------------------------------------------------------------------------- 1 | import React, {HTMLAttributes} from 'react'; 2 | import {cancelEvent} from 'utils'; 3 | import './IconButtons.scss'; 4 | 5 | export type IconButtonsProps = HTMLAttributes; 6 | 7 | export default function IconButtons({className = '', children, ...props}: IconButtonsProps) { 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './IconButton'; 2 | export * from './IconButton'; 3 | -------------------------------------------------------------------------------- /src/components/CoverArt/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './CoverArt'; 2 | export * from './CoverArt'; 3 | -------------------------------------------------------------------------------- /src/components/CoverArtVisualizer/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './CoverArtVisualizer'; 2 | export * from './CoverArtVisualizer'; 3 | -------------------------------------------------------------------------------- /src/components/DatePicker/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './DatePicker'; 2 | export * from './DatePicker'; 3 | -------------------------------------------------------------------------------- /src/components/Dialog/DialogButtons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface DialogButtonsProps { 4 | value?: string; 5 | disabled?: boolean; 6 | submitText?: React.ReactNode; 7 | } 8 | 9 | export default function DialogButtons({ 10 | value = '', 11 | disabled, 12 | submitText = 'Confirm', 13 | }: DialogButtonsProps) { 14 | return ( 15 |
16 | 19 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Dialog/alert.scss: -------------------------------------------------------------------------------- 1 | .alert-dialog { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Dialog/confirm.scss: -------------------------------------------------------------------------------- 1 | .confirm-dialog { 2 | text-align: center; 3 | 4 | &-storage { 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | justify-content: center; 9 | margin-top: 2em; 10 | font-size: 0.75em; 11 | font-style: italic; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Dialog/error.tsx: -------------------------------------------------------------------------------- 1 | import alert, {AlertOptions} from './alert'; 2 | 3 | export default function error(err: Error): Promise; 4 | export default function error(err: string): Promise; 5 | export default function error(err: AlertOptions & {system?: boolean}): Promise; 6 | export default async function error( 7 | err: Error | string | (AlertOptions & {system?: boolean}) 8 | ): Promise { 9 | if (typeof err === 'string') { 10 | err = Error(err); 11 | } 12 | const {title = 'Error', message = 'Unknown error', system = true} = err as any; 13 | await alert({ 14 | icon: 'error', 15 | title, 16 | message, 17 | system, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Dialog/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Dialog'; 2 | export * from './Dialog'; 3 | export {default as alert} from './alert'; 4 | export {default as confirm} from './confirm'; 5 | export {default as error} from './error'; 6 | export {default as prompt} from './prompt'; 7 | export {default as showDialog} from './showDialog'; 8 | -------------------------------------------------------------------------------- /src/components/Dialog/prompt.scss: -------------------------------------------------------------------------------- 1 | .prompt-dialog { 2 | text-align: center; 3 | 4 | input[type="url"] { 5 | width: 24em; 6 | } 7 | 8 | textarea { 9 | min-width: 20em; 10 | min-height: 4em; 11 | width: 32em; 12 | height: 12em; 13 | margin-top: 0.5em; 14 | margin-left: 0; 15 | font-family: monospace; 16 | font-size: 0.875em; 17 | } 18 | 19 | label:has(+ textarea) { 20 | display: block; 21 | text-align: left; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Errors/ErrorBox.scss: -------------------------------------------------------------------------------- 1 | @use 'styles/effects.scss'; 2 | 3 | .error-box { 4 | @include effects.inset; 5 | flex: auto; 6 | padding: 1em; 7 | border-radius: var(--app-border-radius); 8 | overflow: hidden; 9 | 10 | h2 { 11 | font-size: 1.5em; 12 | font-weight: bold; 13 | margin-bottom: 0.75em; 14 | } 15 | 16 | .buttons { 17 | margin-top: 1em; 18 | text-align: left; 19 | } 20 | 21 | button.disconnect { 22 | white-space: normal; 23 | } 24 | } -------------------------------------------------------------------------------- /src/components/Errors/ErrorReport.scss: -------------------------------------------------------------------------------- 1 | .error-report { 2 | font-size: max(0.75em, 1rem); 3 | 4 | pre { 5 | white-space: pre-wrap; 6 | 7 | & + p { 8 | text-align: right; 9 | margin-top: 1em; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Errors/HandledError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {NoInternetError} from 'services/errors'; 3 | import MediaServiceLabel from 'components/MediaSources/MediaServiceLabel'; 4 | import {ErrorBoxProps} from './ErrorBox'; 5 | 6 | type HandledErrorProps = ErrorBoxProps & { 7 | error: Error; 8 | }; 9 | 10 | export default function HandledError({error, service, children}: HandledErrorProps) { 11 | return ( 12 |
13 | {service && error instanceof NoInternetError ? ( 14 |

15 | 16 |

17 | ) : null} 18 |
19 |
{error?.message || String(error)}
20 |
21 | {children} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Errors/UnhandledError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {ErrorBoxProps} from './ErrorBox'; 3 | import ErrorReport from './ErrorReport'; 4 | 5 | export default function UnhandledError({error, children, reportedBy='MediaBrowser', reportingId, service}: ErrorBoxProps) { 6 | return ( 7 |
8 |

Error

9 | 10 | {service && ( 11 |

12 | 15 |

16 | )} 17 | {children} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ExternalLink/ExternalLink.scss: -------------------------------------------------------------------------------- 1 | @use 'styles/effects.scss'; 2 | 3 | .external-link { 4 | white-space: nowrap; 5 | text-decoration: none; 6 | 7 | &:hover &-text, 8 | &:active &-text { 9 | text-decoration: underline; 10 | } 11 | 12 | &-content { 13 | display: inline-flex; 14 | flex-direction: row; 15 | align-items: center; 16 | justify-content: center; 17 | } 18 | 19 | &-text { 20 | border-radius: calc(var(--roundness) * 0.5em); 21 | outline-offset: 0.125em; 22 | 23 | .icon + & { 24 | margin-left: 0.25em; 25 | } 26 | } 27 | 28 | .focus-visible &:focus &-text { 29 | @include effects.outline; 30 | outline-width: 2px; 31 | } 32 | 33 | > .icon-link { 34 | position: relative; 35 | left: 0.5em; 36 | top: -0.75em; 37 | font-size: 0.625em; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ExternalLink/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './ExternalLink'; 2 | export * from './ExternalLink'; 3 | -------------------------------------------------------------------------------- /src/components/Icon/Flag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface FlagProps { 4 | country: string; 5 | } 6 | 7 | export default function Flag({country}: FlagProps) { 8 | return ( 9 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Icon/SvgDefs.scss: -------------------------------------------------------------------------------- 1 | .svg-defs { 2 | position: absolute; 3 | visibility: hidden; 4 | } -------------------------------------------------------------------------------- /src/components/Icon/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Icon'; 2 | export * from './Icon'; 3 | -------------------------------------------------------------------------------- /src/components/ListView/DetailsBox.scss: -------------------------------------------------------------------------------- 1 | .details-box { 2 | width: 100%; 3 | height: 10em; 4 | margin: 0.5em 0; 5 | 6 | .list-view-cell.value { 7 | font-family: monospace; 8 | } 9 | 10 | .scrollable { 11 | --scrollbar-size: max(10px, calc(1.25em * var(--scrollbar-thickness))); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/ListView/ListBox.scss: -------------------------------------------------------------------------------- 1 | .list-box { 2 | width: 100%; 3 | height: calc(11em - 2px); 4 | margin: 0.5em 0; 5 | 6 | .scrollable { 7 | --scrollbar-size: max(10px, calc(1.25em * var(--scrollbar-thickness))); 8 | } 9 | 10 | .list-view-cell { 11 | padding: 0 0.25em; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/ListView/ListBox.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | import {Except} from 'type-fest'; 3 | import ListView, {ListViewLayout, ListViewProps} from './ListView'; 4 | import './ListBox.scss'; 5 | 6 | export interface ListBoxProps extends Except, 'layout'> { 7 | renderItem?: (item: T, rowIndex: number) => React.ReactNode; 8 | } 9 | 10 | export default function ListBox({ 11 | renderItem: render = String, 12 | className = '', 13 | ...props 14 | }: ListBoxProps) { 15 | const layout: ListViewLayout = useMemo(() => { 16 | return {view: 'details', cols: [{render}]}; 17 | }, [render]); 18 | 19 | return {...props} className={`list-box ${className}`} layout={layout} />; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ListView/ListViewBodyCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Column} from './ListView'; 3 | 4 | export interface ListViewBodyCellProps { 5 | rowIndex: number; 6 | col: Column; 7 | item: T; 8 | } 9 | 10 | export default function ListViewBodyCell({ 11 | rowIndex, 12 | col: {className = '', render, style}, 13 | item, 14 | }: ListViewBodyCellProps) { 15 | return ( 16 |
17 | {render(item, rowIndex)} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ListView/ListViewHeadCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Column} from './ListView'; 3 | 4 | export default function ListViewHeadCell({style, title}: Column) { 5 | return ( 6 |
7 | {title} 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ListView/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './ListView'; 2 | export * from './ListView'; 3 | -------------------------------------------------------------------------------- /src/components/Login/ConnectionLogging.scss: -------------------------------------------------------------------------------- 1 | .connection-logging { 2 | font-family: monospace; 3 | font-size: 0.75em; 4 | margin-top: 2em; 5 | 6 | p { 7 | margin: 0.25em 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Login/CredentialsButton.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback} from 'react'; 2 | import MediaService from 'types/MediaService'; 3 | import {showCredentialsDialog} from 'components/Settings/MediaLibrarySettings/CredentialsDialog'; 4 | 5 | export interface CredentialsButtonProps extends React.ButtonHTMLAttributes { 6 | service: MediaService; 7 | } 8 | 9 | export default function CredentialsButton({ 10 | service, 11 | className = 'credentials-button', 12 | children = 'Enter credentials…', 13 | ...props 14 | }: CredentialsButtonProps) { 15 | const handleClick = useCallback(() => { 16 | showCredentialsDialog(service); 17 | }, [service]); 18 | 19 | return ( 20 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Login/CredentialsRequired.scss: -------------------------------------------------------------------------------- 1 | .credentials-required { 2 | font-size: 0.75em; 3 | padding: 0.5em; 4 | 5 | p { 6 | margin: 0.5em 0; 7 | } 8 | 9 | &-link { 10 | margin-top: 1em; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | 14 | .external-link-content { 15 | display: inline; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Login/CredentialsRequired.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ExternalLink from 'components/ExternalLink'; 3 | import {LoginProps} from './Login'; 4 | import './CredentialsRequired.scss'; 5 | 6 | export default function CredentialsRequired({service}: LoginProps) { 7 | const credentialsUrl = service.credentialsUrl; 8 | 9 | return ( 10 |
11 |

Please register a client application to continue.

12 | {credentialsUrl ? ( 13 |

14 | 15 |

16 | ) : null} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Login/DefaultLogin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ConnectionLogging from './ConnectionLogging'; 3 | import HTTPDownloadLink from './HTTPDownloadLink'; 4 | import LoginButton from './LoginButton'; 5 | import ServiceLink from './ServiceLink'; 6 | import LoginRequired from './LoginRequired'; 7 | import {LoginProps} from './Login'; 8 | 9 | export default function DefaultLogin({service}: LoginProps) { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Login/HTTPDownloadLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ServiceType from 'types/ServiceType'; 3 | import {dockerUrl, downloadUrl} from 'services/constants'; 4 | import ExternalLink from 'components/ExternalLink'; 5 | import {LoginProps} from './Login'; 6 | 7 | export default function HTTPDownloadLink({service}: LoginProps) { 8 | const showDownloadLink = 9 | location.protocol === 'https:' && 10 | service.serviceType === ServiceType.PersonalMedia && service.id !== 'plex'; 11 | 12 | return showDownloadLink ? ( 13 | <> 14 |

15 | Download the desktop app if you are unable 16 | to login via HTTPS 17 |
18 | or use the docker image. 19 |

20 | 21 | ) : null; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaService from 'types/MediaService'; 3 | import DefaultLogin from './DefaultLogin'; 4 | import './Login.scss'; 5 | 6 | export interface LoginProps { 7 | service: MediaService; 8 | } 9 | 10 | export default function Login({service}: LoginProps) { 11 | const Login = service.Components?.Login || DefaultLogin; 12 | return ( 13 |
14 |
15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Login/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaService from 'types/MediaService'; 3 | 4 | export interface LoginButtonProps extends React.ButtonHTMLAttributes { 5 | service: MediaService; 6 | } 7 | 8 | export default function LoginButton({ 9 | service, 10 | children = `Connect to ${service.name}…`, 11 | ...props 12 | }: LoginButtonProps) { 13 | return ( 14 |

15 | 18 |

19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Login/LoginDialog.scss: -------------------------------------------------------------------------------- 1 | .login-dialog { 2 | input[type=url] { 3 | min-width: 20em; 4 | } 5 | 6 | .message { 7 | text-align: left; 8 | font-size: 0.75em; 9 | min-height: 1.5em; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Login/LoginRequired.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ServiceType from 'types/ServiceType'; 3 | import {LoginProps} from './Login'; 4 | 5 | export default function LoginRequired({service}: LoginProps) { 6 | return ( 7 |

8 | You need to be logged in to{' '} 9 | {service.serviceType === ServiceType.DataService ? 'access your data' : 'play music'} from{' '} 10 | {service.name}. 11 |

12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Login/RestrictedAccessWarning.scss: -------------------------------------------------------------------------------- 1 | .restricted-access-warning { 2 | font-size: 0.75em; 3 | padding: 0.5em; 4 | 5 | p { 6 | margin: 0.5em 0; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Login/RestrictedAccessWarning.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {LoginProps} from './Login'; 3 | import './RestrictedAccessWarning.scss'; 4 | 5 | export default function RestrictedAccessWarning({service}: LoginProps) { 6 | return service.restrictedAccess ? ( 7 |
8 |

9 | This application is in development mode. 10 |

11 |

You currently need to be an approved user to access services from {service.name}.

12 |
13 | ) : null; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Login/ServiceLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ExternalLink from 'components/ExternalLink'; 3 | import Icon from 'components/Icon'; 4 | import {LoginProps} from './Login'; 5 | 6 | export default function ServiceLink({service}: LoginProps) { 7 | return ( 8 |

9 | 10 | 11 |

12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Login/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Login'; 2 | export * from './Login'; 3 | -------------------------------------------------------------------------------- /src/components/Media/PlaybackState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useCurrentTime from 'hooks/useCurrentTime'; 3 | import usePaused from 'hooks/usePaused'; 4 | import useLoadingState from './useLoadingState'; 5 | 6 | export default function PlaybackState() { 7 | const loadingState = useLoadingState(); 8 | const paused = usePaused(); 9 | const currentTime = useCurrentTime(); 10 | const started = currentTime > 0; 11 | 12 | return ( 13 |

14 | {loadingState === 'error' 15 | ? 'error' 16 | : paused 17 | ? started 18 | ? 'paused' 19 | : '' 20 | : loadingState === 'loaded' 21 | ? 'playing' 22 | : loadingState} 23 |

24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Media/ProgressBar.scss: -------------------------------------------------------------------------------- 1 | .progress-bar { 2 | position: absolute; 3 | bottom: 0; 4 | width: 100%; 5 | height: var(--progress-bar-height); 6 | background-color: var(--progress-bar-background-color); 7 | border: 0; 8 | border-radius: 0; 9 | display: none; 10 | transition: opacity 1s linear; 11 | 12 | &[value^="0"] { 13 | opacity: 0; 14 | } 15 | 16 | .media.fullscreen & { 17 | display: block; 18 | } 19 | 20 | &::-webkit-progress-bar { 21 | background-color: var(--progress-bar-background-color); 22 | border-radius: 0; 23 | } 24 | 25 | &::-webkit-progress-value { 26 | background-color: var(--progress-bar-color); 27 | border-radius: 0; 28 | } 29 | 30 | &::-moz-progress-bar { 31 | background-color: var(--progress-bar-color); 32 | border-radius: 0; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Media/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useCurrentTime from 'hooks/useCurrentTime'; 3 | import useDuration from 'hooks/useDuration'; 4 | import {MAX_DURATION} from 'services/constants'; 5 | import './ProgressBar.scss'; 6 | 7 | export default function ProgressBar() { 8 | const duration = useDuration(); 9 | const currentTime = useCurrentTime(); 10 | return ( 11 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Media/Static.scss: -------------------------------------------------------------------------------- 1 | .static { 2 | /* https://css-tricks.com/making-static-noise-from-a-weird-css-gradient-bug/ */ 3 | position: absolute; 4 | inset: 0; 5 | background: 6 | repeating-radial-gradient(#000 0 0.0001%,#fff 0 0.0002%) 50% 0/2500px 2500px, 7 | repeating-conic-gradient(#000 0 0.0001%,#fff 0 0.0002%) 60% 60%/2500px 2500px; 8 | background-blend-mode: difference; 9 | animation: tv-static .2s infinite alternate; 10 | } 11 | 12 | @keyframes tv-static { 13 | 100% { 14 | background-position: 50% 0, 60% 50% 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Media/Static.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Static.scss'; 3 | 4 | export default function Static() { 5 | return
; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Media/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Media'; 2 | export * from './Media'; 3 | -------------------------------------------------------------------------------- /src/components/MediaBrowser/ErrorScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ErrorBox, {ErrorBoxProps} from 'components/Errors/ErrorBox'; 3 | 4 | export default function ErrorScreen(props: ErrorBoxProps) { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/MediaBrowser/FilterSelect.scss: -------------------------------------------------------------------------------- 1 | .filter-select { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | margin-bottom: var(--gutter-width); 6 | 7 | label { 8 | flex: initial; 9 | margin-left: 0.25em; 10 | } 11 | 12 | select { 13 | flex: auto; 14 | min-width: 0; 15 | margin-right: 1px; 16 | cursor: pointer; 17 | } 18 | 19 | & + .error-box { 20 | margin-bottom: var(--gutter-width); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/MediaBrowser/MediaItems.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaItem from 'types/MediaItem'; 3 | import MediaItemList from 'components/MediaList/MediaItemList'; 4 | import {PagedItemsProps} from './PagedItems'; 5 | 6 | export default function MediaItems({source, ...props}: PagedItemsProps) { 7 | return ( 8 |
9 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/MediaBrowser/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MediaBrowser'; 2 | export * from './MediaBrowser'; 3 | -------------------------------------------------------------------------------- /src/components/MediaBrowser/useErrorScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | import {FallbackProps} from 'react-error-boundary'; 3 | import MediaService from 'types/MediaService'; 4 | import {AnyMediaSource} from 'types/MediaSource'; 5 | import ErrorScreen from './ErrorScreen'; 6 | 7 | export default function useErrorScreen( 8 | service: MediaService, 9 | source: AnyMediaSource 10 | ) { 11 | return useMemo(() => { 12 | return function MediaBrowserError({error}: FallbackProps) { 13 | return ; 14 | }; 15 | }, [service, source]); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/MediaBrowser/useHistoryPager.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import MediaItem from 'types/MediaItem'; 3 | import MediaSource from 'types/MediaSource'; 4 | import useSource from 'hooks/useSource'; 5 | 6 | export default function useHistoryPager(source: MediaSource | null, startAt = 0) { 7 | const params = useMemo(() => ({startAt}), [startAt]); 8 | const pager = useSource(source, params); 9 | return pager; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/MediaControls/MediaButton.scss: -------------------------------------------------------------------------------- 1 | .media-button { 2 | background-color: var(--media-button-color); 3 | background-image: var(--media-button-background); 4 | color: var(--media-button-text-color); 5 | border: 0; 6 | padding: 0.5rem; 7 | width: 2rem; 8 | height: 2rem; 9 | font-size: 0.75rem; 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | justify-content: center; 14 | box-shadow: 0 0 1px 1px var(--black); 15 | border-radius: calc(var(--roundness) * 100%); 16 | 17 | &:first-child { 18 | margin-left: auto; 19 | } 20 | 21 | &:last-child { 22 | margin-right: auto; 23 | } 24 | 25 | &:enabled:active, 26 | &:enabled.active { 27 | transform: scale(0.98); 28 | } 29 | 30 | &-menu { 31 | width: 1.25rem; 32 | height: 1.25rem; 33 | padding: 0; 34 | cursor: pointer; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/MediaControls/VolumeControl.scss: -------------------------------------------------------------------------------- 1 | .volume-control { 2 | display: flex; 3 | flex: auto; 4 | min-width: 4.25rem; 5 | max-width: 6.25rem; 6 | margin-right: 0.25em; 7 | 8 | .icon-button { 9 | flex: initial; 10 | width: 1.25em; 11 | height: 1.25em; 12 | } 13 | 14 | input[type=range] { 15 | --track-height: 0.15em; 16 | --thumb-size: .875em; 17 | min-width: 3rem; 18 | max-width: 5rem; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/MediaControls/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MediaControls'; 2 | export * from './MediaControls'; 3 | -------------------------------------------------------------------------------- /src/components/MediaInfo/CurrentlyPlaying.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaType from 'types/MediaType'; 3 | import PlaylistItem from 'types/PlaylistItem'; 4 | import Visualizer from 'types/Visualizer'; 5 | import MediaInfo from './MediaInfo'; 6 | import VisualizerInfo from './VisualizerInfo'; 7 | 8 | export interface CurrentlyPlayingProps { 9 | item: PlaylistItem | null; 10 | visualizer: Visualizer | null; 11 | } 12 | 13 | export default function CurrentlyPlaying({item, visualizer}: CurrentlyPlayingProps) { 14 | return ( 15 |
16 | {item ? :

No media loaded.

} 17 | {item && item.mediaType !== MediaType.Video ? ( 18 | 19 | ) : null} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/MediaInfo/CurrentlyPlayingTabs.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | import TabList, {TabItem} from 'components/TabList'; 3 | import MediaDetails from './MediaDetails'; 4 | import CurrentlyPlaying, {CurrentlyPlayingProps} from './CurrentlyPlaying'; 5 | 6 | export default function CurrentlyPlayingTabs({item, visualizer}: CurrentlyPlayingProps) { 7 | const tabs: TabItem[] = useMemo(() => { 8 | const tabs = [ 9 | { 10 | tab: 'Now playing', 11 | panel: , 12 | }, 13 | ]; 14 | if (item) { 15 | tabs.push({ 16 | tab: 'Details', 17 | panel: , 18 | }); 19 | } 20 | return tabs; 21 | }, [item, visualizer]); 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/MediaInfo/MediaDetails.scss: -------------------------------------------------------------------------------- 1 | .media-details { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | font-size: max(0.75em, 10px); 6 | 7 | .details-box { 8 | height: 100%; 9 | margin: 0; 10 | 11 | & + p { 12 | margin-top: 1em; 13 | margin-bottom: 0; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/MediaInfo/MediaInfoDialog.scss: -------------------------------------------------------------------------------- 1 | .media-info-dialog { 2 | font-size: min(max(1.5vw, 12px), 1.125em); 3 | width: 40em; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/MediaInfo/MediaInfoTabs.scss: -------------------------------------------------------------------------------- 1 | .media-info-tabs { 2 | .tab-list-tabs { 3 | font-size: 0.875em; 4 | } 5 | 6 | .tab-panel { 7 | position: absolute; 8 | inset: 0; 9 | 10 | &:first-child { 11 | position: static; 12 | 13 | &[hidden] { 14 | display: block; 15 | visibility: hidden; 16 | } 17 | } 18 | } 19 | 20 | & + .dialog-buttons { 21 | margin-top: 1.5em; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/MediaInfo/MediaInfoTabs.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | import MediaObject from 'types/MediaObject'; 3 | import TabList, {TabItem} from 'components/TabList'; 4 | import MediaInfo, {MediaInfoProps} from './MediaInfo'; 5 | import MediaDetails from './MediaDetails'; 6 | import './MediaInfoTabs.scss'; 7 | 8 | export default function MediaInfoTabs({item}: MediaInfoProps) { 9 | const tabs: TabItem[] = useMemo( 10 | () => [ 11 | { 12 | tab: 'General', 13 | panel: , 14 | }, 15 | { 16 | tab: 'Details', 17 | panel: , 18 | }, 19 | ], 20 | [item] 21 | ); 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/MediaInfo/useMediaInfoDialog.ts: -------------------------------------------------------------------------------- 1 | import {RefObject, useEffect} from 'react'; 2 | import {fromEvent} from 'rxjs'; 3 | import {browser} from 'utils'; 4 | 5 | export default function useMediaInfoDialog(dialogRef: RefObject) { 6 | useEffect(() => { 7 | const subscription = fromEvent(document, 'keydown', { 8 | capture: true, 9 | }).subscribe((event) => { 10 | if ( 11 | event[browser.cmdKey] && 12 | !event.shiftKey && 13 | !event.altKey && 14 | event.code === 'KeyI' && 15 | !event.repeat 16 | ) { 17 | event.preventDefault(); 18 | dialogRef.current?.close(); 19 | } 20 | }); 21 | return () => subscription.unsubscribe(); 22 | }, [dialogRef]); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/MediaLibrary/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MediaLibrary'; 2 | export * from './MediaLibrary'; 3 | -------------------------------------------------------------------------------- /src/components/MediaList/AlbumList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaAlbum from 'types/MediaAlbum'; 3 | import MediaSourceLayout from 'types/MediaSourceLayout'; 4 | import MediaList, {MediaListProps} from './MediaList'; 5 | 6 | const defaultLayout: MediaSourceLayout = { 7 | view: 'card compact', 8 | fields: ['Thumbnail', 'Title', 'Artist', 'Year'], 9 | }; 10 | 11 | export default function AlbumList({ 12 | className = '', 13 | layout = defaultLayout, 14 | draggable = true, 15 | ...props 16 | }: MediaListProps) { 17 | return ( 18 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/MediaList/ArtistList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaArtist from 'types/MediaArtist'; 3 | import MediaSourceLayout from 'types/MediaSourceLayout'; 4 | import MediaList, {MediaListProps} from './MediaList'; 5 | 6 | const defaultLayout: MediaSourceLayout = { 7 | view: 'card compact', 8 | fields: ['Thumbnail', 'Title', 'Genre'], 9 | }; 10 | 11 | export default function ArtistList({ 12 | className = '', 13 | layout = defaultLayout, 14 | ...props 15 | }: MediaListProps) { 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/MediaList/MediaItemList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaItem from 'types/MediaItem'; 3 | import MediaSourceLayout from 'types/MediaSourceLayout'; 4 | import MediaList, {MediaListProps} from './MediaList'; 5 | 6 | const defaultLayout: MediaSourceLayout = { 7 | view: 'details', 8 | fields: ['Artist', 'Title', 'Album', 'Track', 'Duration', 'Genre', 'PlayCount'], 9 | }; 10 | 11 | export default function MediaItemList({ 12 | className = '', 13 | layout = defaultLayout, 14 | multiple = true, 15 | draggable = true, 16 | ...props 17 | }: MediaListProps) { 18 | return ( 19 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/MediaList/MediaListStatusBar.scss: -------------------------------------------------------------------------------- 1 | .media-list-status-bar { 2 | line-height: normal; 3 | 4 | p { 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | } 9 | 10 | .progress-ring { 11 | margin-right: 0.25em; 12 | } 13 | 14 | .message.error { 15 | color: inherit; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/MediaList/PlaylistList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaPlaylist from 'types/MediaPlaylist'; 3 | import MediaSourceLayout from 'types/MediaSourceLayout'; 4 | import MediaList, {MediaListProps} from './MediaList'; 5 | 6 | const defaultLayout: MediaSourceLayout = { 7 | view: 'card compact', 8 | fields: ['Thumbnail', 'Title', 'TrackCount', 'Owner', 'Progress'], 9 | }; 10 | 11 | export default function PlaylistList({ 12 | className = '', 13 | layout = defaultLayout, 14 | ...props 15 | }: MediaListProps) { 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/MediaList/ProgressRing.scss: -------------------------------------------------------------------------------- 1 | .progress-ring { 2 | &.busy { 3 | animation: progress-spin 1s linear infinite; 4 | } 5 | } 6 | 7 | @keyframes progress-spin { 8 | 0% { 9 | transform: rotate(0deg); 10 | } 11 | 12 | 100% { 13 | transform: rotate(1turn); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/MediaList/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MediaList'; 2 | export * from './MediaList'; 3 | -------------------------------------------------------------------------------- /src/components/MediaList/useIsPlaylistPlayable.ts: -------------------------------------------------------------------------------- 1 | import MediaPlaylist from 'types/MediaPlaylist'; 2 | import usePager from 'hooks/usePager'; 3 | 4 | export default function useIsPlaylistPlayable(playlist?: MediaPlaylist): boolean { 5 | const pager = playlist?.pager || null; 6 | const [{items}] = usePager(pager); 7 | if (pager) { 8 | const pageSize = pager.pageSize; 9 | const playlistSize = playlist!.trackCount ?? pager.maxSize; 10 | const itemCount = items.reduce((total) => (total += 1), 0); 11 | return playlistSize == null ? false : itemCount + 2 * pageSize >= playlistSize; 12 | } else { 13 | return false; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/MediaList/useOnDragStart.ts: -------------------------------------------------------------------------------- 1 | import {useCallback} from 'react'; 2 | import MediaObject from 'types/MediaObject'; 3 | 4 | export default function useOnDragStart(selectedItems: readonly T[]) { 5 | return useCallback( 6 | (event: React.DragEvent) => { 7 | const spotifyTracks = selectedItems.filter((item) => item.src.startsWith('spotify:track:')); 8 | if (spotifyTracks.length > 0) { 9 | // Allow dragging of Spotify tracks to external Spotify apps. 10 | const trackUris = spotifyTracks.map((item) => item.src); 11 | event.dataTransfer.setData('text/x-spotify-tracks', trackUris.join('\n')); 12 | event.dataTransfer.effectAllowed = 'copyMove'; 13 | } 14 | }, 15 | [selectedItems] 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/MediaPlayback/MediaPlayback.scss: -------------------------------------------------------------------------------- 1 | .media-playback { 2 | .panel.playback { 3 | bottom: 0; 4 | 5 | @media (display-mode: window-controls-overlay) { 6 | top: max(1.5rem, 32px); 7 | } 8 | 9 | .electron & { 10 | top: env(titlebar-area-height, 1.5rem); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/MediaPlayback/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MediaPlayback'; 2 | export * from './MediaPlayback'; 3 | -------------------------------------------------------------------------------- /src/components/MediaSources/MediaSourceLabel.scss: -------------------------------------------------------------------------------- 1 | .media-source-label { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | 6 | .icon { 7 | min-width: 1em; 8 | margin-right: 0.25em; 9 | 10 | &-folder, 11 | &-folder-up { 12 | margin-top: -0.125em; 13 | } 14 | 15 | &-pin { 16 | transition: transform 0.5s; 17 | } 18 | } 19 | 20 | &.unpinned { 21 | .icon-pin { 22 | transform: rotate(0.5turn); 23 | 24 | & + .text { 25 | text-decoration: line-through; 26 | } 27 | } 28 | } 29 | 30 | .text { 31 | display: inline-block; 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/MediaSources/ProvidedBy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaItem from 'types/MediaItem'; 3 | import {isPublicMediaService} from 'services/mediaServices'; 4 | import MediaSourceLabel from './MediaSourceLabel'; 5 | import useMediaServices from 'hooks/useMediaServices'; 6 | 7 | export default function ProvidedBy({item}: {item: MediaItem | null}) { 8 | const services = useMediaServices(); 9 | const [serviceId] = item?.src.split(':') || []; 10 | const service = services.find((service) => service.id === serviceId); 11 | 12 | if (item && service && isPublicMediaService(service)) { 13 | return ( 14 | 19 | ); 20 | } else { 21 | return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/MediaSources/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MediaSources'; 2 | export * from './MediaSources'; 3 | -------------------------------------------------------------------------------- /src/components/MiniPlayer/MiniPlayer.scss: -------------------------------------------------------------------------------- 1 | .mini-player { 2 | position: absolute; 3 | inset: 0; 4 | overflow: hidden; 5 | 6 | .app-title { 7 | position: absolute; 8 | left: var(--gutter-width); 9 | top: calc(var(--gutter-width) / 2); 10 | } 11 | 12 | .app-drag-region { 13 | height: 2rem; 14 | } 15 | 16 | .media { 17 | top: calc(var(--gutter-width) / 2 + 2rem); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/MiniPlayer/MiniPlayer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppDragRegion from 'components/App/AppDragRegion'; 3 | import AppTitle from 'components/App/AppTitle'; 4 | import Media from 'components/Media'; 5 | import './MiniPlayer.scss'; 6 | 7 | export default function MiniPlayer() { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/MiniPlayer/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './MiniPlayer'; 2 | export * from './MiniPlayer'; 3 | -------------------------------------------------------------------------------- /src/components/Playlist/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Playlist'; 2 | export * from './Playlist'; 3 | -------------------------------------------------------------------------------- /src/components/PopupMenu/PopupMenuItemCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Except} from 'type-fest'; 3 | import PopupMenuItem, {PopupMenuItemProps} from './PopupMenuItem'; 4 | 5 | export interface PopupMenuItemCheckboxProps 6 | extends Except, 'role'> { 7 | checked?: boolean; 8 | } 9 | 10 | export default function PopupMenuItemCheckbox({ 11 | className = '', 12 | checked = false, 13 | ...props 14 | }: PopupMenuItemCheckboxProps) { 15 | return ( 16 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/PopupMenu/PopupMenuSeparator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function PopupMenuSeparator() { 4 | return
  • ; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/PopupMenu/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './PopupMenu'; 2 | export * from './PopupMenu'; 3 | export {default as PopupMenuItem} from './PopupMenuItem'; 4 | export * from './PopupMenuItem'; 5 | export {default as PopupMenuItemCheckbox} from './PopupMenuItemCheckbox'; 6 | export * from './PopupMenuItemCheckbox'; 7 | export {default as PopupMenuSeparator} from './PopupMenuSeparator'; 8 | export * from './PopupMenuSeparator'; 9 | export {default as showPopupMenu} from './showPopupMenu'; 10 | -------------------------------------------------------------------------------- /src/components/Scrollable/FixedHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface FixedHeaderProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export default function FixedHeader({children}: FixedHeaderProps) { 8 | return <>{children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Scrollable/Scrollable.scss: -------------------------------------------------------------------------------- 1 | .scrollable { 2 | --scrollbar-size: calc(1.5em * var(--scrollbar-thickness)); 3 | 4 | overflow: hidden; 5 | border-radius: inherit; 6 | 7 | &, 8 | &-content { 9 | position: absolute; 10 | inset: 0; 11 | } 12 | 13 | &-content { 14 | isolation: isolate; 15 | display: flex; 16 | flex-direction: column; 17 | min-width: calc(2 * var(--scrollbar-size)); 18 | min-height: calc(2 * var(--scrollbar-size)); 19 | } 20 | 21 | &-head { 22 | flex: initial; 23 | } 24 | 25 | &-body { 26 | flex: auto; 27 | overflow: hidden; 28 | } 29 | 30 | &-body-content { 31 | position: relative; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Scrollable/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Scrollable'; 2 | export * from './Scrollable'; 3 | -------------------------------------------------------------------------------- /src/components/SearchBar/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './SearchBar'; 2 | export * from './SearchBar'; 3 | -------------------------------------------------------------------------------- /src/components/Settings/AdvancedSettings/AdvancedSettings.scss: -------------------------------------------------------------------------------- 1 | .advanced-settings { 2 | .dialog-button-submit { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Settings/AdvancedSettings/AdvancedSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TabList, {TabItem} from 'components/TabList'; 3 | import Backup from './Backup'; 4 | import Logs from './Logs'; 5 | import Troubleshooting from './Troubleshooting'; 6 | import './AdvancedSettings.scss'; 7 | 8 | const tabs: TabItem[] = [ 9 | { 10 | tab: 'Logs', 11 | panel: , 12 | }, 13 | { 14 | tab: 'Backup', 15 | panel: , 16 | }, 17 | { 18 | tab: 'Troubleshooting', 19 | panel: , 20 | }, 21 | ]; 22 | 23 | export default function AdvancedSettings() { 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Settings/AdvancedSettings/Backup.scss: -------------------------------------------------------------------------------- 1 | .backup { 2 | .checkbox-list { 3 | max-height: 8em; 4 | 5 | & + p { 6 | margin-top: 1.25em; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Settings/AdvancedSettings/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './AdvancedSettings'; 2 | export * from './AdvancedSettings'; 3 | -------------------------------------------------------------------------------- /src/components/Settings/AdvancedSettings/useLogs.ts: -------------------------------------------------------------------------------- 1 | import Logger, {Log} from 'utils/Logger'; 2 | import useObservable from 'hooks/useObservable'; 3 | 4 | export default function useLogs(): readonly Log[] { 5 | return useObservable(Logger.observeLogs, Logger.logs); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Settings/AppSettings/AppSettings.scss: -------------------------------------------------------------------------------- 1 | .app-settings { 2 | &-general { 3 | fieldset p { 4 | margin: 0; 5 | } 6 | } 7 | 8 | .app-preferences { 9 | kbd { 10 | font: inherit; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Settings/AppSettings/AppSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TabList, {TabItem} from 'components/TabList'; 3 | import AppSettingsGeneral from './AppSettingsGeneral'; 4 | import AppPreferences from './AppPreferences'; 5 | import './AppSettings.scss'; 6 | 7 | const tabs: TabItem[] = [ 8 | { 9 | tab: 'General', 10 | panel: , 11 | }, 12 | { 13 | tab: 'Preferences', 14 | panel: , 15 | }, 16 | ]; 17 | 18 | export default function AppSettings() { 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Settings/AppSettings/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './AppSettings'; 2 | export * from './AppSettings'; 3 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/AppearanceSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TabList, {TabItem} from 'components/TabList'; 3 | import AppearanceSettingsGeneral from './AppearanceSettingsGeneral'; 4 | import ThemeEditor from './ThemeEditor'; 5 | import UserThemes from './UserThemes'; 6 | 7 | const tabs: TabItem[] = [ 8 | { 9 | tab: 'General', 10 | panel: , 11 | }, 12 | { 13 | tab: 'Theme Editor', 14 | panel: , 15 | }, 16 | { 17 | tab: 'My Themes', 18 | panel: , 19 | }, 20 | ]; 21 | 22 | export default function AppearanceSettings() { 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/AppearanceSettingsGeneral.scss: -------------------------------------------------------------------------------- 1 | .appearance-settings-general { 2 | .table-layout label { 3 | display: inline; 4 | } 5 | 6 | .font-size label { 7 | display: table-cell; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/ThemeEditor/SaveThemeDialog.scss: -------------------------------------------------------------------------------- 1 | .save-theme-dialog { 2 | select { 3 | width: 100%; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/ThemeEditor/ThemeColor.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback} from 'react'; 2 | import {Except} from 'type-fest'; 3 | import theme, {ThemeColorName} from 'services/theme'; 4 | 5 | export interface ThemeColorProps 6 | extends Except, 'onChange'> { 7 | colorName: ThemeColorName; 8 | onChange?: (value: string) => void; 9 | } 10 | 11 | export default function ThemeColor({colorName, onChange, ...props}: ThemeColorProps) { 12 | const handleChange = useCallback( 13 | (event: React.ChangeEvent) => { 14 | const value = event.target.value; 15 | theme[colorName] = value; 16 | onChange?.(value); 17 | }, 18 | [colorName, onChange] 19 | ); 20 | 21 | return ; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/ThemeEditor/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './ThemeEditor'; 2 | export * from './ThemeEditor'; 3 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/ThemeEditor/saveTheme.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import theme from 'services/theme'; 3 | import themeStore from 'services/theme/themeStore'; 4 | import {DialogProps, showDialog} from 'components/Dialog'; 5 | import SaveThemeDialog from './SaveThemeDialog'; 6 | 7 | export default async function saveTheme(suggestedName: string): Promise { 8 | const name = await showDialog( 9 | (props: DialogProps) => , 10 | true 11 | ); 12 | if (name) { 13 | theme.name = name; 14 | theme.userTheme = true; 15 | await themeStore.addUserTheme(theme.current); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/UserThemes.scss: -------------------------------------------------------------------------------- 1 | .user-themes { 2 | &-buttons { 3 | text-align: right; 4 | display: flex; 5 | flex-direction: row; 6 | } 7 | 8 | &-import { 9 | margin-top: 2em; 10 | } 11 | 12 | &-delete { 13 | margin-left: auto; 14 | } 15 | 16 | input[type="file"] { 17 | position: absolute; 18 | visibility: hidden; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/confirmDeleteTheme.ts: -------------------------------------------------------------------------------- 1 | import {confirm} from 'components/Dialog'; 2 | 3 | export default async function confirmDeleteTheme(name: string): Promise { 4 | return confirm({ 5 | icon: 'palette', 6 | title: 'My Themes', 7 | message: `Delete theme '${name}'?`, 8 | okLabel: 'Delete', 9 | storageId: 'delete-user-theme', 10 | system: true, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/confirmOverwriteTheme.ts: -------------------------------------------------------------------------------- 1 | import themeStore from 'services/theme/themeStore'; 2 | import {confirm} from 'components/Dialog'; 3 | 4 | export default async function confirmOverwriteTheme(name: string): Promise { 5 | if (!themeStore.getUserTheme(name)) { 6 | return true; 7 | } 8 | return confirm({ 9 | icon: 'palette', 10 | title: 'My Themes', 11 | message: `Overwrite existing theme '${name}'?`, 12 | okLabel: 'Save', 13 | storageId: 'overwrite-user-theme', 14 | system: true, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './AppearanceSettings'; 2 | export * from './AppearanceSettings'; 3 | 4 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/useCurrentTheme.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import {map, merge} from 'rxjs'; 3 | import theme, {CurrentTheme} from 'services/theme'; 4 | import themeStore from 'services/theme/themeStore'; 5 | import useObservable from 'hooks/useObservable'; 6 | 7 | export default function useCurrentTheme(): CurrentTheme { 8 | const observeTheme = useMemo( 9 | () => () => { 10 | // Make sure we get a fresh object. 11 | return merge(theme.observe(), themeStore.observeUserThemes()).pipe( 12 | map(() => ({...theme.current})) 13 | ); 14 | }, 15 | [] 16 | ); 17 | return useObservable(observeTheme, theme.current); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/useDefaultThemes.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import themeStore from 'services/theme/themeStore'; 3 | 4 | export default function useDefaultThemes() { 5 | return useMemo(() => themeStore.getDefaultThemes(), []); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Settings/AppearanceSettings/useUserThemes.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import themeStore from 'services/theme/themeStore'; 3 | import useObservable from 'hooks/useObservable'; 4 | 5 | export default function useUserThemes() { 6 | const observeUserThemes = useMemo(() => () => themeStore.observeUserThemes(), []); 7 | return useObservable(observeUserThemes, themeStore.getUserThemes()); 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/AudioSettings.scss: -------------------------------------------------------------------------------- 1 | .audio-settings { 2 | select, 3 | input { 4 | max-width: 6em; 5 | } 6 | 7 | .warning { 8 | margin-top: 1.5em; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/CredentialsDialog.scss: -------------------------------------------------------------------------------- 1 | .credentials-dialog { 2 | width: 32em; 3 | height: 34em; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/DisconnectButton.scss: -------------------------------------------------------------------------------- 1 | .disconnect-button { 2 | width: 100%; 3 | 4 | &:disabled { 5 | background-image: none; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/MediaServiceList.scss: -------------------------------------------------------------------------------- 1 | .media-service-list { 2 | & > li:not(.no-icon) { 3 | align-items: baseline; 4 | } 5 | 6 | & + & { 7 | margin-top: 1em; 8 | border-top: 1px solid; 9 | padding-top: 1em; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/MediaServiceSettingsGeneral.scss: -------------------------------------------------------------------------------- 1 | .media-service-settings-general { 2 | .media-source-label { 3 | display: inline-flex; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/PersonalMediaServerInfo.scss: -------------------------------------------------------------------------------- 1 | .personal-media-server-info { 2 | .table-layout p { 3 | line-height: normal; 4 | } 5 | 6 | input { 7 | border: none; 8 | background: none; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/PersonalMediaServerSettings.scss: -------------------------------------------------------------------------------- 1 | .personal-media-server-settings { 2 | .external-link-text { 3 | text-decoration: underline; 4 | } 5 | 6 | select { 7 | min-width: 8em; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/PinnedSettings.scss: -------------------------------------------------------------------------------- 1 | .pinned-settings { 2 | &-buttons { 3 | text-align: right; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/confirmDisconnectServices.scss: -------------------------------------------------------------------------------- 1 | .confirm-disconnect-services { 2 | text-align: left; 3 | 4 | ul { 5 | margin: 1em 2em; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/usePinsForService.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import MediaService from 'types/MediaService'; 3 | import Pin from 'types/Pin'; 4 | import pinStore from 'services/pins/pinStore'; 5 | import useObservable from 'hooks/useObservable'; 6 | 7 | export default function usePinsForService(service: MediaService): readonly Pin[] { 8 | const observePinsForService = useMemo( 9 | () => () => pinStore.observePinsForService(service.id), 10 | [service] 11 | ); 12 | return useObservable(observePinsForService, pinStore.getPinsForService(service.id)); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Settings/MediaLibrarySettings/useServerInfo.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {defer} from 'rxjs'; 3 | import PersonalMediaService from 'types/PersonalMediaService'; 4 | import useIsLoggedIn from 'hooks/useIsLoggedIn'; 5 | 6 | export default function useServerInfo(service: PersonalMediaService) { 7 | const isLoggedIn = useIsLoggedIn(service); 8 | const [serverInfo, setServerInfo] = useState>({}); 9 | 10 | useEffect(() => { 11 | if (isLoggedIn && service.getServerInfo) { 12 | const subscription = defer(() => service.getServerInfo!()).subscribe(setServerInfo); 13 | return () => subscription.unsubscribe(); 14 | } 15 | }, [service, isLoggedIn]); 16 | 17 | return serverInfo; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/AmpshaderSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, {useId} from 'react'; 2 | import visualizerSettings from 'services/visualizer/visualizerSettings'; 3 | 4 | export default function AmpshaderSettings() { 5 | const id = useId(); 6 | 7 | return ( 8 |
    9 | Options 10 |

    11 | (visualizerSettings.ampshaderTransparency = e.target.checked)} 16 | /> 17 | 18 |

    19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/ButterchurnSettings.scss: -------------------------------------------------------------------------------- 1 | .butterchurn-settings { 2 | .table-layout { 3 | select, 4 | input { 5 | max-width: max-content; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/VisualizerFavorites.scss: -------------------------------------------------------------------------------- 1 | .visualizer-favorites { 2 | .list-view { 3 | width: 100%; 4 | height: 20em; 5 | margin: 0.5em 0; 6 | } 7 | 8 | &-buttons { 9 | display: flex; 10 | flex-direction: row; 11 | align-items: baseline; 12 | } 13 | 14 | &-delete { 15 | margin-top: 0.5em; 16 | margin-left: auto; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/VisualizerSettings.scss: -------------------------------------------------------------------------------- 1 | .visualizer-settings { 2 | &-dialog { 3 | width: 33.5em; 4 | height: 36.5em; 5 | } 6 | 7 | select + button { 8 | margin-left: 1em; 9 | } 10 | 11 | .ambient-video-settings { 12 | input[type="url"] { 13 | width: 100%; 14 | } 15 | 16 | fieldset + p { 17 | margin-top: 1.5em; 18 | } 19 | } 20 | 21 | &-general fieldset { 22 | margin-top: 1.5em; 23 | } 24 | 25 | .use-provider { 26 | padding-top: 0; 27 | padding-bottom: 0; 28 | 29 | &.in-use { 30 | opacity: 1; 31 | color: var(--background-color); 32 | background: var(--text-color); 33 | } 34 | } 35 | 36 | .compatibility { 37 | margin-top: 2em; 38 | font-style: italic; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/VisualizerSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | import {t} from 'services/i18n'; 3 | import TabList, {TabItem} from 'components/TabList'; 4 | import VisualizerSettingsGeneral from './VisualizerSettingsGeneral'; 5 | import VisualizerFavorites from './VisualizerFavorites'; 6 | import './VisualizerSettings.scss'; 7 | 8 | export default function VisualizerSettings() { 9 | const tabs: TabItem[] = useMemo( 10 | () => [ 11 | { 12 | tab: 'General', 13 | panel: , 14 | }, 15 | { 16 | tab: t('Favorites'), 17 | panel: , 18 | }, 19 | ], 20 | [] 21 | ); 22 | 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/VisualizerSettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Dialog, {DialogProps} from 'components/Dialog'; 3 | import VisualizerSettings from './VisualizerSettings'; 4 | 5 | export default function VisualizerSettingsDialog(props: DialogProps) { 6 | return ( 7 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Settings/VisualizerSettings/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './VisualizerSettings'; 2 | export * from './VisualizerSettings'; 3 | export {default as VisualizerSettingsDialog} from './VisualizerSettingsDialog'; 4 | -------------------------------------------------------------------------------- /src/components/Settings/index.ts: -------------------------------------------------------------------------------- 1 | export {default as SettingsDialog} from './SettingsDialog'; 2 | export * from './SettingsDialog'; 3 | export * from './AppearanceSettings'; 4 | export * from './VisualizerSettings'; 5 | -------------------------------------------------------------------------------- /src/components/Splitter/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Splitter'; 2 | export * from './Splitter'; 3 | -------------------------------------------------------------------------------- /src/components/Splitter/layoutSettings.ts: -------------------------------------------------------------------------------- 1 | import {LiteStorage} from 'utils'; 2 | 3 | const storage = new LiteStorage('layout/2'); 4 | 5 | export default { 6 | get(id: string, defaultValue = 0): number { 7 | return storage.getNumber(id, defaultValue); 8 | }, 9 | 10 | set(id: string, value: number): void { 11 | storage.setNumber(id, value); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/StarRating/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './StarRating'; 2 | export * from './StarRating'; 3 | -------------------------------------------------------------------------------- /src/components/StartupWizard/StartupWizard.scss: -------------------------------------------------------------------------------- 1 | .startup-wizard { 2 | width: 24em; 3 | height: 34em; 4 | 5 | h3 { 6 | font-size: 1.375em; 7 | margin-bottom: 0.75em; 8 | } 9 | 10 | form { 11 | width: 100%; 12 | } 13 | 14 | .personal-media-services .media-service-list { 15 | font-size: 1.125em; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/StartupWizard/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './StartupWizard'; 2 | export * from './StartupWizard'; 3 | -------------------------------------------------------------------------------- /src/components/StatusBar/StatusBar.scss: -------------------------------------------------------------------------------- 1 | .status-bar { 2 | padding: var(--gutter-width) 0; 3 | font-size: 0.75em; 4 | white-space: nowrap; 5 | overflow: hidden; 6 | text-overflow: ellipsis; 7 | user-select: none; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/StatusBar/StatusBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './StatusBar.scss'; 3 | 4 | export interface StatusBarProps { 5 | className?: string; 6 | children: React.ReactNode; 7 | } 8 | 9 | export default function StatusBar({className = '', children}: StatusBarProps) { 10 | return
    {children}
    ; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/StatusBar/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './StatusBar'; 2 | export * from './StatusBar'; 3 | -------------------------------------------------------------------------------- /src/components/SunClock/SunClock.scss: -------------------------------------------------------------------------------- 1 | .sun-clock { 2 | --sun-color: var(--text-color-h), var(--text-color-s), var(--text-color-l); 3 | --am-alpha: 0%; 4 | --pm-alpha: 0%; 5 | --am-color: hsla(var(--sun-color), var(--am-alpha)); 6 | --pm-color: hsla(var(--sun-color), var(--pm-alpha)); 7 | 8 | display: inline-block; 9 | width: 1em; 10 | height: 1em; 11 | margin-right: 0.1875em; 12 | background-image: linear-gradient(180deg, var(--am-color) 50%, var(--pm-color) 50%); 13 | border-radius: 50%; 14 | border: 0.125em solid; 15 | aspect-ratio: 1; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/SunClock/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './SunClock'; 2 | export * from './SunClock'; 3 | -------------------------------------------------------------------------------- /src/components/TabList/TabPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {TabItem} from './TabList'; 3 | 4 | export interface TabPanelProps { 5 | id: string; 6 | item: TabItem; 7 | index: number; 8 | hidden: boolean; 9 | } 10 | 11 | export default function TabPanel({id, item, index, hidden}: TabPanelProps) { 12 | return ( 13 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/TabList/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './TabList'; 2 | export * from './TabList'; 3 | -------------------------------------------------------------------------------- /src/components/TextBox/TextBox.scss: -------------------------------------------------------------------------------- 1 | .text-box { 2 | line-height: 1.25; 3 | 4 | p { 5 | margin: 0 0 1em 0; 6 | } 7 | 8 | .scrollable.overflow-y &-content { 9 | padding-right: 1ex; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/TextBox/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './TextBox'; 2 | export * from './TextBox'; 3 | -------------------------------------------------------------------------------- /src/components/Time/Time.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Except} from 'type-fest'; 3 | import {formatTime} from 'utils'; 4 | 5 | export interface TimeProps extends Except, 'children'> { 6 | time: number; 7 | } 8 | 9 | export default function Time({time, ...props}: TimeProps) { 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Time/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Time'; 2 | export * from './Time'; 3 | -------------------------------------------------------------------------------- /src/components/TreeView/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './TreeView'; 2 | export * from './TreeView'; 3 | -------------------------------------------------------------------------------- /src/hooks/useBaseFontSize.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import theme from 'services/theme'; 3 | 4 | export default function useBaseFontSize(): number { 5 | const [fontSize, setFontSize] = useState(theme.fontSize); 6 | 7 | useEffect(() => { 8 | const subscription = theme.observeFontSize().subscribe(setFontSize); 9 | return () => subscription.unsubscribe(); 10 | }, []); 11 | 12 | return fontSize; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useCurrentTime.ts: -------------------------------------------------------------------------------- 1 | import playback, {observeCurrentTime} from 'services/mediaPlayback/playback'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function useCurrentTime(): number { 5 | return useObservable(observeCurrentTime, playback.currentTime); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useCurrentTrack.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import PlaylistItem from 'types/PlaylistItem'; 3 | import playback, {observeCurrentItem} from 'services/mediaPlayback/playback'; 4 | 5 | export default function useCurrentTrack(): PlaylistItem | null { 6 | const [value, setValue] = useState(playback.currentItem); 7 | 8 | useEffect(() => { 9 | const subscription = observeCurrentItem().subscribe(setValue); 10 | return () => subscription.unsubscribe(); 11 | }, []); 12 | 13 | return value; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useCurrentVisualizer.ts: -------------------------------------------------------------------------------- 1 | import Visualizer from 'types/Visualizer'; 2 | import {getCurrentVisualizer, observeCurrentVisualizer} from 'services/visualizer'; 3 | import useObservable from './useObservable'; 4 | 5 | export default function useCurrentVisualizer(): Visualizer | null { 6 | return useObservable(observeCurrentVisualizer, getCurrentVisualizer()); 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/useCurrentlyPlaying.ts: -------------------------------------------------------------------------------- 1 | import PlaylistItem from 'types/PlaylistItem'; 2 | import {getCurrentItem, observeCurrentItem} from 'services/playlist'; 3 | import useObservable from './useObservable'; 4 | 5 | export default function useCurrentlyPlaying(): PlaylistItem | null { 6 | return useObservable(observeCurrentItem, getCurrentItem()); 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/useDebouncedValue.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {debounceTime} from 'rxjs'; 3 | import useSubject from './useSubject'; 4 | 5 | export default function useDebouncedValue(initialValue: T, dueTime: number) { 6 | const [debouncedValue, setDebouncedValue] = useState(initialValue); 7 | const [value$, nextValue] = useSubject(); 8 | 9 | useEffect(() => { 10 | const subscription = value$.pipe(debounceTime(dueTime)).subscribe(setDebouncedValue); 11 | return () => subscription.unsubscribe(); 12 | }, [value$, dueTime]); 13 | 14 | return [debouncedValue, nextValue] as const; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useDuration.ts: -------------------------------------------------------------------------------- 1 | import playback, {observeDuration} from 'services/mediaPlayback/playback'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function useDuration(): number { 5 | return useObservable(observeDuration, playback.duration); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useFirstValue.ts: -------------------------------------------------------------------------------- 1 | import {useRef} from 'react'; 2 | 3 | export default function useFirstValue(value: T) { 4 | const valueRef = useRef(value); 5 | if (valueRef.current == null && value != null) { 6 | valueRef.current = value; 7 | } 8 | return valueRef.current; 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/useIsLoggedIn.ts: -------------------------------------------------------------------------------- 1 | import MediaService from 'types/MediaService'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function useIsLoggedIn(service: MediaService): boolean { 5 | return useObservable(service.observeIsLoggedIn, service.isLoggedIn()); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useIsOnLine.ts: -------------------------------------------------------------------------------- 1 | import {observeIsOnLine, isOnLine} from 'services/online'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function useIsOnLine(): boolean { 5 | return useObservable(observeIsOnLine, isOnLine()); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useIsPlaying.ts: -------------------------------------------------------------------------------- 1 | import usePlaybackState from './usePlaybackState'; 2 | 3 | export default function useIsPlaying(): boolean { 4 | const {paused, currentTime} = usePlaybackState(); 5 | return !paused && currentTime > 0; 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useMediaServices.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {defer} from 'rxjs'; 3 | import MediaService from 'types/MediaService'; 4 | import {getServices, loadMediaServices} from 'services/mediaServices'; 5 | 6 | export default function useMediaServices() { 7 | const [services, setServices] = useState(() => getServices()); 8 | 9 | useEffect(() => { 10 | const subscription = defer(() => loadMediaServices()).subscribe(setServices); 11 | return () => subscription.unsubscribe(); 12 | }, []); 13 | 14 | return services; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useMiniPlayerActive.ts: -------------------------------------------------------------------------------- 1 | import miniPlayer from 'services/mediaPlayback/miniPlayer'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function useMiniPlayerActive(): boolean { 5 | return useObservable(miniPlayer.observeActive, miniPlayer.active); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useObservable.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import type {Observable} from 'rxjs'; 3 | 4 | export default function useObservable(observe: () => Observable, initialValue: T): T { 5 | const [value, setValue] = useState(initialValue); 6 | 7 | useEffect(() => { 8 | const subscription = observe().subscribe(setValue); 9 | return () => subscription.unsubscribe(); 10 | }, [observe]); 11 | 12 | return value; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/usePaused.ts: -------------------------------------------------------------------------------- 1 | import playback, {observePaused} from 'services/mediaPlayback/playback'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function usePaused(): boolean { 5 | return useObservable(observePaused, playback.paused); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/usePlaybackState.ts: -------------------------------------------------------------------------------- 1 | import PlaybackState from 'types/PlaybackState'; 2 | import {getPlaybackState, observePlaybackState} from 'services/mediaPlayback/playback'; 3 | import useObservable from './useObservable'; 4 | 5 | export default function usePlaybackState(): PlaybackState { 6 | return useObservable(observePlaybackState, getPlaybackState()); 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/usePreferences.ts: -------------------------------------------------------------------------------- 1 | import preferences, {observePreferences} from 'services/preferences'; 2 | import useObservable from './useObservable'; 3 | 4 | export default function usePreferences() { 5 | return useObservable(observePreferences, {...preferences}); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from 'react'; 2 | 3 | export default function usePrevious(value: T) { 4 | const ref = useRef(undefined); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }, [value]); 9 | 10 | return ref.current; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useSearch.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import MediaObject from 'types/MediaObject'; 3 | import MediaSearchParams from 'types/MediaSearchParams'; 4 | import MediaSource from 'types/MediaSource'; 5 | import Pager from 'types/Pager'; 6 | import useSource from './useSource'; 7 | 8 | export default function useSearch( 9 | source: MediaSource | null, 10 | q: MediaSearchParams['q'] = '', 11 | sortBy?: MediaSearchParams['sortBy'], 12 | sortOrder?: MediaSearchParams['sortOrder'] 13 | ): Pager | null { 14 | const params: MediaSearchParams = useMemo( 15 | () => ({q, sortBy, sortOrder}), 16 | [q, sortBy, sortOrder] 17 | ); 18 | const pager = useSource(source, params); 19 | return pager; 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useSorting.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import MediaObject from 'types/MediaObject'; 3 | import MediaSource from 'types/MediaSource'; 4 | import {getSourceSorting, observeSourceSorting} from 'services/mediaServices/servicesSettings'; 5 | import useObservable from './useObservable'; 6 | 7 | export default function useSorting(source: MediaSource) { 8 | const observeSorting = useMemo(() => () => observeSourceSorting(source), [source]); 9 | const {sortBy, sortOrder} = useObservable(observeSorting, getSourceSorting(source)); 10 | return {sortBy, sortOrder}; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useSubject.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useRef} from 'react'; 2 | import type {Observable} from 'rxjs'; 3 | import {Subject} from 'rxjs'; 4 | 5 | export default function useSubject(): [Observable, (next: T) => void] { 6 | const ref = useRef | null>(null); 7 | 8 | if (ref.current === null) { 9 | ref.current = new Subject(); 10 | } 11 | 12 | const next = useCallback((value: T) => { 13 | ref.current!.next(value); 14 | }, []); 15 | 16 | return [ref.current, next]; 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/useThrottledValue.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {throttleTime, ThrottleConfig} from 'rxjs'; 3 | import useSubject from './useSubject'; 4 | 5 | export default function useThrottledValue( 6 | initialValue: T, 7 | dueTime: number, 8 | {leading = true, trailing = false}: ThrottleConfig = {} 9 | ) { 10 | const [throttledValue, setThrottledValue] = useState(initialValue); 11 | const [value$, nextValue] = useSubject(); 12 | 13 | useEffect(() => { 14 | const subscription = value$ 15 | .pipe(throttleTime(dueTime, undefined, {leading, trailing})) 16 | .subscribe(setThrottledValue); 17 | return () => subscription.unsubscribe(); 18 | }, [value$, dueTime, leading, trailing]); 19 | 20 | return [throttledValue, nextValue] as const; 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/useVisualizerProviders.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {defer} from 'rxjs'; 3 | import VisualizerProvider from 'types/VisualizerProvider'; 4 | import {getVisualizerProviders, loadVisualizers} from 'services/visualizer/visualizerProviders'; 5 | 6 | export default function useVisualizerProviders() { 7 | const [providers, setProviders] = useState(() => 8 | getVisualizerProviders() 9 | ); 10 | 11 | useEffect(() => { 12 | const subscription = defer(() => loadVisualizers()).subscribe(setProviders); 13 | return () => subscription.unsubscribe(); 14 | }, []); 15 | 16 | return providers; 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/useVisualizerSettings.ts: -------------------------------------------------------------------------------- 1 | import visualizerSettings, { 2 | observeVisualizerSettings, 3 | } from 'services/visualizer/visualizerSettings'; 4 | import useObservable from './useObservable'; 5 | 6 | export default function useVisualizerSettings() { 7 | return useObservable(observeVisualizerSettings, {...visualizerSettings}); 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/useYouTubeVideoInfo.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {defer} from 'rxjs'; 3 | import MediaItem from 'types/MediaItem'; 4 | import youtubeApi from 'services/youtube/youtubeApi'; 5 | 6 | export default function useYouTubeVideoInfo(src: string) { 7 | const [service, , videoId] = src.split(':'); 8 | const [videoInfo, setVideoInfo] = useState(null); 9 | 10 | useEffect(() => { 11 | setVideoInfo(null); 12 | if (service === 'youtube' && videoId) { 13 | const subscription = defer(() => youtubeApi.getMediaItem(videoId)).subscribe( 14 | setVideoInfo 15 | ); 16 | return () => subscription.unsubscribe(); 17 | } 18 | }, [service, videoId]); 19 | 20 | return videoInfo; 21 | } 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'styles/index.scss'; 2 | import React, {StrictMode} from 'react'; 3 | import {createRoot} from 'react-dom/client'; 4 | import {ErrorBoundary} from 'react-error-boundary'; 5 | import {Logger} from 'utils'; 6 | import {createErrorReport} from 'services/reporting'; 7 | import App from 'components/App'; 8 | import BSOD from 'components/Errors/BSOD'; 9 | import './registerServiceWorker'; 10 | 11 | Logger.createErrorReport = createErrorReport; 12 | 13 | const uncaught = new Logger('uncaught'); 14 | 15 | window.onerror = __dev__ ? uncaught.error : uncaught.warn; 16 | 17 | createRoot(document.getElementById('app')!).render( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /src/services/ampcastElectron.ts: -------------------------------------------------------------------------------- 1 | import AmpcastElectron from 'types/AmpcastElectron'; 2 | import {browser} from 'utils'; 3 | 4 | const ampcastElectron: AmpcastElectron | undefined = browser.isElectron 5 | ? (globalThis as any)['ampcastElectron'] 6 | : undefined; 7 | 8 | export default ampcastElectron; 9 | -------------------------------------------------------------------------------- /src/services/apple/AppleBitrate.ts: -------------------------------------------------------------------------------- 1 | const enum AppleBitrate { 2 | Standard = 64, 3 | High = 256, 4 | } 5 | 6 | export default AppleBitrate; 7 | -------------------------------------------------------------------------------- /src/services/apple/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import mediaPlayer from 'services/mediaPlayback/mediaPlayer'; 2 | import musicKitPlayer from './musicKitPlayer'; 3 | 4 | mediaPlayer.registerPlayer(musicKitPlayer, (item) => !!item?.src.startsWith('apple:')); 5 | -------------------------------------------------------------------------------- /src/services/apple/components/useCredentials.ts: -------------------------------------------------------------------------------- 1 | import useObservable from 'hooks/useObservable'; 2 | import appleSettings, {AppleCredentials} from '../appleSettings'; 3 | 4 | export default function useCredentials(): AppleCredentials { 5 | return useObservable(appleSettings.observeCredentials, appleSettings.getCredentials()); 6 | } 7 | -------------------------------------------------------------------------------- /src/services/apple/components/useMusicKit.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {defer} from 'rxjs'; 3 | import {getMusicKitInstance} from '../appleAuth'; 4 | 5 | export default function useMusicKit(devToken: string) { 6 | const [musicKit, setMusicKit] = useState(null); 7 | const [error, setError] = useState(null); 8 | 9 | useEffect(() => { 10 | if (devToken) { 11 | const subscription = defer(() => getMusicKitInstance()).subscribe({ 12 | next: setMusicKit, 13 | error: setError, 14 | }); 15 | return () => subscription.unsubscribe(); 16 | } 17 | }, [devToken]); 18 | 19 | return {musicKit, error}; 20 | } 21 | -------------------------------------------------------------------------------- /src/services/apple/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './apple'; 2 | export * from './apple'; 3 | -------------------------------------------------------------------------------- /src/services/audio/OmniAudioContext.ts: -------------------------------------------------------------------------------- 1 | import OmniAnalyserNode from './OmniAnalyserNode'; 2 | 3 | export default class OmniAudioContext extends AudioContext { 4 | createAnalyser(): AnalyserNode { 5 | return new OmniAnalyserNode(this); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/services/audio/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './audio'; 2 | export * from './audio'; 3 | -------------------------------------------------------------------------------- /src/services/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_DURATION = 24 * 60 * 60 - 1; 2 | export const githubRepoUrl = 'https://github.com/rekkyrosso/ampcast'; 3 | export const downloadUrl = `${githubRepoUrl}/releases`; 4 | export const dockerUrl = `${githubRepoUrl}/pkgs/container/ampcast`; 5 | export const supportUrl = 'https://www.reddit.com/r/ampcast'; 6 | -------------------------------------------------------------------------------- /src/services/emby/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './emby'; 2 | export * from './emby'; 3 | -------------------------------------------------------------------------------- /src/services/i18n.ts: -------------------------------------------------------------------------------- 1 | const en_us = /^en(-us)?$/i.test(navigator.language); 2 | 3 | export function t(string: string): string { 4 | // Let's keep this simple for now. 5 | if (string && !en_us) { 6 | return string.replaceAll(/(F)avorite(s?)/gi, '$1avourite$2'); 7 | } 8 | return string; 9 | } 10 | -------------------------------------------------------------------------------- /src/services/jellyfin/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './jellyfin'; 2 | export * from './jellyfin'; 3 | -------------------------------------------------------------------------------- /src/services/jellyfin/jellyfinSettings.ts: -------------------------------------------------------------------------------- 1 | import {EmbySettings} from 'services/emby/embySettings'; 2 | 3 | const jellyfinSettings = new EmbySettings('jellyfin'); 4 | 5 | export default jellyfinSettings; 6 | -------------------------------------------------------------------------------- /src/services/lastfm/components/LastFmHistoryBrowser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaItem from 'types/MediaItem'; 3 | import MediaService from 'types/MediaService'; 4 | import MediaSource from 'types/MediaSource'; 5 | import HistoryBrowser from 'components/MediaBrowser/HistoryBrowser'; 6 | import useHistoryStart from './useHistoryStart'; 7 | 8 | export interface LastFmHistoryBrowserProps { 9 | service: MediaService; 10 | source: MediaSource; 11 | } 12 | 13 | export default function LastFmHistoryBrowser({ 14 | service: lastfm, 15 | source: history, 16 | }: LastFmHistoryBrowserProps) { 17 | const minDate = useHistoryStart(); 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /src/services/lastfm/components/useCredentials.ts: -------------------------------------------------------------------------------- 1 | import useObservable from 'hooks/useObservable'; 2 | import lastfmSettings, {LastFmCredentials} from '../lastfmSettings'; 3 | 4 | export default function useCredentials(): LastFmCredentials { 5 | return useObservable(lastfmSettings.observeCredentials, lastfmSettings.getCredentials()); 6 | } 7 | -------------------------------------------------------------------------------- /src/services/lastfm/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './lastfm'; 2 | export * from './lastfm'; 3 | -------------------------------------------------------------------------------- /src/services/listenbrainz/components/ListenBrainzHistoryBrowser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MediaItem from 'types/MediaItem'; 3 | import MediaService from 'types/MediaService'; 4 | import MediaSource from 'types/MediaSource'; 5 | import HistoryBrowser from 'components/MediaBrowser/HistoryBrowser'; 6 | import useHistoryStart from './useHistoryStart'; 7 | 8 | export interface ListenBrainzHistoryBrowserProps { 9 | service: MediaService; 10 | source: MediaSource; 11 | } 12 | 13 | export default function ListenBrainzHistoryBrowser({ 14 | service: listenbrainz, 15 | source: history, 16 | }: ListenBrainzHistoryBrowserProps) { 17 | const minDate = useHistoryStart(); 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /src/services/listenbrainz/components/ListenBrainzLoginDialog.scss: -------------------------------------------------------------------------------- 1 | .listenbrainz-login-dialog { 2 | .listenbrainz-link { 3 | text-align: center; 4 | margin-bottom: 1em; 5 | font-size: 0.875em; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/services/listenbrainz/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './listenbrainz'; 2 | export * from './listenbrainz'; 3 | -------------------------------------------------------------------------------- /src/services/listenbrainz/listenbrainzSettings.ts: -------------------------------------------------------------------------------- 1 | import {LiteStorage} from 'utils'; 2 | 3 | const storage = new LiteStorage('listenbrainz'); 4 | 5 | export default { 6 | get token(): string { 7 | return storage.getString('token'); 8 | }, 9 | 10 | set token(token: string) { 11 | storage.setString('token', token); 12 | }, 13 | 14 | get userId(): string { 15 | return storage.getString('userId'); 16 | }, 17 | 18 | set userId(userId: string) { 19 | storage.setString('userId', userId); 20 | }, 21 | 22 | get firstScrobbledAt(): string { 23 | return storage.getString('firstScrobbledAt'); 24 | }, 25 | 26 | set firstScrobbledAt(time: string) { 27 | storage.setString('firstScrobbledAt', time); 28 | }, 29 | 30 | clear(): void { 31 | storage.clear(); 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/services/lookup/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './lookup'; 2 | export * from './lookup'; 3 | export * from './lookupEvents'; 4 | -------------------------------------------------------------------------------- /src/services/lookup/lookupSettings.ts: -------------------------------------------------------------------------------- 1 | import {LiteStorage} from 'utils'; 2 | 3 | const storage = new LiteStorage('lookup'); 4 | 5 | export default { 6 | get preferPersonalMedia(): boolean { 7 | return storage.getBoolean('preferPersonalMedia'); 8 | }, 9 | 10 | set preferPersonalMedia(preferPersonalMedia: boolean) { 11 | storage.setBoolean('preferPersonalMedia', preferPersonalMedia); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/services/mediaPlayback/defaultPlaybackState.ts: -------------------------------------------------------------------------------- 1 | import PlaybackState from 'types/PlaybackState'; 2 | import {isMiniPlayer} from 'utils'; 3 | 4 | const defaultPlaybackState: PlaybackState = { 5 | currentItem: null, 6 | currentTime: 0, 7 | startedAt: 0, 8 | endedAt: 0, 9 | duration: 0, 10 | paused: true, 11 | playbackId: '', 12 | miniPlayer: isMiniPlayer, 13 | }; 14 | 15 | export default defaultPlaybackState; 16 | -------------------------------------------------------------------------------- /src/services/mediaPlayback/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './mediaPlayback'; 2 | export * from './mediaPlayback'; 3 | -------------------------------------------------------------------------------- /src/services/mediaPlayback/mediaPlaybackSettings.ts: -------------------------------------------------------------------------------- 1 | import {LiteStorage} from 'utils'; 2 | 3 | const storage = new LiteStorage('mediaPlayback'); 4 | const session = new LiteStorage('mediaPlayback', 'session'); 5 | 6 | const mediaPlaybackSettings = { 7 | get loop(): boolean { 8 | return session.getBoolean('loop'); 9 | }, 10 | 11 | set loop(loop: boolean) { 12 | session.setBoolean('loop', loop); 13 | }, 14 | 15 | get muted(): boolean { 16 | return session.getBoolean('muted'); 17 | }, 18 | 19 | set muted(muted: boolean) { 20 | session.setBoolean('muted', muted); 21 | }, 22 | 23 | get volume(): number { 24 | return storage.getNumber('volume', 0.7); 25 | }, 26 | 27 | set volume(volume: number) { 28 | storage.setNumber('volume', volume); 29 | }, 30 | }; 31 | 32 | export default mediaPlaybackSettings; 33 | -------------------------------------------------------------------------------- /src/services/mediaPlayback/players/observeNearEnd.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import {distinctUntilChanged, map, withLatestFrom} from 'rxjs'; 3 | import Player from 'types/Player'; 4 | 5 | export default function observeNearEnd( 6 | player: Player, 7 | secondsBeforeEnd: number 8 | ): Observable { 9 | return player.observeCurrentTime().pipe( 10 | withLatestFrom(player.observeDuration()), 11 | map(([currentTime, duration]) => duration - currentTime <= secondsBeforeEnd), 12 | distinctUntilChanged() 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/services/mediaPlayback/scrobbler.ts: -------------------------------------------------------------------------------- 1 | import {filter, mergeMap, tap} from 'rxjs'; 2 | import {isMiniPlayer, Logger} from 'utils'; 3 | import {addListen} from 'services/localdb/listens'; 4 | import {observeMediaServices} from 'services/mediaServices'; 5 | import {observePlaybackEnd} from './playback'; 6 | 7 | if (!isMiniPlayer) { 8 | const logger = new Logger('mediaPlayback/scrobbler'); 9 | const registeredScrobblers: Record = {}; 10 | 11 | observePlaybackEnd() 12 | .pipe(mergeMap((state) => addListen(state))) 13 | .subscribe(logger); 14 | 15 | observeMediaServices() 16 | .pipe( 17 | mergeMap((services) => services), 18 | filter((service) => !registeredScrobblers[service.id]), 19 | tap((service) => (registeredScrobblers[service.id] = true)), 20 | tap((service) => service.scrobble?.()) 21 | ) 22 | .subscribe(logger); 23 | } 24 | -------------------------------------------------------------------------------- /src/services/mediaServices/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mediaServices'; 2 | -------------------------------------------------------------------------------- /src/services/mediaServices/noAuth.ts: -------------------------------------------------------------------------------- 1 | import {of} from 'rxjs'; 2 | import Auth from 'types/Auth'; 3 | 4 | export default function noAuth(isLoggedIn: boolean): Auth { 5 | return { 6 | noAuth: true, 7 | observeIsLoggedIn: () => of(isLoggedIn), 8 | isConnected: () => isLoggedIn, 9 | isLoggedIn: () => isLoggedIn, 10 | login: () => Promise.resolve(), 11 | logout: () => Promise.resolve(), 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/services/metadata/index.ts: -------------------------------------------------------------------------------- 1 | export * from './matcher'; 2 | export * from './metadata'; 3 | export * from './metadataChanges'; 4 | export * from './music-metadata-js'; 5 | export * from './playlistParser'; 6 | export * from './thumbnails'; 7 | export * from './userData'; 8 | -------------------------------------------------------------------------------- /src/services/mixcloud/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './mixcloud'; 2 | -------------------------------------------------------------------------------- /src/services/mixcloud/mixcloud.ts: -------------------------------------------------------------------------------- 1 | import PublicMediaService from 'types/PublicMediaService'; 2 | import ServiceType from 'types/ServiceType'; 3 | import noAuth from 'services/mediaServices/noAuth'; 4 | 5 | const mixcloud: PublicMediaService = { 6 | ...noAuth(false), 7 | id: 'mixcloud', 8 | name: 'Mixcloud', 9 | icon: 'mixcloud', 10 | url: 'https://www.mixcloud.com', 11 | serviceType: ServiceType.PublicMedia, 12 | defaultHidden: true, 13 | defaultNoScrobble: true, 14 | iframeAudioPlayback: { 15 | // Mixcloud player shows mainly interactive content. 16 | // Use CoverArt visualizer instead. 17 | showCoverArt: true, 18 | }, 19 | compareForRating: () => false, 20 | }; 21 | 22 | export default mixcloud; 23 | -------------------------------------------------------------------------------- /src/services/musicbrainz/index.ts: -------------------------------------------------------------------------------- 1 | export const musicBrainzHost = 'https://musicbrainz.org'; 2 | -------------------------------------------------------------------------------- /src/services/navidrome/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './navidrome'; 2 | export * from './navidrome'; 3 | -------------------------------------------------------------------------------- /src/services/navidrome/subsonicApi.ts: -------------------------------------------------------------------------------- 1 | import SubsonicApi from 'services/subsonic/factory/SubsonicApi'; 2 | import navidromeSettings from './navidromeSettings'; 3 | 4 | const subsonicApi = new SubsonicApi('navidrome', navidromeSettings); 5 | 6 | export default subsonicApi; 7 | -------------------------------------------------------------------------------- /src/services/online.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import {BehaviorSubject, distinctUntilChanged, fromEvent, map, merge} from 'rxjs'; 3 | 4 | const isOnLine$ = new BehaviorSubject(isOnLine()); 5 | 6 | export function observeIsOnLine(): Observable { 7 | return isOnLine$.pipe(distinctUntilChanged()); 8 | } 9 | 10 | export function isOnLine(): boolean { 11 | return navigator.onLine; 12 | } 13 | 14 | merge( 15 | fromEvent(window, 'online').pipe(map(() => true)), 16 | fromEvent(window, 'offline').pipe(map(() => false)) 17 | ).subscribe(isOnLine$); 18 | -------------------------------------------------------------------------------- /src/services/pagers/ErrorPager.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import {NEVER, of} from 'rxjs'; 3 | import Pager from 'types/Pager'; 4 | 5 | export default class ErrorPager implements Pager { 6 | readonly pageSize = 0; 7 | 8 | constructor(private readonly error: unknown) {} 9 | 10 | observeBusy(): Observable { 11 | return of(false); 12 | } 13 | 14 | observeItems(): Observable { 15 | return NEVER; 16 | } 17 | 18 | observeSize(): Observable { 19 | return NEVER; 20 | } 21 | 22 | observeError(): Observable { 23 | return of(this.error); 24 | } 25 | 26 | fetchAt(): void { 27 | // do nothing 28 | } 29 | 30 | disconnect(): void { 31 | // do nothing 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/services/pagers/SimpleMediaPager.ts: -------------------------------------------------------------------------------- 1 | import MediaObject from 'types/MediaObject'; 2 | import AbstractPager from './AbstractPager'; 3 | 4 | export default class SimpleMediaPager extends AbstractPager { 5 | constructor(private readonly fetch: () => Promise) { 6 | super({pageSize: Infinity}); 7 | } 8 | 9 | fetchAt(index: 0): void; 10 | fetchAt(): void { 11 | if (!this.disconnected && !this.connected) { 12 | this.connect(); 13 | this.busy = true; 14 | this.fetch() 15 | .then((items) => { 16 | this.size = items.length; 17 | this.items = items; 18 | this.busy = false; 19 | }) 20 | .catch((error) => { 21 | this.error = error; 22 | this.busy = false; 23 | }); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/services/pagers/SubjectPager.ts: -------------------------------------------------------------------------------- 1 | import MediaObject from 'types/MediaObject'; 2 | import AbstractPager from './AbstractPager'; 3 | 4 | export default class SubjectPager extends AbstractPager { 5 | fetchAt(): void { 6 | // do nothing 7 | } 8 | 9 | async next(fetch: () => Promise): Promise { 10 | if (!this.disconnected) { 11 | this.busy = true; 12 | try { 13 | this.connect(); 14 | const items = await fetch(); 15 | this.size = items.length; 16 | this.items = items; 17 | } catch (err) { 18 | this.error = err; 19 | } 20 | this.busy = false; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/services/playlist/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './playlist'; 2 | export * from './playlist'; 3 | -------------------------------------------------------------------------------- /src/services/plex/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import mediaPlayer from 'services/mediaPlayback/mediaPlayer'; 2 | import plexRadioPlayer from './plexRadioPlayer'; 3 | 4 | mediaPlayer.registerPlayer(plexRadioPlayer, (item) => !!item?.src.startsWith('plex:radio:')); 5 | -------------------------------------------------------------------------------- /src/services/plex/components/PlexLogin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {LoginProps} from 'components/Login'; 3 | import DefaultLogin from 'components/Login/DefaultLogin'; 4 | import usePinRefresher from './usePinRefresher'; 5 | 6 | export default function PlexLogin({service: plex}: LoginProps) { 7 | usePinRefresher(); 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /src/services/plex/components/usePinRefresher.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {Subject, map, mergeMap, tap, timer} from 'rxjs'; 3 | import {refreshPin} from '../plexAuth'; 4 | 5 | export default function usePinRefresher() { 6 | useEffect(() => { 7 | const delay$ = new Subject(); 8 | const subscription = delay$ 9 | .pipe( 10 | mergeMap((delay) => timer(delay)), 11 | mergeMap(() => refreshPin()), 12 | map((pin) => pin.expiresIn * 1000), 13 | tap((delay) => delay$.next(delay)) 14 | ) 15 | .subscribe(); 16 | delay$.next(0); 17 | return () => subscription.unsubscribe(); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/services/plex/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './plex'; 2 | export * from './plex'; 3 | -------------------------------------------------------------------------------- /src/services/plex/plexItemType.ts: -------------------------------------------------------------------------------- 1 | enum plexItemType { 2 | Artist = 'artist', 3 | Album = 'album', 4 | Track = 'track', 5 | Clip = 'clip', 6 | Playlist = 'playlist', 7 | } 8 | 9 | export default plexItemType; 10 | -------------------------------------------------------------------------------- /src/services/plex/plexMediaType.ts: -------------------------------------------------------------------------------- 1 | enum plexMediaType { 2 | // Movie = '1', 3 | // Show = '2', 4 | // Season = '3', 5 | Episode = '4', 6 | // Trailer = '5', 7 | // Comic = '6', 8 | // Person = '7', 9 | Artist = '8', 10 | Album = '9', 11 | Track = '10', 12 | // PhotoAlbum = '11', 13 | MusicVideo = '12', // Or 'Picture'? 14 | // Photo = '13', 15 | Clip = '14', 16 | Playlist = '15', 17 | } 18 | 19 | export default plexMediaType; 20 | -------------------------------------------------------------------------------- /src/services/soundcloud/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './soundcloud'; 2 | -------------------------------------------------------------------------------- /src/services/soundcloud/soundcloud.ts: -------------------------------------------------------------------------------- 1 | import PublicMediaService from 'types/PublicMediaService'; 2 | import ServiceType from 'types/ServiceType'; 3 | import noAuth from 'services/mediaServices/noAuth'; 4 | 5 | const soundcloud: PublicMediaService = { 6 | ...noAuth(false), 7 | id: 'soundcloud', 8 | name: 'SoundCloud', 9 | icon: 'soundcloud', 10 | url: 'https://soundcloud.com', 11 | serviceType: ServiceType.PublicMedia, 12 | defaultHidden: true, 13 | iframeAudioPlayback: { 14 | showContent: true, 15 | isCoverArt: true, 16 | }, 17 | compareForRating: () => false, 18 | }; 19 | 20 | export default soundcloud; 21 | -------------------------------------------------------------------------------- /src/services/spotify/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import mediaPlayer from 'services/mediaPlayback/mediaPlayer'; 2 | import spotifyAudioAnalyser from './spotifyAudioAnalyser'; 3 | import spotifyPlayer from './spotifyPlayer'; 4 | 5 | spotifyAudioAnalyser.player = spotifyPlayer; 6 | 7 | mediaPlayer.registerPlayer(spotifyPlayer, (item) => !!item?.src.startsWith('spotify:')); 8 | -------------------------------------------------------------------------------- /src/services/spotify/components/useCredentials.ts: -------------------------------------------------------------------------------- 1 | import useObservable from 'hooks/useObservable'; 2 | import spotifySettings, {SpotifyCredentials} from '../spotifySettings'; 3 | 4 | export default function useCredentials(): SpotifyCredentials { 5 | return useObservable(spotifySettings.observeCredentials, spotifySettings.getCredentials()); 6 | } 7 | -------------------------------------------------------------------------------- /src/services/spotify/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './spotify'; 2 | export * from './spotify'; 3 | -------------------------------------------------------------------------------- /src/services/spotify/spotifyApi.ts: -------------------------------------------------------------------------------- 1 | import SpotifyWebApi from 'spotify-web-api-js'; 2 | 3 | const spotifyApi = new SpotifyWebApi(); 4 | 5 | export default spotifyApi; 6 | -------------------------------------------------------------------------------- /src/services/subsonic/airsonic.ts: -------------------------------------------------------------------------------- 1 | import SubsonicService from './factory/SubsonicService'; 2 | 3 | const airsonic = new SubsonicService( 4 | 'airsonic', 5 | 'Airsonic', 6 | 'https://github.com/airsonic-advanced/airsonic-advanced', 7 | 'Airsonic-Advanced' 8 | ); 9 | 10 | export default airsonic; 11 | -------------------------------------------------------------------------------- /src/services/subsonic/ampache.ts: -------------------------------------------------------------------------------- 1 | import SubsonicService from './factory/SubsonicService'; 2 | 3 | const ampache = new SubsonicService( 4 | 'ampache', 5 | 'Ampache', 6 | 'https://ampache.org', 7 | 'Ampache (Subsonic API)' 8 | ); 9 | 10 | export default ampache; 11 | -------------------------------------------------------------------------------- /src/services/subsonic/factory/components/SubsonicLoginDialog.scss: -------------------------------------------------------------------------------- 1 | .login-dialog { 2 | .icon-subsonic { 3 | filter: drop-shadow(1px 1px 0 rgba(16, 16, 16, 0.6)) !important; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/services/subsonic/gonic.ts: -------------------------------------------------------------------------------- 1 | import SubsonicService from './factory/SubsonicService'; 2 | 3 | const gonic = new SubsonicService('gonic', 'gonic', 'https://github.com/sentriz/gonic'); 4 | 5 | export default gonic; 6 | -------------------------------------------------------------------------------- /src/services/subsonic/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './subsonic'; 2 | -------------------------------------------------------------------------------- /src/services/subsonic/subsonic.ts: -------------------------------------------------------------------------------- 1 | import SubsonicService from './factory/SubsonicService'; 2 | 3 | const subsonic = new SubsonicService( 4 | 'subsonic', 5 | 'Subsonic', 6 | 'http://www.subsonic.org', 7 | 'Subsonic (or compatible)' 8 | ); 9 | 10 | export default subsonic; 11 | -------------------------------------------------------------------------------- /src/services/theme/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './theme'; 2 | export * from './theme'; 3 | -------------------------------------------------------------------------------- /src/services/theme/themes/astronaut.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Astronaut", 3 | "fontName": "Roboto", 4 | "backgroundColor": "#000000", 5 | "textColor": "#95a5d6", 6 | "frameColor": "#141415", 7 | "frameTextColor": "#b1bdcd", 8 | "selectedBackgroundColor": "#221650", 9 | "selectedTextColor": "#ffffff", 10 | "spacing": 0.72, 11 | "roundness": 0.57, 12 | "flat": false 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/blackgold.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Black Gold", 3 | "fontName": "Inter", 4 | "backgroundColor": "#0d0c0c", 5 | "textColor": "#a9aa92", 6 | "frameColor": "#1c1c12", 7 | "frameTextColor": "#e8e8bf", 8 | "selectedBackgroundColor": "#46462d", 9 | "selectedTextColor": "#ffffff", 10 | "spacing": 0.375, 11 | "roundness": 0.25, 12 | "flat": false 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/carbon.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Carbon", 3 | "fontName": "Muli", 4 | "backgroundColor": "#252429", 5 | "textColor": "#dbe5eb", 6 | "frameColor": "#2f2e33", 7 | "frameTextColor": "#d1d1d1", 8 | "selectedBackgroundColor": "#244b70", 9 | "selectedTextColor": "#ffffff", 10 | "scrollbarThickness": 1, 11 | "mediaButtonColor": "#a5adbb", 12 | "mediaButtonTextColor": "#000000", 13 | "spacing": 0.38, 14 | "roundness": 0.67, 15 | "flat": false 16 | } -------------------------------------------------------------------------------- /src/services/theme/themes/contrast.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Contrast", 3 | "fontName": "Verdana", 4 | "backgroundColor": "#051115", 5 | "textColor": "#ddeff3", 6 | "frameColor": "#c3c3c6", 7 | "frameTextColor": "#000000", 8 | "selectedBackgroundColor": "#1e25ae", 9 | "selectedTextColor": "#ffffff", 10 | "scrollbarColor": "#85858a", 11 | "scrollbarTextColor": "#000000", 12 | "scrollbarThickness": 1.33, 13 | "mediaButtonColor": "#202037", 14 | "mediaButtonTextColor": "#f8f8fc", 15 | "spacing": 0.75, 16 | "roundness": 0.85, 17 | "flat": true 18 | } -------------------------------------------------------------------------------- /src/services/theme/themes/debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Debug", 3 | "fontName": "Cursive", 4 | "backgroundColor": "#9dceec", 5 | "textColor": "#0e0425", 6 | "frameColor": "#00af4d", 7 | "frameTextColor": "#fbf6ac", 8 | "selectedBackgroundColor": "#ffcd03", 9 | "selectedTextColor": "#000000", 10 | "buttonColor": "#f57d20", 11 | "buttonTextColor": "#000000", 12 | "scrollbarColor": "#dd1a21", 13 | "scrollbarTextColor": "#fff579", 14 | "mediaButtonColor": "#f4f4f4", 15 | "mediaButtonTextColor": "#181818", 16 | "spacing": 0.38, 17 | "roundness": 0.48, 18 | "flat": false 19 | } -------------------------------------------------------------------------------- /src/services/theme/themes/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "(boring default)", 3 | "fontName": "Arial", 4 | "backgroundColor": "#0d0f12", 5 | "textColor": "#8facbc", 6 | "frameColor": "#32312f", 7 | "frameTextColor": "#d1d1d1", 8 | "selectedBackgroundColor": "#163d5a", 9 | "selectedTextColor": "#ebebeb", 10 | "spacing": 0.38, 11 | "roundness": 0.48, 12 | "flat": false 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/glacier.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Glacier", 3 | "fontName": "Inter", 4 | "backgroundColor": "#d9e6e8", 5 | "textColor": "#08191b", 6 | "frameColor": "#bfd4e8", 7 | "frameTextColor": "#1c1c12", 8 | "selectedBackgroundColor": "#ffffff", 9 | "selectedTextColor": "#04030c", 10 | "spacing": 0.65, 11 | "roundness": 0.3, 12 | "flat": false 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/indigo.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Indigo", 3 | "fontName": "Poppins", 4 | "backgroundColor": "#06181e", 5 | "textColor": "#d1d3f5", 6 | "frameColor": "#43477a", 7 | "frameTextColor": "#f3f3f7", 8 | "selectedBackgroundColor": "#4a437a", 9 | "selectedTextColor": "#ffffff", 10 | "spacing": 0.625, 11 | "roundness": 0.84375, 12 | "flat": true 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/jeep.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jeep", 3 | "fontName": "Overpass", 4 | "backgroundColor": "#101202", 5 | "textColor": "#8ebf88", 6 | "frameColor": "#1c2103", 7 | "frameTextColor": "#cbcdb1", 8 | "selectedBackgroundColor": "#362802", 9 | "selectedTextColor": "#ffffff", 10 | "scrollbarThickness": 1, 11 | "spacing": 0.72, 12 | "roundness": 0.84, 13 | "flat": false 14 | } -------------------------------------------------------------------------------- /src/services/theme/themes/lego.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "LEGO", 3 | "fontName": "Open Sans", 4 | "backgroundColor": "#fcfcfc", 5 | "textColor": "#152332", 6 | "frameColor": "#1b80f1", 7 | "frameTextColor": "#ffffff", 8 | "selectedBackgroundColor": "#fc2420", 9 | "selectedTextColor": "#ffffff", 10 | "mediaButtonColor": "#f4c522", 11 | "mediaButtonTextColor": "#12100d", 12 | "spacing": 0.53, 13 | "roundness": 0, 14 | "flat": true 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/mellowyellow.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mellow Yellow", 3 | "fontName": "Poppins", 4 | "backgroundColor": "#f5f2d1", 5 | "textColor": "#1c1302", 6 | "frameColor": "#fef4c3", 7 | "frameTextColor": "#513506", 8 | "selectedBackgroundColor": "#fdeb91", 9 | "selectedTextColor": "#000000", 10 | "mediaButtonColor": "#fffae1", 11 | "mediaButtonTextColor": "#513506", 12 | "spacing": 0.32, 13 | "roundness": 0.29, 14 | "flat": true 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/moodyblue.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Moody Blue", 3 | "fontName": "Inter", 4 | "backgroundColor": "#051a23", 5 | "textColor": "#bec1c0", 6 | "frameColor": "#125673", 7 | "frameTextColor": "#d8eee7", 8 | "selectedBackgroundColor": "#124373", 9 | "selectedTextColor": "#ffffff", 10 | "spacing": 0.5, 11 | "roundness": 1, 12 | "flat": false 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/neon.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Neon", 3 | "fontName": "Roboto", 4 | "backgroundColor": "#010123", 5 | "textColor": "#7edbdd", 6 | "frameColor": "#610a51", 7 | "frameTextColor": "#fec3f3", 8 | "selectedBackgroundColor": "#28449a", 9 | "selectedTextColor": "#ffffff", 10 | "mediaButtonColor": "#f2d202", 11 | "mediaButtonTextColor": "#160914", 12 | "spacing": 0.59, 13 | "roundness": 0.62, 14 | "flat": false 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/notebook.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Notebook", 3 | "fontName": "Muli", 4 | "backgroundColor": "#f7f7f7", 5 | "textColor": "#150e0e", 6 | "frameColor": "#d1d1d1", 7 | "frameTextColor": "#121111", 8 | "selectedBackgroundColor": "#feffbd", 9 | "selectedTextColor": "#000000", 10 | "mediaButtonColor": "#c5e0e7", 11 | "mediaButtonTextColor": "#1b121c", 12 | "spacing": 0.48, 13 | "roundness": 0.14, 14 | "flat": true 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/palepink.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pale Pink", 3 | "fontName": "Muli", 4 | "backgroundColor": "#f7f7f7", 5 | "textColor": "#150e0e", 6 | "frameColor": "#cfbdd0", 7 | "frameTextColor": "#121111", 8 | "selectedBackgroundColor": "#b9d8f3", 9 | "selectedTextColor": "#000000", 10 | "spacing": 0.375, 11 | "roundness": 0.53125, 12 | "flat": true 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/potpourri.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Potpourri", 3 | "fontName": "Bricolage Grotesque", 4 | "backgroundColor": "#f9f8dd", 5 | "textColor": "#000000", 6 | "frameColor": "#beaee0", 7 | "frameTextColor": "#000000", 8 | "selectedBackgroundColor": "#fcccc2", 9 | "selectedTextColor": "#000000", 10 | "mediaButtonColor": "#fcccc2", 11 | "mediaButtonTextColor": "#000000", 12 | "spacing": 0.38, 13 | "roundness": 0.67, 14 | "flat": true 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/proton.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Proton", 3 | "fontName": "System UI", 4 | "backgroundColor": "#120f0c", 5 | "textColor": "#c4c7b8", 6 | "frameColor": "#121211", 7 | "frameTextColor": "#72bba4", 8 | "selectedBackgroundColor": "#27493f", 9 | "selectedTextColor": "#e6e8de", 10 | "mediaButtonColor": "#34836a", 11 | "mediaButtonTextColor": "#10100b", 12 | "buttonColor": "#292924", 13 | "buttonTextColor": "#72bba4", 14 | "scrollbarThickness": 0.67, 15 | "spacing": 0.375, 16 | "roundness": 0.53125, 17 | "flat": true 18 | } -------------------------------------------------------------------------------- /src/services/theme/themes/purplelicious.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Purplelicious (Winamp)", 3 | "fontName": "Arial", 4 | "backgroundColor": "#000000", 5 | "textColor": "#ffb464", 6 | "frameColor": "#3e0445", 7 | "frameTextColor": "#ffb464", 8 | "selectedBackgroundColor": "#3e0445", 9 | "selectedTextColor": "#ffb464", 10 | "spacing": 0.21875, 11 | "roundness": 0.09375, 12 | "flat": false 13 | } 14 | -------------------------------------------------------------------------------- /src/services/theme/themes/radioactive.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Radioactive", 3 | "fontName": "Ubuntu", 4 | "backgroundColor": "#0f0f10", 5 | "textColor": "#d1f5d2", 6 | "frameColor": "#322724", 7 | "frameTextColor": "#c3feca", 8 | "selectedBackgroundColor": "#43304b", 9 | "selectedTextColor": "#ffffff", 10 | "mediaButtonColor": "#c3feca", 11 | "mediaButtonTextColor": "#021c05", 12 | "spacing": 0.73, 13 | "roundness": 0.86, 14 | "flat": false 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/saddle.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Saddle", 3 | "fontName": "Bricolage Grotesque", 4 | "backgroundColor": "#271607", 5 | "textColor": "#cbbe86", 6 | "frameColor": "#331d0a", 7 | "frameTextColor": "#fedfc3", 8 | "selectedBackgroundColor": "#5d3512", 9 | "selectedTextColor": "#ffffff", 10 | "spacing": 0.375, 11 | "roundness": 0.34375, 12 | "flat": false 13 | } -------------------------------------------------------------------------------- /src/services/theme/themes/treasure.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Treasure", 3 | "fontName": "Nunito Sans", 4 | "backgroundColor": "#040323", 5 | "textColor": "#dee5e8", 6 | "frameColor": "#64211c", 7 | "frameTextColor": "#e4d8d8", 8 | "selectedBackgroundColor": "#373c0c", 9 | "selectedTextColor": "#ffffff", 10 | "mediaButtonColor": "#d1ca10", 11 | "mediaButtonTextColor": "#12100d", 12 | "spacing": 0.43, 13 | "roundness": 0.69, 14 | "flat": false 15 | } -------------------------------------------------------------------------------- /src/services/theme/themes/velvet.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Velvet", 3 | "fontName": "Lato", 4 | "backgroundColor": "#160916", 5 | "textColor": "#c2d7ea", 6 | "frameColor": "#280b1f", 7 | "frameTextColor": "#7de8e6", 8 | "mediaButtonColor": "#ccae14", 9 | "mediaButtonTextColor": "#0d0b04", 10 | "selectedBackgroundColor": "#412438", 11 | "selectedTextColor": "#ebebeb", 12 | "scrollbarTextColor": "#d7bd3c", 13 | "spacing": 0.85, 14 | "roundness": 0.67, 15 | "scrollbarThickness": 1.33, 16 | "flat": true 17 | } -------------------------------------------------------------------------------- /src/services/theme/themes/winamp-classic.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Winamp Classic", 3 | "fontName": "Arial", 4 | "backgroundColor": "#000000", 5 | "textColor": "#00e200", 6 | "frameColor": "#383757", 7 | "frameTextColor": "#d2d2d2", 8 | "buttonColor": "#464379", 9 | "buttonTextColor": "#d2d2d2", 10 | "mediaButtonColor": "#cccccc", 11 | "mediaButtonTextColor": "#111111", 12 | "selectedBackgroundColor": "#040dae", 13 | "selectedTextColor": "#d2d2d2", 14 | "spacing": 0.21875, 15 | "roundness": 0.09375, 16 | "scrollbarThickness": 0.67, 17 | "flat": false 18 | } 19 | -------------------------------------------------------------------------------- /src/services/theme/themes/winamp-modern.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Winamp Modern (WACUP)", 3 | "fontName": "Inter", 4 | "backgroundColor": "#14121c", 5 | "textColor": "#8ac440", 6 | "frameColor": "#1f1f36", 7 | "frameTextColor": "#f0f0f0", 8 | "mediaButtonColor": "#cccccc", 9 | "mediaButtonTextColor": "#111111", 10 | "selectedBackgroundColor": "#2f2f76", 11 | "selectedTextColor": "#f0f0f0", 12 | "spacing": 0.21875, 13 | "roundness": 0.09375, 14 | "flat": false 15 | } 16 | -------------------------------------------------------------------------------- /src/services/tidal/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import mediaPlayer from 'services/mediaPlayback/mediaPlayer'; 2 | import tidalPlayer from './tidalPlayer'; 3 | 4 | mediaPlayer.registerPlayer(tidalPlayer, (item) => !!item?.src.startsWith('tidal:')); 5 | -------------------------------------------------------------------------------- /src/services/tidal/components/useCredentials.ts: -------------------------------------------------------------------------------- 1 | import useObservable from 'hooks/useObservable'; 2 | import tidalSettings, {TidalCredentials} from '../tidalSettings'; 3 | 4 | export default function useCredentials(): TidalCredentials { 5 | return useObservable(tidalSettings.observeCredentials, tidalSettings.getCredentials()); 6 | } 7 | -------------------------------------------------------------------------------- /src/services/tidal/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './tidal'; 2 | export * from './tidal'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/ambientvideo/ambientvideo.ts: -------------------------------------------------------------------------------- 1 | import VisualizerProvider from 'types/VisualizerProvider'; 2 | import {AmbientVideoVisualizer} from 'types/Visualizer'; 3 | import AmbientVideoPlayer from './AmbientVideoPlayer'; 4 | import {getVisualizers, observeVisualizers} from './visualizers'; 5 | 6 | const ambientvideo: VisualizerProvider = { 7 | id: 'ambientvideo', 8 | name: 'Ambient Video', 9 | defaultHidden: true, 10 | get visualizers() { 11 | return getVisualizers(); 12 | }, 13 | observeVisualizers, 14 | createPlayer(audio) { 15 | if (!this.player) { 16 | (this as any).player = new AmbientVideoPlayer(audio); 17 | } 18 | return this.player!; 19 | }, 20 | }; 21 | 22 | export default ambientvideo; 23 | -------------------------------------------------------------------------------- /src/services/visualizer/ambientvideo/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './ambientvideo'; 2 | export * from './ambientvideo'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/ampshader.ts: -------------------------------------------------------------------------------- 1 | import VisualizerProvider from 'types/VisualizerProvider'; 2 | import {AmpShaderVisualizer} from 'types/Visualizer'; 3 | import AmpShaderPlayer from './AmpShaderPlayer'; 4 | import {getVisualizers, observeVisualizers} from './visualizers'; 5 | 6 | const ampshader: VisualizerProvider = { 7 | id: 'ampshader', 8 | name: 'ampshader', 9 | get visualizers() { 10 | return getVisualizers(); 11 | }, 12 | observeVisualizers, 13 | createPlayer(audio) { 14 | if (!this.player) { 15 | (this as any).player = new AmpShaderPlayer(audio, 'main'); 16 | } 17 | return this.player!; 18 | }, 19 | }; 20 | 21 | export default ampshader; 22 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/footer.frag: -------------------------------------------------------------------------------- 1 | out vec4 as_FragColor; 2 | void main() { 3 | vec4 color = vec4(1e20); 4 | mainImage(color, gl_FragCoord.xy); 5 | as_FragColor = color; 6 | } 7 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/header.frag: -------------------------------------------------------------------------------- 1 | #version 300 es 2 | #ifdef GL_OES_standard_derivatives 3 | #extension GL_OES_standard_derivatives : enable 4 | #endif 5 | #ifdef GL_ES 6 | precision highp float; 7 | precision highp int; 8 | precision mediump sampler3D; 9 | #endif 10 | uniform vec3 iBackgroundColor; 11 | uniform vec3 iBlackColor; 12 | uniform vec3 iColor; 13 | uniform vec3 iFrameColor; 14 | uniform vec3 iResolution; 15 | uniform float iTime; 16 | uniform float iTimeDelta; 17 | uniform int iFrame; 18 | uniform float iChannelTime[4]; 19 | uniform vec3 iMouse; 20 | uniform sampler2D iChannel0; 21 | uniform sampler2D iChannel1; 22 | uniform sampler2D iChannel2; 23 | uniform sampler2D iChannel3; 24 | uniform vec4 iDate; 25 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './ampshader'; 2 | export * from './ampshader'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/visualizers/creation.frag: -------------------------------------------------------------------------------- 1 | // https://noisehack.com/build-music-visualizer-web-audio-api/ (adapted from) 2 | // https://www.shadertoy.com/view/XsXXDn 3 | // http://www.pouet.net/prod.php?which=57245 4 | // If you intend to reuse this shader, please add credits to 'Danilo Guanabara' 5 | 6 | void mainImage( out vec4 fragColor, in vec2 fragCoord ){ 7 | vec3 c; 8 | float z = 0.1 * iTime; 9 | vec2 uv = fragCoord.xy / iResolution.xy; 10 | vec2 p = uv - 0.5; 11 | p.x *= iResolution.x / iResolution.y; 12 | float l = 0.2 * length(p); 13 | for (int i = 0; i < 3; i++) { 14 | z += 0.07; 15 | uv += p / l * (sin(z) + 1.0) * abs(sin(l * 9.0 - z * 2.0)); 16 | c[i] = 0.01 / length(abs(mod(uv, 1.0) - 0.5)); 17 | } 18 | float intensity = texture(iChannel0, vec2(l, 0.5)).x; 19 | fragColor = vec4(c / l * intensity, iTime); 20 | } 21 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/visualizers/index.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import {BehaviorSubject} from 'rxjs'; 3 | import {AmpShaderVisualizer} from 'types/Visualizer'; 4 | 5 | const visualizers$ = new BehaviorSubject([]); 6 | 7 | export function getVisualizers(): readonly AmpShaderVisualizer[] { 8 | return visualizers$.value; 9 | } 10 | 11 | export function observeVisualizers(): Observable { 12 | return visualizers$; 13 | } 14 | 15 | async function loadVisualizers(): Promise { 16 | const {default: presets} = await import( 17 | /* webpackChunkName: "ampshader-presets" */ 18 | /* webpackMode: "lazy-once" */ 19 | './presets' 20 | ); 21 | visualizers$.next(presets); 22 | } 23 | 24 | loadVisualizers(); 25 | -------------------------------------------------------------------------------- /src/services/visualizer/ampshader/visualizers/soundSinusWave.frag: -------------------------------------------------------------------------------- 1 | // https://www.shadertoy.com/view/XsX3zS 2 | #define WAVES 8.0 3 | 4 | void mainImage( out vec4 fragColor, in vec2 fragCoord ) { 5 | vec2 uv = -1.0 + 2.0 * fragCoord.xy / iResolution.xy; 6 | 7 | float time = iTime * 1.0; 8 | 9 | vec3 color = vec3(0.0); 10 | 11 | for (float i=0.0; i 0.5) 6 | { 7 | uv -= 0.5; 8 | uv *= 2.; 9 | } 10 | else 11 | { 12 | uv *= 2.; 13 | uv = 1. - uv; 14 | } 15 | 16 | float fft = texture( iChannel0, vec2(uv.x,0.25) ).x; 17 | float dr = length(uv); 18 | float radius = 1.8; 19 | 20 | vec3 col = vec3(0.); 21 | if( abs(uv.y) = { 8 | id: 'audiomotion', 9 | name: 'audioMotion-analyzer', 10 | shortName: 'audioMotion', 11 | externalUrl: 'https://audiomotion.dev/', 12 | visualizers, 13 | observeVisualizers: () => of(visualizers), 14 | createPlayer(audio) { 15 | if (!this.player) { 16 | (this as any).player = new AudioMotionPlayer(audio); 17 | } 18 | return this.player!; 19 | }, 20 | }; 21 | 22 | export default audiomotion; 23 | -------------------------------------------------------------------------------- /src/services/visualizer/audiomotion/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './audiomotion'; 2 | export * from './audiomotion'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/butterchurn/butterchurn.ts: -------------------------------------------------------------------------------- 1 | import VisualizerProvider from 'types/VisualizerProvider'; 2 | import {ButterchurnVisualizer} from 'types/Visualizer'; 3 | import ButterchurnPlayer from './ButterchurnPlayer'; 4 | import {getVisualizers, observeVisualizers} from './visualizers'; 5 | 6 | const butterchurn: VisualizerProvider = { 7 | id: 'butterchurn', 8 | name: 'Butterchurn (Milkdrop)', 9 | shortName: 'Butterchurn', 10 | externalUrl: 'https://butterchurnviz.com/', 11 | get visualizers() { 12 | return getVisualizers(); 13 | }, 14 | observeVisualizers, 15 | createPlayer(audio) { 16 | if (!this.player) { 17 | (this as any).player = new ButterchurnPlayer(audio); 18 | } 19 | return this.player!; 20 | }, 21 | }; 22 | 23 | export default butterchurn; 24 | -------------------------------------------------------------------------------- /src/services/visualizer/butterchurn/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './butterchurn'; 2 | export * from './butterchurn'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/coverart/AnimatedBackgroundPlayer.ts: -------------------------------------------------------------------------------- 1 | import AudioManager from 'types/AudioManager'; 2 | import {AmpShaderVisualizer} from 'types/Visualizer'; 3 | import AmpShaderPlayer from '../ampshader/AmpShaderPlayer'; 4 | import animatedBackground from './animatedBackground.frag'; 5 | 6 | export default class AnimatedBackgroundPlayer extends AmpShaderPlayer { 7 | private readonly visualizer: AmpShaderVisualizer = { 8 | providerId: 'ampshader', 9 | name: 'Foamy water by k_mouse', 10 | shader: animatedBackground, 11 | externalUrl: 'https://www.shadertoy.com/view/llcXW7', 12 | }; 13 | 14 | constructor(audio: AudioManager) { 15 | super(audio, 'animated-background'); 16 | } 17 | 18 | load(): void { 19 | super.load(this.visualizer); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/services/visualizer/coverart/components/useNextTrack.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import PlaylistItem from 'types/PlaylistItem'; 3 | import {observeNextItem, getNextItem} from 'services/playlist'; 4 | 5 | export default function useNextTrack(): PlaylistItem | null { 6 | const [value, setValue] = useState(getNextItem()); 7 | 8 | useEffect(() => { 9 | const subscription = observeNextItem().subscribe(setValue); 10 | return () => subscription.unsubscribe(); 11 | }, []); 12 | 13 | return value; 14 | } 15 | -------------------------------------------------------------------------------- /src/services/visualizer/coverart/coverart.ts: -------------------------------------------------------------------------------- 1 | import {of} from 'rxjs'; 2 | import VisualizerProvider from 'types/VisualizerProvider'; 3 | import {CoverArtVisualizer} from 'types/Visualizer'; 4 | import CovertArtPlayer from './CovertArtPlayer'; 5 | import visualizers from './visualizers'; 6 | 7 | const coverart: VisualizerProvider = { 8 | id: 'coverart', 9 | name: 'Cover Art', 10 | visualizers, 11 | observeVisualizers: () => of(visualizers), 12 | createPlayer(audio) { 13 | if (!this.player) { 14 | (this as any).player = new CovertArtPlayer(audio); 15 | } 16 | return this.player!; 17 | }, 18 | }; 19 | 20 | export default coverart; 21 | -------------------------------------------------------------------------------- /src/services/visualizer/coverart/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './coverart'; 2 | export * from './coverart'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/coverart/visualizers/index.ts: -------------------------------------------------------------------------------- 1 | import {CoverArtVisualizer} from 'types/Visualizer'; 2 | import {default as DefaultVisualizer} from '../components/CoverArtVisualizer'; 3 | 4 | const defaultVisualizer: CoverArtVisualizer = { 5 | providerId: 'coverart', 6 | name: '', 7 | component: DefaultVisualizer, 8 | }; 9 | 10 | const visualizers: CoverArtVisualizer[] = [defaultVisualizer]; 11 | 12 | export default visualizers; 13 | -------------------------------------------------------------------------------- /src/services/visualizer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './visualizer'; 2 | export {observeVisualizerSettings} from './visualizerSettings'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/spotifyviz/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './spotifyviz'; 2 | export * from './spotifyviz'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/spotifyviz/spotifyviz.ts: -------------------------------------------------------------------------------- 1 | import {of} from 'rxjs'; 2 | import VisualizerProvider from 'types/VisualizerProvider'; 3 | import {SpotifyVizVisualizer} from 'types/Visualizer'; 4 | import SpotifyVizPlayer from './SpotifyVizPlayer'; 5 | import visualizers from './visualizers'; 6 | 7 | const spotifyviz: VisualizerProvider = { 8 | id: 'spotifyviz', 9 | name: 'spotify-viz', 10 | // 404 now (used to work) 11 | // externalUrl: 'https://github.com/zachwinter/spotify-viz/', 12 | visualizers, 13 | observeVisualizers: () => of(visualizers), 14 | createPlayer() { 15 | if (!this.player) { 16 | (this as any).player = new SpotifyVizPlayer(); 17 | } 18 | return this.player!; 19 | }, 20 | }; 21 | 22 | export default spotifyviz; 23 | -------------------------------------------------------------------------------- /src/services/visualizer/spotifyviz/visualizers/index.ts: -------------------------------------------------------------------------------- 1 | import {SpotifyVizVisualizer} from 'types/Visualizer'; 2 | import example from './example'; 3 | 4 | const visualizers: SpotifyVizVisualizer[] = [ 5 | { 6 | providerId: 'spotifyviz', 7 | name: 'example by zachwinter', 8 | config: example, 9 | // Dead link. 10 | // externalUrl: 'https://github.com/zachwinter/spotify-viz/blob/master/client/example.js', 11 | }, 12 | ]; 13 | 14 | export default visualizers; 15 | -------------------------------------------------------------------------------- /src/services/visualizer/visualizers.ts: -------------------------------------------------------------------------------- 1 | import VisualizerProvider from 'types/VisualizerProvider'; 2 | import ambientvideo from './ambientvideo'; 3 | import ampshader from './ampshader'; 4 | import audiomotion from './audiomotion'; 5 | import butterchurn from './butterchurn'; 6 | import coverart from './coverart'; 7 | import spotifyviz from './spotifyviz'; 8 | import waveform from './waveform'; 9 | 10 | const visualizers: VisualizerProvider[] = [ 11 | ambientvideo, 12 | ampshader, 13 | audiomotion, 14 | butterchurn, 15 | coverart, 16 | spotifyviz, 17 | waveform 18 | ]; 19 | 20 | export default visualizers; 21 | -------------------------------------------------------------------------------- /src/services/visualizer/waveform/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './waveform'; 2 | export * from './waveform'; 3 | -------------------------------------------------------------------------------- /src/services/visualizer/waveform/waveform.ts: -------------------------------------------------------------------------------- 1 | import {of} from 'rxjs'; 2 | import VisualizerProvider from 'types/VisualizerProvider'; 3 | import {WaveformVisualizer} from 'types/Visualizer'; 4 | import WaveformPlayer from './WaveformPlayer'; 5 | import visualizers from './visualizers'; 6 | 7 | const waveform: VisualizerProvider = { 8 | id: 'waveform', 9 | name: 'Waveform', 10 | externalUrl: 11 | 'https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API', 12 | visualizers, 13 | observeVisualizers: () => of(visualizers), 14 | createPlayer(audio) { 15 | if (!this.player) { 16 | (this as any).player = new WaveformPlayer(audio, 'main'); 17 | } 18 | return this.player!; 19 | }, 20 | }; 21 | 22 | export default waveform; 23 | -------------------------------------------------------------------------------- /src/services/youtube/components/useCredentials.ts: -------------------------------------------------------------------------------- 1 | import useObservable from 'hooks/useObservable'; 2 | import youtubeSettings, {YouTubeCredentials} from '../youtubeSettings'; 3 | 4 | export default function useCredentials(): YouTubeCredentials { 5 | return useObservable(youtubeSettings.observeCredentials, youtubeSettings.getCredentials()); 6 | } 7 | -------------------------------------------------------------------------------- /src/services/youtube/components/useGoogleClientLibrary.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {defer} from 'rxjs'; 3 | import {getGsiClient} from '../youtubeAuth'; 4 | 5 | export default function useGoogleClientLibrary() { 6 | const [client, setClient] = useState(null); 7 | const [error, setError] = useState(null); 8 | 9 | useEffect(() => { 10 | const subscription = defer(() => getGsiClient()).subscribe({ 11 | next: setClient, 12 | error: setError, 13 | }); 14 | return () => subscription.unsubscribe(); 15 | }, []); 16 | 17 | return {client, error}; 18 | } 19 | -------------------------------------------------------------------------------- /src/services/youtube/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './youtube'; 2 | export * from './youtube'; 3 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @use './reset.scss'; 2 | @use './theming.scss'; 3 | @use './base.scss'; 4 | @use './forms.scss'; 5 | -------------------------------------------------------------------------------- /src/styles/reset.scss: -------------------------------------------------------------------------------- 1 | @use './libs/minireset.scss'; 2 | 3 | *:focus, 4 | *:focus-visible { 5 | outline: none; 6 | } 7 | 8 | html, body { 9 | overflow: hidden; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/Action.ts: -------------------------------------------------------------------------------- 1 | const enum Action { 2 | Pin = 'pin', 3 | Unpin = 'unpin', 4 | Like = 'like', 5 | Unlike = 'unlike', 6 | Rate = 'rate', 7 | Info = 'info', 8 | AddToLibrary = 'library-add', 9 | RemoveFromLibrary = 'library-remove', 10 | PlayNow = 'play-now', 11 | PlayNext = 'play-next', 12 | Queue = 'queue', 13 | AddToPlaylist = 'add-to-playlist', 14 | AddToNewPlaylist = 'add-to-new-playlist', 15 | AddToRecentPlaylist1 = 'add-to-recent-playlist-1', 16 | AddToRecentPlaylist2 = 'add-to-recent-playlist-2', 17 | AddToRecentPlaylist3 = 'add-to-recent-playlist-3', 18 | AddToRecentPlaylist4 = 'add-to-recent-playlist-4', 19 | } 20 | 21 | export default Action; 22 | -------------------------------------------------------------------------------- /src/types/AmpcastElectron.d.ts: -------------------------------------------------------------------------------- 1 | export default interface AmpcastElectron { 2 | getCredential(key: string): Promise; 3 | setCredential(key: string, value: string): Promise; 4 | clearCredentials(): Promise; 5 | setFontSize(fontSize: number): void; 6 | setFrameColor(color: string): void; 7 | setFrameTextColor(color: string): void; 8 | getPreferredPort(): Promise; 9 | setPreferredPort(port: number): Promise; 10 | quit(): void; 11 | } 12 | -------------------------------------------------------------------------------- /src/types/AudioManager.d.ts: -------------------------------------------------------------------------------- 1 | export default interface AudioManager { 2 | readonly context: AudioContext; 3 | readonly replayGain: number; 4 | readonly source: AudioNode; 5 | readonly streamingSupported: boolean; 6 | volume: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/AudioSettings.d.ts: -------------------------------------------------------------------------------- 1 | export default interface AudioSettings { 2 | replayGainMode: ReplayGainMode; 3 | replayGainPreAmp: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/Auth.d.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | 3 | export default interface Auth { 4 | observeIsLoggedIn(this: unknown): Observable; 5 | isConnected(this: unknown): boolean; 6 | isLoggedIn(this: unknown): boolean; 7 | login(this: unknown): Promise; 8 | logout(this: unknown): Promise; 9 | // Everything below here should be optional. 10 | noAuth?: boolean; 11 | observeConnectionLogging?(this: unknown): Observable; 12 | reconnect?(this: unknown): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/BaseVisualizer.d.ts: -------------------------------------------------------------------------------- 1 | import Thumbnail from './Thumbnail'; 2 | import VisualizerProviderId from './VisualizerProviderId'; 3 | 4 | export default interface BaseVisualizer { 5 | readonly providerId: VisualizerProviderId; 6 | readonly name: string; 7 | // Everything below here should be optional. 8 | readonly title?: string; // Readable title 9 | readonly externalUrl?: string; 10 | readonly thumbnails?: Thumbnail[]; 11 | readonly duration?: number; 12 | readonly spotifyExcluded?: boolean; 13 | readonly opaque?: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/Browsable.d.ts: -------------------------------------------------------------------------------- 1 | import {SetRequired} from 'type-fest'; 2 | import MediaService from './MediaService'; 3 | 4 | type Browsable = SetRequired; 5 | 6 | export default Browsable; 7 | -------------------------------------------------------------------------------- /src/types/BuildConfig.d.ts: -------------------------------------------------------------------------------- 1 | import MediaService from './MediaService'; 2 | import MediaServiceId, {PersonalMediaServiceId} from './MediaServiceId'; 3 | import PersonalMediaService from './PersonalMediaService'; 4 | 5 | export default interface BuildConfig { 6 | readonly enabledServices: readonly string[]; 7 | getServerHost(service: PersonalMediaService): string; 8 | getServerHost(serviceId: PersonalMediaServiceId): string; 9 | hasProxyLogin(service: PersonalMediaService): boolean; 10 | hasProxyLogin(serviceId: PersonalMediaServiceId): boolean; 11 | isStartupService(service: MediaService): boolean; 12 | isStartupService(serviceId: MediaServiceId): boolean; 13 | isServiceDisabled(service: MediaService): boolean; 14 | isServiceDisabled(serviceId: MediaServiceId): boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/ChildOf.d.ts: -------------------------------------------------------------------------------- 1 | import MediaAlbum from './MediaAlbum'; 2 | import MediaArtist from './MediaArtist'; 3 | import MediaFolder from './MediaFolder'; 4 | import MediaFolderItem from './MediaFolderItem'; 5 | import MediaItem from './MediaItem'; 6 | import MediaObject from './MediaObject'; 7 | import MediaPlaylist from './MediaPlaylist'; 8 | 9 | type ChildOf = T extends MediaArtist 10 | ? MediaAlbum 11 | : T extends MediaFolder 12 | ? MediaFolderItem 13 | : T extends MediaAlbum | MediaPlaylist 14 | ? MediaItem 15 | : never; 16 | 17 | export default ChildOf; 18 | -------------------------------------------------------------------------------- /src/types/CreatePlaylistOptions.d.ts: -------------------------------------------------------------------------------- 1 | import MediaItem from './MediaItem'; 2 | 3 | export default interface CreatePlaylistOptions { 4 | description?: string; 5 | items?: readonly T[]; 6 | isPublic?: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/DRMInfo.d.ts: -------------------------------------------------------------------------------- 1 | import DRMKeySystem from './DRMKeySystem'; 2 | import DRMType from './DRMType'; 3 | 4 | export default interface DRMInfo { 5 | readonly type: DRMType; 6 | readonly keySystem: DRMKeySystem; 7 | readonly certificate?: string; 8 | readonly license?: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/DRMKeySystem.d.ts: -------------------------------------------------------------------------------- 1 | type DRMKeySystem = 'com.widevine.alpha' | 'com.apple.fairplay' | 'com.microsoft.playready'; 2 | 3 | export default DRMKeySystem; 4 | -------------------------------------------------------------------------------- /src/types/DRMType.d.ts: -------------------------------------------------------------------------------- 1 | type DRMType = 'widevine' | 'fairplay' | 'playready'; 2 | 3 | export default DRMType; 4 | -------------------------------------------------------------------------------- /src/types/DataService.ts: -------------------------------------------------------------------------------- 1 | import BaseMediaService from './BaseMediaService'; 2 | import ServiceType from './ServiceType'; 3 | 4 | export default interface DataService extends BaseMediaService { 5 | readonly serviceType: ServiceType.DataService; 6 | // Everything below here should be optional. 7 | readonly canScrobble?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/ErrorReport.d.ts: -------------------------------------------------------------------------------- 1 | import Snapshot from './Snapshot'; 2 | 3 | export default interface ErrorReport { 4 | readonly reportedBy: string; 5 | readonly reportingId: string; // id of logger or `MediaSource` id 6 | readonly error: { 7 | readonly message: string; 8 | readonly httpStatus?: number; 9 | readonly httpStatusText?: string; 10 | readonly stack: string[]; 11 | } | null; 12 | readonly snapshot: Snapshot; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/FilterType.ts: -------------------------------------------------------------------------------- 1 | const enum FilterType { 2 | ByCountry = 0, 3 | ByDecade = 1, 4 | ByGenre = 2, 5 | ByMood = 3, 6 | ByStyle = 4, 7 | ByAppleStationGenre = 101, 8 | ByPlexStationType = 102, 9 | } 10 | 11 | export default FilterType; 12 | -------------------------------------------------------------------------------- /src/types/ItemType.ts: -------------------------------------------------------------------------------- 1 | const enum ItemType { 2 | Media = 0, 3 | Album = 1, 4 | Artist = 2, 5 | Playlist = 3, 6 | Folder = 4, 7 | } 8 | 9 | export default ItemType; 10 | -------------------------------------------------------------------------------- /src/types/ItemsByService.d.ts: -------------------------------------------------------------------------------- 1 | import MediaItem from './MediaItem'; 2 | import MediaService from './MediaService'; 3 | 4 | export default interface ItemsByService { 5 | readonly service: MediaService; 6 | readonly items: readonly T[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/LibraryAction.d.ts: -------------------------------------------------------------------------------- 1 | import Action from './Action'; 2 | 3 | type LibraryAction = Extract< 4 | Action, 5 | Action.AddToLibrary | Action.RemoveFromLibrary | Action.Rate | Action.Like | Action.Unlike 6 | >; 7 | 8 | export default LibraryAction; 9 | -------------------------------------------------------------------------------- /src/types/LinearType.ts: -------------------------------------------------------------------------------- 1 | const enum LinearType { 2 | NonLinear = 0, 3 | OnDemand = NonLinear, 4 | Station = 1, // Radio/TV station/channel 5 | Show = 10, // Radio/TV programme (no music metadata) 6 | MusicTrack = 20, // Music track (or music video) 7 | Ad = 99, // Commercial break 8 | OffAir = -1, 9 | } 10 | 11 | export default LinearType; 12 | -------------------------------------------------------------------------------- /src/types/Listen.d.ts: -------------------------------------------------------------------------------- 1 | import MediaItem from './MediaItem'; 2 | 3 | export interface ListenData { 4 | readonly sessionId: string; 5 | readonly lastfmScrobbledAt: number; 6 | readonly listenbrainzScrobbledAt: number; 7 | } 8 | 9 | type Listen = MediaItem & ListenData; 10 | 11 | export default Listen; 12 | -------------------------------------------------------------------------------- /src/types/LookupStatus.ts: -------------------------------------------------------------------------------- 1 | const enum LookupStatus { 2 | Looking = 'looking', 3 | NotFound = 'not-found' 4 | } 5 | 6 | export default LookupStatus; 7 | -------------------------------------------------------------------------------- /src/types/MediaAlbum.d.ts: -------------------------------------------------------------------------------- 1 | import BaseMediaObject from './BaseMediaObject'; 2 | import ItemType from './ItemType'; 3 | import MediaItem from './MediaItem'; 4 | import Pager from './Pager'; 5 | 6 | export default interface MediaAlbum extends BaseMediaObject { 7 | readonly itemType: ItemType.Album; 8 | readonly pager: Pager; 9 | // Everything below here should be optional. 10 | readonly artist?: string; 11 | readonly multiDisc?: boolean; 12 | readonly year?: number; 13 | readonly trackCount?: number; 14 | readonly duration?: number; 15 | readonly playedAt?: number; // UTC 16 | readonly unplayable?: boolean; 17 | readonly release_mbid?: string; 18 | readonly artist_mbids?: readonly string[]; 19 | readonly caa_mbid?: string; // cover art archive 20 | readonly copyright?: string; 21 | readonly explicit?: boolean; 22 | readonly badge?: string; 23 | readonly shareLink?: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/types/MediaArtist.d.ts: -------------------------------------------------------------------------------- 1 | import BaseMediaObject from './BaseMediaObject'; 2 | import ItemType from './ItemType'; 3 | import MediaAlbum from './MediaAlbum'; 4 | import Pager from './Pager'; 5 | 6 | export default interface MediaArtist extends BaseMediaObject { 7 | readonly itemType: ItemType.Artist; 8 | readonly pager: Pager; 9 | // Everything below here should be optional. 10 | readonly artist_mbid?: string; 11 | readonly country?: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/MediaFilter.d.ts: -------------------------------------------------------------------------------- 1 | export default interface MediaFilter { 2 | readonly id: string; 3 | readonly title: string; 4 | readonly count?: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/MediaFolder.d.ts: -------------------------------------------------------------------------------- 1 | import BaseMediaObject from './BaseMediaObject'; 2 | import ItemType from './ItemType'; 3 | import MediaFolderItem from './MediaFolderItem'; 4 | import Pager from './Pager'; 5 | 6 | export default interface MediaFolder extends BaseMediaObject { 7 | readonly itemType: ItemType.Folder; 8 | readonly fileName: string; 9 | readonly path: string; 10 | readonly pager: Pager; 11 | // Everything below here should be optional. 12 | readonly parentFolder?: MediaFolder; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/MediaFolderItem.d.ts: -------------------------------------------------------------------------------- 1 | import {SetRequired} from 'type-fest'; 2 | import MediaFolder from './MediaFolder'; 3 | import MediaItem from './MediaItem'; 4 | 5 | type MediaFolderItem = MediaFolder | SetRequired; 6 | 7 | export default MediaFolderItem; 8 | -------------------------------------------------------------------------------- /src/types/MediaObject.d.ts: -------------------------------------------------------------------------------- 1 | import MediaAlbum from './MediaAlbum'; 2 | import MediaArtist from './MediaArtist'; 3 | import MediaFolderItem from './MediaFolderItem'; 4 | import MediaItem from './MediaItem'; 5 | import MediaPlaylist from './MediaPlaylist'; 6 | 7 | type MediaObject = MediaAlbum | MediaArtist | MediaItem | MediaPlaylist | MediaFolderItem; 8 | 9 | export default MediaObject; 10 | -------------------------------------------------------------------------------- /src/types/MediaPlayback.d.ts: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | import PlaylistItem from './PlaylistItem'; 3 | 4 | export default interface MediaPlayback extends Player { 5 | stopAfterCurrent: boolean; 6 | eject(): void; 7 | loadAndPlay(item: PlaylistItem): void; 8 | next(): void; 9 | prev(): void; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/MediaPlaylist.d.ts: -------------------------------------------------------------------------------- 1 | import BaseMediaObject from './BaseMediaObject'; 2 | import ItemType from './ItemType'; 3 | import MediaItem from './MediaItem'; 4 | import Pager from './Pager'; 5 | 6 | export default interface MediaPlaylist extends BaseMediaObject { 7 | readonly itemType: ItemType.Playlist; 8 | readonly pager: Pager; 9 | // Everything below here should be optional 10 | readonly isOwn?: boolean; 11 | readonly owner?: { 12 | readonly name: string; 13 | readonly url?: string; 14 | }; 15 | readonly trackCount?: number; 16 | readonly duration?: number; 17 | readonly playedAt?: number; // UTC 18 | readonly modifiedAt?: number; // UTC 19 | readonly isChart?: boolean; 20 | readonly unplayable?: boolean; 21 | } 22 | -------------------------------------------------------------------------------- /src/types/MediaSearchParams.d.ts: -------------------------------------------------------------------------------- 1 | export default interface MediaSearchParams { 2 | readonly q?: string; 3 | readonly sortBy?: string; 4 | readonly sortOrder?: 1 | -1; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/MediaService.d.ts: -------------------------------------------------------------------------------- 1 | import DataService from './DataService'; 2 | import PersonalMediaService from './PersonalMediaService'; 3 | import PublicMediaService from './PublicMediaService'; 4 | 5 | type MediaService = 6 | | PublicMediaService 7 | | PersonalMediaService 8 | | DataService; 9 | 10 | export default MediaService; 11 | -------------------------------------------------------------------------------- /src/types/MediaServiceId.d.ts: -------------------------------------------------------------------------------- 1 | export type PublicMediaServiceId = 2 | | 'apple' 3 | | 'internet-radio' 4 | | 'mixcloud' 5 | | 'soundcloud' 6 | | 'spotify' 7 | | 'tidal' 8 | | 'youtube'; 9 | 10 | export type PersonalMediaServiceId = 11 | | 'airsonic' 12 | | 'ampache' 13 | | 'emby' 14 | | 'gonic' 15 | | 'jellyfin' 16 | | 'navidrome' 17 | | 'plex' 18 | | 'subsonic'; 19 | 20 | export type DataServiceId = 'lastfm' | 'listenbrainz'; 21 | 22 | type MediaServiceId = PublicMediaServiceId | PersonalMediaServiceId | DataServiceId; 23 | 24 | export default MediaServiceId; 25 | -------------------------------------------------------------------------------- /src/types/MediaType.ts: -------------------------------------------------------------------------------- 1 | const enum MediaType { 2 | Audio = 0, 3 | Video = 1, 4 | } 5 | 6 | export default MediaType; 7 | -------------------------------------------------------------------------------- /src/types/MetadataChange.d.ts: -------------------------------------------------------------------------------- 1 | import MediaObject from './MediaObject'; 2 | 3 | export default interface MetadataChange { 4 | readonly match: (object: T) => boolean; 5 | readonly values: Partial; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/NextVisualizerReason.d.ts: -------------------------------------------------------------------------------- 1 | type NextVisualizerReason = 2 | | 'new-media-item' 3 | | 'new-provider' 4 | | 'new-visualizers' 5 | | 'next-clicked' 6 | | 'transition' 7 | | 'error'; 8 | 9 | export default NextVisualizerReason; 10 | -------------------------------------------------------------------------------- /src/types/Pager.d.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | 3 | export default interface Pager { 4 | readonly pageSize: number; 5 | readonly maxSize?: number | undefined; 6 | observeBusy: () => Observable; 7 | observeItems(): Observable; 8 | observeSize(): Observable; 9 | observeError(): Observable; 10 | fetchAt(index: number, length?: number): void; 11 | disconnect(): void; 12 | } 13 | 14 | export interface PagerConfig { 15 | readonly pageSize: number; 16 | readonly maxSize?: number; 17 | readonly lookup?: boolean; // lookup only (no background fetching) 18 | readonly noCache?: boolean; // disable caching (implementation specific) 19 | } 20 | 21 | export interface Page { 22 | readonly items: readonly T[]; 23 | readonly total?: number; 24 | readonly atEnd?: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /src/types/ParentOf.d.ts: -------------------------------------------------------------------------------- 1 | import MediaAlbum from './MediaAlbum'; 2 | import MediaArtist from './MediaArtist'; 3 | import MediaFolder from './MediaFolder'; 4 | import MediaItem from './MediaItem'; 5 | import MediaObject from './MediaObject'; 6 | import MediaPlaylist from './MediaPlaylist'; 7 | 8 | type ParentOf = T extends MediaItem 9 | ? MediaAlbum | MediaPlaylist | MediaFolder 10 | : T extends MediaAlbum 11 | ? MediaArtist 12 | : T extends MediaFolder 13 | ? MediaFolder | undefined 14 | : never; 15 | 16 | export default ParentOf; 17 | -------------------------------------------------------------------------------- /src/types/PersonalMediaLibrary.d.ts: -------------------------------------------------------------------------------- 1 | export default interface PersonalMediaLibrary { 2 | readonly id: string; 3 | readonly title: string; 4 | readonly type?: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/PersonalMediaServerSettings.d.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import PersonalMediaLibrary from './PersonalMediaLibrary'; 3 | 4 | export default interface PersonalMediaServerSettings { 5 | readonly audioLibraries: readonly PersonalMediaLibrary[]; 6 | readonly host: string; 7 | libraryId: string; 8 | libraries: readonly PersonalMediaLibrary[]; 9 | readonly videoLibraryId?: string; 10 | observeLibraryId: () => Observable; 11 | useManualLogin?: boolean; 12 | userName?: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/PersonalMediaService.d.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import BaseMediaService from './BaseMediaService'; 3 | import PersonalMediaLibrary from './PersonalMediaLibrary'; 4 | import PersonalMediaServerSettings from './PersonalMediaServerSettings'; 5 | import ServiceType from './ServiceType'; 6 | 7 | export default interface PersonalMediaService 8 | extends BaseMediaService, 9 | Partial { 10 | readonly host: string; 11 | readonly serviceType: ServiceType.PersonalMedia; 12 | readonly Components?: BaseMediaService['Components'] & { 13 | ServerSettings?: React.FC<{service: PersonalMediaService}>; 14 | }; 15 | // Everything below here should be optional. 16 | getLibraries?: () => Promise; 17 | getServerInfo?: () => Promise>; 18 | } 19 | -------------------------------------------------------------------------------- /src/types/Pin.d.ts: -------------------------------------------------------------------------------- 1 | import {Except} from 'type-fest'; 2 | import MediaAlbum from './MediaAlbum'; 3 | import MediaArtist from './MediaArtist'; 4 | import MediaFolder from './MediaFolder'; 5 | import MediaPlaylist from './MediaPlaylist'; 6 | 7 | export type Pinnable = MediaAlbum | MediaArtist | MediaPlaylist | MediaFolder; 8 | 9 | type Pin = Except & { 10 | readonly isPinned: true; 11 | }; 12 | 13 | export default Pin; 14 | -------------------------------------------------------------------------------- /src/types/PlayAction.d.ts: -------------------------------------------------------------------------------- 1 | import Action from './Action'; 2 | 3 | type PlayAction = Extract; 4 | 5 | export default PlayAction; 6 | -------------------------------------------------------------------------------- /src/types/PlayableItem.d.ts: -------------------------------------------------------------------------------- 1 | import MediaItem from './MediaItem'; 2 | 3 | type PlayableItem = Pick< 4 | MediaItem, 5 | 'src' | 'srcs' | 'linearType' | 'playbackType' | 'isLivePlayback' | 'container' | 'startTime' 6 | > & { 7 | readonly duration?: number; // Make this optional 8 | }; 9 | 10 | export default PlayableItem; 11 | -------------------------------------------------------------------------------- /src/types/Playback.d.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import PlaylistItem from './PlaylistItem'; 3 | import PlaybackState from './PlaybackState'; 4 | 5 | export default interface Playback { 6 | paused: boolean; 7 | currentItem: PlaylistItem | null; 8 | currentTime: number; 9 | duration: number; 10 | observePlaybackState(): Observable; 11 | observePlaybackStart(): Observable; 12 | observePlaybackEnd(): Observable; 13 | observeCurrentItem(): Observable; 14 | observeCurrentTime(): Observable; 15 | observeDuration(): Observable; 16 | observePaused(): Observable; 17 | getPlaybackId(): string; 18 | getPlaybackState(): PlaybackState; 19 | ended(): void; 20 | pause(): void; 21 | play(): void; 22 | playing(): void; 23 | stop(): void; 24 | suspend(): void; 25 | unsuspend(): void; 26 | } 27 | -------------------------------------------------------------------------------- /src/types/PlaybackState.d.ts: -------------------------------------------------------------------------------- 1 | import PlaylistItem from './PlaylistItem'; 2 | 3 | export default interface PlaybackState { 4 | readonly currentItem: PlaylistItem | null; 5 | readonly currentTime: number; 6 | readonly startedAt: number; // Ms 7 | readonly endedAt: number; // Ms 8 | readonly duration: number; 9 | readonly paused: boolean; 10 | readonly playbackId: string; 11 | readonly miniPlayer: boolean; // State comes from miniPlayer. 12 | } 13 | -------------------------------------------------------------------------------- /src/types/PlaybackType.ts: -------------------------------------------------------------------------------- 1 | const enum PlaybackType { 2 | Direct = 0, 3 | HLS = 1, 4 | DASH = 2, // Not currently used. 5 | IFrame = 3, 6 | Icecast = 4, 7 | IcecastM3u = 5, // `.m3u` or `.pls` 8 | IcecastOgg = 6, 9 | HLSMetadata = 11, 10 | } 11 | 12 | export default PlaybackType; 13 | -------------------------------------------------------------------------------- /src/types/PlaylistItem.d.ts: -------------------------------------------------------------------------------- 1 | import LookupStatus from './LookupStatus'; 2 | import MediaItem from './MediaItem'; 3 | import UserData from './UserData'; 4 | 5 | type PlaylistItem = Subtract & { 6 | readonly id: string; 7 | readonly lookupStatus?: LookupStatus; 8 | }; 9 | 10 | export default PlaylistItem; 11 | -------------------------------------------------------------------------------- /src/types/Preferences.d.ts: -------------------------------------------------------------------------------- 1 | export default interface Preferences { 2 | disableExplicitContent: boolean; 3 | markExplicitContent: boolean; 4 | mediaInfoTabs: boolean; 5 | miniPlayer: boolean; 6 | spacebarTogglePlay: boolean; 7 | } -------------------------------------------------------------------------------- /src/types/PublicMediaService.d.ts: -------------------------------------------------------------------------------- 1 | import BaseMediaService from './BaseMediaService'; 2 | import MediaType from './MediaType'; 3 | import ServiceType from './ServiceType'; 4 | 5 | export default interface PublicMediaService extends BaseMediaService { 6 | readonly serviceType: ServiceType.PublicMedia; 7 | readonly Components?: BaseMediaService['Components'] & { 8 | StreamingSettings?: React.FC<{service: PublicMediaService}>; 9 | }; 10 | // Everything below here should be optional. 11 | readonly primaryMediaType?: MediaType; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/ReplayGainMode.d.ts: -------------------------------------------------------------------------------- 1 | type ReplayGainMode = '' | 'album' | 'track'; 2 | 3 | export default ReplayGainMode; 4 | -------------------------------------------------------------------------------- /src/types/ServiceType.ts: -------------------------------------------------------------------------------- 1 | const enum ServiceType { 2 | PublicMedia = 0, 3 | PersonalMedia = 1, 4 | DataService = 2, 5 | } 6 | 7 | export default ServiceType; 8 | -------------------------------------------------------------------------------- /src/types/Theme.d.ts: -------------------------------------------------------------------------------- 1 | export default interface Theme { 2 | readonly name: string; 3 | readonly fontName?: string; 4 | readonly frameColor: string; 5 | readonly frameTextColor: string; 6 | readonly backgroundColor: string; 7 | readonly textColor: string; 8 | readonly selectedBackgroundColor: string; 9 | readonly selectedTextColor: string; 10 | readonly buttonColor?: string; 11 | readonly buttonTextColor?: string; 12 | readonly scrollbarColor?: string; 13 | readonly scrollbarTextColor?: string; 14 | readonly scrollbarThickness?: number; 15 | readonly mediaButtonColor?: string; 16 | readonly mediaButtonTextColor?: string; 17 | readonly roundness: number; 18 | readonly spacing: number; 19 | readonly flat: boolean; 20 | } 21 | -------------------------------------------------------------------------------- /src/types/Thumbnail.d.ts: -------------------------------------------------------------------------------- 1 | export default interface Thumbnail { 2 | readonly url: string; 3 | readonly width: number; 4 | readonly height: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/UserData.d.ts: -------------------------------------------------------------------------------- 1 | export default interface UserData { 2 | readonly rating?: number; 3 | readonly globalLikes?: number; 4 | readonly globalRating?: number; 5 | readonly playCount?: number; 6 | readonly globalPlayCount?: number; 7 | readonly inLibrary?: boolean; 8 | readonly isPinned?: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/UserTheme.d.ts: -------------------------------------------------------------------------------- 1 | import Theme from './Theme'; 2 | 3 | export default interface UserTheme extends Theme { 4 | readonly userTheme: true; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/VisualizerFavorite.d.ts: -------------------------------------------------------------------------------- 1 | import Visualizer from './Visualizer'; 2 | 3 | type VisualizerFavorite = Pick; 4 | 5 | export default VisualizerFavorite; 6 | -------------------------------------------------------------------------------- /src/types/VisualizerProvider.d.ts: -------------------------------------------------------------------------------- 1 | import type {Observable} from 'rxjs'; 2 | import AudioManager from './AudioManager'; 3 | import Player from './Player'; 4 | import Visualizer from './Visualizer'; 5 | 6 | export default interface VisualizerProvider { 7 | readonly id: T['providerId']; 8 | readonly name: string; 9 | readonly shortName?: string; 10 | readonly player?: Player; 11 | readonly visualizers: readonly T[]; 12 | readonly externalUrl?: string; 13 | readonly defaultHidden?: boolean; 14 | createPlayer(audio: AudioManager): Player; 15 | observeVisualizers(): Observable; 16 | } 17 | -------------------------------------------------------------------------------- /src/types/VisualizerProviderId.d.ts: -------------------------------------------------------------------------------- 1 | type VisualizerProviderId = 2 | | 'none' 3 | | 'ambientvideo' 4 | | 'ampshader' 5 | | 'audiomotion' 6 | | 'butterchurn' 7 | | 'coverart' 8 | | 'spotifyviz' 9 | | 'waveform' 10 | ; 11 | 12 | export default VisualizerProviderId; 13 | -------------------------------------------------------------------------------- /src/utils/LiteStorage/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './LiteStorage'; 2 | export * from './LiteStorage'; 3 | -------------------------------------------------------------------------------- /src/utils/LiteStorage/memoryStorage.ts: -------------------------------------------------------------------------------- 1 | const items = new Map(); 2 | 3 | const memoryStorage: Storage = { 4 | get length(): number { 5 | return items.size; 6 | }, 7 | 8 | clear(): void { 9 | items.clear(); 10 | }, 11 | 12 | key(index: number): string | null { 13 | return [...items.keys()][index] ?? null; 14 | }, 15 | 16 | getItem(key: string): string | null { 17 | return items.get(String(key)) ?? null; 18 | }, 19 | 20 | setItem(key: string, value: string): void { 21 | items.set(String(key), String(value)); 22 | }, 23 | 24 | removeItem(key: string): void { 25 | items.delete(String(key)); 26 | }, 27 | }; 28 | 29 | export default memoryStorage; 30 | -------------------------------------------------------------------------------- /src/utils/browser.ts: -------------------------------------------------------------------------------- 1 | import {detect} from 'detect-browser'; 2 | import isElectron from 'is-electron'; 3 | 4 | const browser = detect(); 5 | const name = browser?.name || 'unknown'; 6 | const displayName = /^edge/.test(name) 7 | ? 'Microsoft Edge' 8 | : name.replace(/^\w/, (char) => char.toUpperCase()); 9 | const version = browser?.version || '0'; 10 | const os = browser?.os || 'unknown'; 11 | const cmdKey: keyof KeyboardEvent = os === 'Mac OS' ? 'metaKey' : 'ctrlKey'; 12 | const cmdKeyStr = os === 'Mac OS' ? '⌘' : 'Ctrl'; 13 | const desktop = !/^(iOS|Android OS|BlackBerry OS|Windows Mobile|Amazon OS)$/.test(os); 14 | 15 | export default { 16 | name, 17 | displayName, 18 | version, 19 | os, 20 | desktop, 21 | cmdKey, 22 | cmdKeyStr, 23 | isElectron: isElectron() 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/event.ts: -------------------------------------------------------------------------------- 1 | type AnyEvent = Pick; 2 | 3 | export function cancelEvent(event: AnyEvent): void { 4 | event.preventDefault(); 5 | event.stopPropagation(); 6 | } 7 | 8 | export function preventDefault(event: AnyEvent): void { 9 | event.preventDefault(); 10 | } 11 | 12 | export function stopPropagation(event: AnyEvent): void { 13 | event.stopPropagation(); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './array'; 2 | export * from './date'; 3 | export * from './dom'; 4 | export * from './event'; 5 | export * from './fetch'; 6 | export * from './media'; 7 | export * from './number'; 8 | export * from './string'; 9 | export * from './utils'; 10 | export {default as LiteStorage} from './LiteStorage'; 11 | export {default as Logger} from './Logger'; 12 | export {default as RateLimiter} from './RateLimiter'; 13 | export {default as browser} from './browser'; 14 | -------------------------------------------------------------------------------- /src/utils/number.ts: -------------------------------------------------------------------------------- 1 | export function clamp(min: number, value: number, max: number): number { 2 | return Math.min(Math.max(value, min), max); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function copyToClipboard(data: any): Promise { 2 | if (data && typeof data === 'object') { 3 | data = JSON.stringify(data, undefined, 2); 4 | } 5 | return navigator.clipboard.writeText(String(data)); 6 | } 7 | 8 | export function exists(value: T): value is NonNullable { 9 | return value != null; 10 | } 11 | 12 | export function isFullscreenMedia(): boolean { 13 | return document.fullscreenElement?.id === 'media' || isMiniPlayer; 14 | } 15 | 16 | export const isMiniPlayer = !!( 17 | opener?.origin === location.origin && 18 | window.name === sessionStorage.getItem('ampcast/session/miniPlayerId') && 19 | location.hash === '#mini-player' 20 | ); 21 | 22 | export function sleep(ms: number): Promise { 23 | return new Promise((resolve) => setTimeout(resolve, ms)); 24 | } 25 | --------------------------------------------------------------------------------