├── src ├── shared │ ├── data │ │ ├── assets │ │ │ ├── files │ │ │ │ ├── txt.txt │ │ │ │ ├── jpg.jpg │ │ │ │ ├── mp3.mp3 │ │ │ │ ├── flac.flac │ │ │ │ └── mp3_cover.mp3 │ │ │ ├── album3.js │ │ │ ├── album2.js │ │ │ ├── album1.js │ │ │ ├── track1.js │ │ │ ├── track2.js │ │ │ ├── track3.js │ │ │ └── files.js │ │ ├── e2e │ │ │ └── index.js │ │ ├── ipc │ │ │ └── index.js │ │ ├── i18n │ │ │ └── index.js │ │ ├── schemas.js │ │ ├── schemas │ │ │ ├── album.js │ │ │ └── album │ │ │ │ ├── shareCandidateSchema.js │ │ │ │ └── albumInstanceSchema.js │ │ ├── defaultIPFSDaemonConfig.js │ │ ├── assets.js │ │ └── constants.js │ ├── tsconfig.json │ ├── utils │ │ ├── getRandomBoolean.js │ │ ├── checkFileIsAudio.js │ │ ├── checkFileIsImage.js │ │ ├── isObject.js │ │ ├── asyncTimeout.js │ │ ├── getMyAppVersion.js │ │ ├── getRandomString.js │ │ ├── checkFsFileMime.js │ │ ├── normalizeAlbumTrackForPlaylist.js │ │ ├── getLatestRelease.js │ │ ├── normalizeCollectionAlbum.js │ │ ├── getAudioMetadataFromFsFile.js │ │ ├── getLocaleCode.js │ │ ├── reduxSagaTicker.js │ │ ├── formatBytes.js │ │ ├── getQualityCode.js │ │ ├── validateAlbum.js │ │ ├── filterFsFilesByMime.js │ │ ├── getSagaChannelsMap.js │ │ ├── getCurrentOSIcon.js │ │ ├── createWorkerReducer.js │ │ ├── getFolderContents.js │ │ ├── getPlaylistTracksFromAlbums.js │ │ ├── createThreadReducer.js │ │ ├── getTargetReleaseAsset.js │ │ ├── getAudioMetadataFromFsFile.spec.js │ │ ├── secondsTohhmmss.js │ │ ├── getBufferedAudioMap.js │ │ ├── splitFoldersAndFiles.js │ │ ├── validateShareCandidate.js │ │ ├── getMetabinDataChannel.js │ │ ├── fsFileToHTMLFile.js │ │ ├── createWorkerController.js │ │ ├── ipcMain.js │ │ ├── createThreadController.js │ │ ├── createAlbumsQuery.js │ │ └── calcPreviousTrackIndex.js │ ├── .eslintrc │ └── config.js ├── renderer │ ├── components │ │ ├── SyncIcon.css │ │ ├── PageContainer.css │ │ ├── ErrorMessage.css │ │ ├── ProcessingScreen.css │ │ ├── CustomTextInput.jsx │ │ ├── SyncIcon.jsx │ │ ├── DNDarea.css │ │ ├── CoverPreview.css │ │ ├── CustomTextInput.css │ │ ├── ErrorMessage.jsx │ │ ├── ProcessingScreen.jsx │ │ ├── ParagraphScreen.css │ │ ├── CustomTextInputRedux.jsx │ │ ├── QualityLabel.css │ │ ├── CustomButton.jsx │ │ ├── PageContainer.jsx │ │ ├── CoverPreview.jsx │ │ ├── ParagraphScreen.jsx │ │ ├── DNDarea.jsx │ │ └── CustomButton.css │ ├── view │ │ ├── App │ │ │ ├── ReadyScreen │ │ │ │ ├── Playlist │ │ │ │ │ ├── Tracklist.css │ │ │ │ │ ├── TracklistConnected.js │ │ │ │ │ ├── PlaylistControlsConnected.js │ │ │ │ │ ├── PlaylistControls.css │ │ │ │ │ ├── PlaylistTrackContainerConnected.js │ │ │ │ │ ├── PlaylistTrackContainer.jsx │ │ │ │ │ ├── EqualizerIcon.jsx │ │ │ │ │ ├── Tracklist.jsx │ │ │ │ │ ├── EqualizerIcon.css │ │ │ │ │ ├── PlaylistControls.jsx │ │ │ │ │ └── PlaylistTrackConnected.js │ │ │ │ ├── Page │ │ │ │ │ ├── DiscoverPage.css │ │ │ │ │ ├── DiscoverPage │ │ │ │ │ │ ├── DiscoverPageBody.css │ │ │ │ │ │ ├── SelectedActions.css │ │ │ │ │ │ ├── DiscoverPageBody │ │ │ │ │ │ │ ├── NoSearchResultsScreen.jsx │ │ │ │ │ │ │ ├── NoAlbumsScreen.jsx │ │ │ │ │ │ │ ├── FeedScreenConnected.js │ │ │ │ │ │ │ └── FeedScreen.css │ │ │ │ │ │ ├── SearchBarConnected.js │ │ │ │ │ │ ├── SelectedActionsConnected.js │ │ │ │ │ │ ├── SearchBar.css │ │ │ │ │ │ └── DiscoverPageBodyConnected.js │ │ │ │ │ ├── DonatePage │ │ │ │ │ │ ├── AboutHero │ │ │ │ │ │ │ ├── NewReleaseCard │ │ │ │ │ │ │ │ ├── AssetsButtons.css │ │ │ │ │ │ │ │ └── AssetsButtons │ │ │ │ │ │ │ │ │ ├── AssetDownloadButton.css │ │ │ │ │ │ │ │ │ └── AssetDownloadButton.jsx │ │ │ │ │ │ │ ├── NewReleaseCard.css │ │ │ │ │ │ │ ├── SocialLink.jsx │ │ │ │ │ │ │ ├── NewReleaseCardConnected.js │ │ │ │ │ │ │ ├── SocialLink.css │ │ │ │ │ │ │ └── NewReleaseCard.jsx │ │ │ │ │ │ ├── DonateCard │ │ │ │ │ │ │ ├── suatmm.gif │ │ │ │ │ │ │ └── DonatePill.jsx │ │ │ │ │ │ ├── AboutHero.css │ │ │ │ │ │ ├── AboutHeroConnected.js │ │ │ │ │ │ ├── DonateCard.css │ │ │ │ │ │ └── DonateCard.jsx │ │ │ │ │ ├── DonatePage.css │ │ │ │ │ ├── SharePage │ │ │ │ │ │ ├── ShareForm.css │ │ │ │ │ │ ├── ShareDropZone.css │ │ │ │ │ │ ├── ShareFormConnected.js │ │ │ │ │ │ ├── ShareForm │ │ │ │ │ │ │ ├── TracklistFieldset │ │ │ │ │ │ │ │ ├── TrackControlsRight.jsx │ │ │ │ │ │ │ │ └── TrackInput.css │ │ │ │ │ │ │ ├── TracklistFieldset.css │ │ │ │ │ │ │ └── AboutFieldset.css │ │ │ │ │ │ └── ShareDropZone.jsx │ │ │ │ │ ├── DonatePage.jsx │ │ │ │ │ ├── SharePageConnected.js │ │ │ │ │ ├── DiscoverPageConnected.js │ │ │ │ │ └── SharePage.jsx │ │ │ │ ├── Navigation.css │ │ │ │ ├── Player │ │ │ │ │ ├── ActivePlayer │ │ │ │ │ │ ├── TrackBar │ │ │ │ │ │ │ ├── TrackBuffer.css │ │ │ │ │ │ │ ├── TrackInfo.css │ │ │ │ │ │ │ ├── CustomRangeInput.css │ │ │ │ │ │ │ ├── TrackBuffer.jsx │ │ │ │ │ │ │ ├── TrackInfo.jsx │ │ │ │ │ │ │ └── TrackTimeline.css │ │ │ │ │ │ ├── TrackBar.css │ │ │ │ │ │ ├── ProgressBar.jsx │ │ │ │ │ │ ├── VolumeInputConnected.js │ │ │ │ │ │ ├── TrackBar.jsx │ │ │ │ │ │ ├── ControlsLeftConnected.js │ │ │ │ │ │ ├── ControlsRightConnected.js │ │ │ │ │ │ ├── ControlsRight.jsx │ │ │ │ │ │ ├── VolumeInput.css │ │ │ │ │ │ └── ControlsLeft.jsx │ │ │ │ │ ├── PendingPlayer.jsx │ │ │ │ │ └── ActivePlayerConnected.js │ │ │ │ ├── IndicatorsBar.css │ │ │ │ ├── IndicatorsBar │ │ │ │ │ ├── Indicator.css │ │ │ │ │ └── Indicator.jsx │ │ │ │ ├── Playlist.css │ │ │ │ ├── PlayerConnected.js │ │ │ │ ├── NavigationConnected.js │ │ │ │ ├── PlaylistConnected.js │ │ │ │ ├── NotificationsConnected.js │ │ │ │ ├── Player.jsx │ │ │ │ ├── Notifications.css │ │ │ │ ├── Notifications.jsx │ │ │ │ ├── Navigation │ │ │ │ │ ├── NavigationItem.css │ │ │ │ │ └── NavigationItem.jsx │ │ │ │ ├── Playlist.jsx │ │ │ │ ├── Page.jsx │ │ │ │ ├── IndicatorsBarConnected.js │ │ │ │ ├── Notifications │ │ │ │ │ └── Toast.jsx │ │ │ │ └── Navigation.jsx │ │ │ ├── LockScreen.css │ │ │ ├── StartScreen.css │ │ │ ├── CloseScreen.css │ │ │ ├── StartScreen │ │ │ │ ├── ProgressBar.css │ │ │ │ └── ProgressBar.jsx │ │ │ ├── LockScreen.jsx │ │ │ ├── Root.css │ │ │ ├── Root.jsx │ │ │ ├── ReadyScreen.css │ │ │ ├── CloseScreen.jsx │ │ │ ├── StartScreen.jsx │ │ │ └── ReadyScreen.jsx │ │ ├── AppConnected.js │ │ └── App.jsx │ ├── index.js │ ├── index.html │ ├── rootReducer.js │ ├── state │ │ ├── actions.js │ │ ├── selectors.js │ │ ├── domains │ │ │ ├── volume.js │ │ │ ├── legalAgreement.js │ │ │ ├── newRelease.js │ │ │ ├── cachedCIDs.js │ │ │ ├── notifications.js │ │ │ ├── audio.js │ │ │ ├── discoverSelected.js │ │ │ ├── albumsInfo.js │ │ │ ├── localCoversCIDs.js │ │ │ ├── appStart.js │ │ │ ├── localAudiosCIDs.js │ │ │ └── ipfsInfo.js │ │ ├── selectors │ │ │ └── simple.js │ │ └── reducers.js │ ├── rootSaga.js │ ├── tsconfig.json │ ├── .eslintrc │ ├── sagas │ │ ├── startApp │ │ │ ├── startServices │ │ │ │ ├── startIndicatorsBarService.js │ │ │ │ ├── startAlbumsCollectionInfo.js │ │ │ │ ├── startPlaybackService.js │ │ │ │ ├── startIPFSCachingService │ │ │ │ │ ├── startIPFSFilesCatch │ │ │ │ │ │ ├── cachePlaylistTracks.js │ │ │ │ │ │ └── cacheDiscoverAlbumsCovers.js │ │ │ │ │ ├── startCachedIPFSFilesReciever.js │ │ │ │ │ └── startIPFSFilesCatch.js │ │ │ │ ├── startIPFSCachingService.js │ │ │ │ ├── startAlbumsSharingService │ │ │ │ │ ├── handleShareFilesSelect │ │ │ │ │ │ ├── getAlbumCandidatesFromItems │ │ │ │ │ │ │ ├── getCandidatesFromFiles │ │ │ │ │ │ │ │ ├── getCoverFromFiles.js │ │ │ │ │ │ │ │ └── extractAlbumInfoFromTracks.js │ │ │ │ │ │ │ ├── getCandidateFromFiles.js │ │ │ │ │ │ │ └── getCandidatesFromFolders.js │ │ │ │ │ │ └── getAlbumCandidatesFromItems.js │ │ │ │ │ ├── handleShareFormChange.js │ │ │ │ │ └── handleShareItemsSelect.js │ │ │ │ ├── startTracksCache.js │ │ │ │ ├── startAlbumsSharingService.js │ │ │ │ ├── startIndicatorBarService │ │ │ │ │ ├── startMetabinPeersRetriever.js │ │ │ │ │ └── startIPFSStatsRetriever.js │ │ │ │ ├── startDiscoverPageService │ │ │ │ │ ├── deleteSelectedAlbums.js │ │ │ │ │ ├── playOrQueueAlbum.js │ │ │ │ │ ├── fetchDiscoverAlbums.js │ │ │ │ │ └── playOrQueueSelectedAlbums.js │ │ │ │ ├── startAblumsReciever.js │ │ │ │ ├── startDiscoverPageService.js │ │ │ │ └── startAlbumsPublisher.js │ │ │ ├── getApis │ │ │ │ ├── getStorageApi.js │ │ │ │ ├── getStorageApi │ │ │ │ │ └── dbApi.worker │ │ │ │ │ │ └── albumsCollectionApi │ │ │ │ │ │ └── creationStream.js │ │ │ │ ├── getRestRemoteApis.js │ │ │ │ └── getAlbumsGateApi.js │ │ │ ├── checkLegalAgreement.js │ │ │ └── getApis.js │ │ └── startApp.js │ ├── view.jsx │ └── store.js ├── e2e │ ├── reusable │ │ ├── player.js │ │ ├── sharePage │ │ │ └── shareDropZone.js │ │ ├── lockScreen.js │ │ ├── navigation.js │ │ ├── playlist.js │ │ ├── app.js │ │ ├── sharePage.js │ │ └── notifications.js │ ├── tsconfig.json │ ├── .eslintrc │ ├── tests.js │ └── tests │ │ ├── sharing │ │ ├── selectTrackFiles.js │ │ └── selectWrongFiles.js │ │ ├── discoverPageTests.js │ │ └── sharePageTests.js ├── tsconfig.base.json └── main │ ├── .eslintrc │ ├── tsconfig.json │ ├── methods │ ├── startMainWindowLifecycle.js │ ├── createMainWindow │ │ ├── withNoNavigation.js │ │ ├── withMenu.js │ │ └── withTray.js │ ├── setAppMenu.js │ ├── withSingleInstanceBehaviour.js │ ├── loadMainWindow.js │ ├── startCommunication │ │ ├── startIpfsProcess.js │ │ ├── startFsBridge.js │ │ └── startIpfsProcess │ │ │ ├── getIpfsDaemonParams.js │ │ │ └── beforeIpfsDaemonStart.js │ ├── createMainWindow.js │ ├── startCommunication.js │ └── withEnvironment.js │ └── threads │ ├── getAlbumCandidatesFromFsItems.thread.js │ ├── getTracksFromFsFiles.thread.js │ ├── getAlbumCandidatesFromFsItems.thread │ ├── getAlbumCandidatesFromFsItems │ │ ├── getCandidatesFromFiles │ │ │ ├── sortTracks.js │ │ │ ├── writeMetadataPictureToFs.js │ │ │ ├── getCoverFromFsFiles.js │ │ │ ├── extractAlbumInfoFromTracks.js │ │ │ ├── getTracksFromFsFiles.js │ │ │ └── getTracksAndCoverFromAudioFiles.js │ │ └── getCandidateFromFiles.js │ └── getAlbumCandidatesFromFsItems.js │ └── ipfs.thread │ └── startIpfsDaemon.js ├── .eslintignore ├── tslint.json ├── resources ├── icon.icns ├── icon.ico ├── icons │ ├── 16x16.png │ ├── 24x24.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 64x64.png │ ├── 96x96.png │ ├── 128x128.png │ ├── 256x256.png │ └── 512x512.png └── indicator_icons │ ├── icon16x16.png │ ├── icon16x16@2x.png │ ├── icon16x16Template.png │ └── icon16x16Template@2x.png ├── .gitignore ├── .vscode ├── settings.json └── launch.json ├── .editorconfig ├── .appveyor.yml ├── .travis.yml ├── .releaserc.json ├── LICENSE ├── .circleci └── config.yml └── .babelrc /src/shared/data/assets/files/txt.txt: -------------------------------------------------------------------------------- 1 | get your first up -------------------------------------------------------------------------------- /src/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .* 2 | node_modules 3 | dist 4 | resources 5 | temp 6 | /app 7 | dll 8 | -------------------------------------------------------------------------------- /src/shared/data/e2e/index.js: -------------------------------------------------------------------------------- 1 | import * as e2e from './types'; 2 | 3 | export default e2e; 4 | -------------------------------------------------------------------------------- /src/shared/data/ipc/index.js: -------------------------------------------------------------------------------- 1 | import * as ipc from './ipc'; 2 | 3 | export default ipc; 4 | -------------------------------------------------------------------------------- /src/shared/data/i18n/index.js: -------------------------------------------------------------------------------- 1 | import * as i18n from './strings'; 2 | 3 | export default i18n; 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-airbnb"] 3 | } -------------------------------------------------------------------------------- /resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icon.icns -------------------------------------------------------------------------------- /resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icon.ico -------------------------------------------------------------------------------- /src/renderer/components/SyncIcon.css: -------------------------------------------------------------------------------- 1 | .syncIcon { 2 | font-size: 2.5em; 3 | color: lightgray; 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /app 2 | /dist 3 | /.temp 4 | /imports 5 | 6 | node_modules 7 | .DS_Store 8 | Thumbs.db 9 | *.log -------------------------------------------------------------------------------- /src/renderer/components/PageContainer.css: -------------------------------------------------------------------------------- 1 | .page-container { 2 | grid-area: page; 3 | overflow-y: auto; 4 | } -------------------------------------------------------------------------------- /resources/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/16x16.png -------------------------------------------------------------------------------- /resources/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/24x24.png -------------------------------------------------------------------------------- /resources/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/32x32.png -------------------------------------------------------------------------------- /resources/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/48x48.png -------------------------------------------------------------------------------- /resources/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/64x64.png -------------------------------------------------------------------------------- /resources/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/96x96.png -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/Tracklist.css: -------------------------------------------------------------------------------- 1 | .tracklist { 2 | overflow-y: auto; 3 | height: 100%; 4 | } -------------------------------------------------------------------------------- /resources/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/128x128.png -------------------------------------------------------------------------------- /resources/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/256x256.png -------------------------------------------------------------------------------- /resources/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/icons/512x512.png -------------------------------------------------------------------------------- /src/renderer/components/ErrorMessage.css: -------------------------------------------------------------------------------- 1 | .errorMessage { 2 | color: red; 3 | text-align: center; 4 | text-transform: uppercase; 5 | } -------------------------------------------------------------------------------- /src/renderer/index.js: -------------------------------------------------------------------------------- 1 | import './css/global.css'; 2 | import './css/palette.css'; 3 | import './css/animate.css'; 4 | import './view'; 5 | -------------------------------------------------------------------------------- /src/shared/data/assets/files/jpg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/src/shared/data/assets/files/jpg.jpg -------------------------------------------------------------------------------- /src/shared/data/assets/files/mp3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/src/shared/data/assets/files/mp3.mp3 -------------------------------------------------------------------------------- /src/shared/utils/getRandomBoolean.js: -------------------------------------------------------------------------------- 1 | const getRandomBoolean = () => Math.random() >= 0.5; 2 | 3 | export default getRandomBoolean; 4 | -------------------------------------------------------------------------------- /resources/indicator_icons/icon16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/indicator_icons/icon16x16.png -------------------------------------------------------------------------------- /src/shared/data/assets/files/flac.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/src/shared/data/assets/files/flac.flac -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage.css: -------------------------------------------------------------------------------- 1 | .albums-page { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100%; 5 | } -------------------------------------------------------------------------------- /resources/indicator_icons/icon16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/indicator_icons/icon16x16@2x.png -------------------------------------------------------------------------------- /src/shared/data/assets/files/mp3_cover.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/src/shared/data/assets/files/mp3_cover.mp3 -------------------------------------------------------------------------------- /src/shared/utils/checkFileIsAudio.js: -------------------------------------------------------------------------------- 1 | 2 | const checkFileIsAudio = file => file.type.includes('audio'); 3 | 4 | export default checkFileIsAudio; 5 | -------------------------------------------------------------------------------- /src/shared/utils/checkFileIsImage.js: -------------------------------------------------------------------------------- 1 | 2 | const checkFileIsImage = file => file.type.includes('image'); 3 | 4 | export default checkFileIsImage; 5 | -------------------------------------------------------------------------------- /src/shared/utils/isObject.js: -------------------------------------------------------------------------------- 1 | 2 | const isObject = candidate => candidate && candidate.constructor === {}.constructor; 3 | 4 | export default isObject; 5 | -------------------------------------------------------------------------------- /resources/indicator_icons/icon16x16Template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/indicator_icons/icon16x16Template.png -------------------------------------------------------------------------------- /src/shared/data/schemas.js: -------------------------------------------------------------------------------- 1 | import * as albumSchema from './schemas/album'; 2 | 3 | export { albumSchema }; // eslint-disable-line import/prefer-default-export 4 | -------------------------------------------------------------------------------- /src/shared/utils/asyncTimeout.js: -------------------------------------------------------------------------------- 1 | 2 | const asyncTimeout = delay => new Promise(resolve => setTimeout(resolve, delay)); 3 | 4 | export default asyncTimeout; 5 | -------------------------------------------------------------------------------- /resources/indicator_icons/icon16x16Template@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/resources/indicator_icons/icon16x16Template@2x.png -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/DiscoverPageBody.css: -------------------------------------------------------------------------------- 1 | .albums-page__body { 2 | width: 100%; 3 | height: 100%; 4 | overflow-y: auto; 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Navigation.css: -------------------------------------------------------------------------------- 1 | 2 | .navigation { 3 | grid-area: navigation; 4 | background-color: var(--primary-color); 5 | overflow-y: auto; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/getMyAppVersion.js: -------------------------------------------------------------------------------- 1 | import { remote } from 'electron'; 2 | 3 | const getMyAppVersion = () => remote.app.getVersion(); 4 | 5 | export default getMyAppVersion; 6 | -------------------------------------------------------------------------------- /src/renderer/view/App/LockScreen.css: -------------------------------------------------------------------------------- 1 | .lockScreen { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | background-color: rgba(20,20,20,0.5); 8 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/NewReleaseCard/AssetsButtons.css: -------------------------------------------------------------------------------- 1 | .releaseButtonsRow { 2 | display: flex; 3 | flex-wrap: wrap; 4 | justify-content: center; 5 | } -------------------------------------------------------------------------------- /src/shared/data/assets/album3.js: -------------------------------------------------------------------------------- 1 | import track3 from './track3'; 2 | 3 | const album3 = { 4 | title: 'Untitled Love Story', 5 | tracks: [track3], 6 | }; 7 | 8 | export default album3; 9 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/DonateCard/suatmm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathephone/pathephone-desktop/HEAD/src/renderer/view/App/ReadyScreen/Page/DonatePage/DonateCard/suatmm.gif -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar/TrackBuffer.css: -------------------------------------------------------------------------------- 1 | .timeline__buffered-piece { 2 | position: absolute; 3 | height: 100%; 4 | background-color: rgba(9, 58, 70, 0.05); 5 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/IndicatorsBar.css: -------------------------------------------------------------------------------- 1 | .indicatorsBar { 2 | display: flex; 3 | align-items: center; 4 | grid-area: indibar; 5 | border-top: 1px solid lightgray; 6 | font-size: 0.75em; 7 | } -------------------------------------------------------------------------------- /src/shared/utils/getRandomString.js: -------------------------------------------------------------------------------- 1 | 2 | const getRandomString = () => ( 3 | Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) 4 | ); 5 | 6 | export default getRandomString; 7 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pathephone 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/shared/data/schemas/album.js: -------------------------------------------------------------------------------- 1 | export { default as instanceSchema } from '~shared/data/schemas/album/albumInstanceSchema'; 2 | export { default as shareCandidateSchema } from '~shared/data/schemas/album/shareCandidateSchema'; 3 | -------------------------------------------------------------------------------- /src/renderer/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import * as reducers from './state/reducers'; 4 | 5 | const rootReducer = combineReducers({ 6 | ...reducers, 7 | }); 8 | 9 | export default rootReducer; 10 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/IndicatorsBar/Indicator.css: -------------------------------------------------------------------------------- 1 | 2 | .indicator, 3 | .indicatorAccented { 4 | text-transform: uppercase; 5 | margin-left: 1em; 6 | } 7 | 8 | .indicatorAccented { 9 | color: var(--accent-color); 10 | } -------------------------------------------------------------------------------- /src/shared/data/assets/album2.js: -------------------------------------------------------------------------------- 1 | import cover from './files/jpg.jpg'; 2 | import track2 from './track2'; 3 | 4 | const album2 = { 5 | title: 'Memories', 6 | tracks: [track2], 7 | cover, 8 | }; 9 | 10 | export default album2; 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tslint.autoFixOnSave": true, 3 | "eslint.autoFixOnSave": true, 4 | "editor.tabSize": 2, 5 | "search.exclude": { 6 | "**/node_modules": true, 7 | "./dist": true, 8 | "./.temp": true 9 | }, 10 | } -------------------------------------------------------------------------------- /src/e2e/reusable/player.js: -------------------------------------------------------------------------------- 1 | import e2e from '~shared/data/e2e'; 2 | 3 | export function playerWaitForActiveStatus() { // eslint-disable-line import/prefer-default-export 4 | return this.app.client 5 | .waitForExist(e2e.PLAYER_ACTIVE_ID); 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/state/actions.js: -------------------------------------------------------------------------------- 1 | import * as systemActions from './actions/system'; 2 | import * as uiActions from './actions/ui'; 3 | 4 | const actions = { 5 | ...systemActions, 6 | ...uiActions, 7 | }; 8 | 9 | export default actions; 10 | -------------------------------------------------------------------------------- /src/renderer/view/App/StartScreen.css: -------------------------------------------------------------------------------- 1 | 2 | .startScreen { 3 | background-color: white; 4 | width: 100%; 5 | height: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | } -------------------------------------------------------------------------------- /src/renderer/state/selectors.js: -------------------------------------------------------------------------------- 1 | 2 | import * as derived from './selectors/derived'; 3 | import * as simple from './selectors/simple'; 4 | 5 | const selectors = { 6 | ...derived, 7 | ...simple, 8 | }; 9 | 10 | export default selectors; 11 | -------------------------------------------------------------------------------- /src/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowJs": true, 5 | "checkJs": false, 6 | "sourceMap": true, 7 | "noImplicitAny": true, 8 | "module": "commonjs", 9 | "target": "es5", 10 | }, 11 | } -------------------------------------------------------------------------------- /src/renderer/components/ProcessingScreen.css: -------------------------------------------------------------------------------- 1 | .processingScreen { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .processingScreenText { 10 | text-transform: uppercase; 11 | } -------------------------------------------------------------------------------- /src/renderer/view/App/CloseScreen.css: -------------------------------------------------------------------------------- 1 | .closeScreen { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .closeScreenText { 10 | color: grey; 11 | text-transform: uppercase; 12 | } -------------------------------------------------------------------------------- /src/renderer/view/App/StartScreen/ProgressBar.css: -------------------------------------------------------------------------------- 1 | .progressBarContainer { 2 | border-radius: 2px; 3 | overflow: hidden; 4 | width: 30em; 5 | background-color: #dedede; 6 | } 7 | .progressBarReady { 8 | height: 0.25em; 9 | background-color: orange; 10 | } -------------------------------------------------------------------------------- /src/shared/data/assets/album1.js: -------------------------------------------------------------------------------- 1 | 2 | import cover from './files/jpg.jpg'; 3 | import track1 from './track1'; 4 | 5 | const album1 = { 6 | title: 'Red Flower', 7 | autoArtist: 'DEgITx', 8 | tracks: [track1], 9 | cover, 10 | }; 11 | 12 | export default album1; 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/renderer/components/CustomTextInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './CustomTextInput.css'; 4 | 5 | const CustomTextInput = props => ( 6 | 7 | ); 8 | 9 | export default CustomTextInput; 10 | -------------------------------------------------------------------------------- /src/main/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "node": true 6 | }, 7 | "settings": { 8 | "import/core-modules": [ 9 | "electron" 10 | ] 11 | }, 12 | "rules": { 13 | "import/no-unresolved": "off" 14 | } 15 | } -------------------------------------------------------------------------------- /src/renderer/components/SyncIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MdSync from 'react-icons/lib/md/sync'; 3 | 4 | import './SyncIcon.css'; 5 | 6 | const SyncCover = () => ( 7 | 10 | ); 11 | 12 | export default SyncCover; 13 | -------------------------------------------------------------------------------- /src/main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "~shared/*": ["./shared/*"], 6 | "~main/*": ["./main/*"], 7 | }, 8 | }, 9 | "include": [ 10 | "../main/**/*", 11 | "../shared/**/*" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/components/DNDarea.css: -------------------------------------------------------------------------------- 1 | .DNDAreaContainer { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | .DNDAreaInput { 8 | position: absolute; 9 | opacity: 0; 10 | cursor: pointer; 11 | width: 100%; 12 | height: 100%; 13 | top: 0; 14 | left: 0; 15 | } -------------------------------------------------------------------------------- /src/shared/data/assets/track1.js: -------------------------------------------------------------------------------- 1 | import { flacFile } from './files'; 2 | 3 | const track1 = { 4 | title: 'City Under Sky (Intro)', 5 | album: 'Red Flower', 6 | artist: 'DEgITx', 7 | file: flacFile, 8 | bitrate: 1052, 9 | hasCover: false, 10 | }; 11 | 12 | export default track1; 13 | -------------------------------------------------------------------------------- /src/shared/data/assets/track2.js: -------------------------------------------------------------------------------- 1 | import { mp3File } from './files'; 2 | 3 | const track2 = { 4 | title: 'Memories (Memories EP Version)', 5 | artist: 'DEgITx', 6 | album: 'Memories', 7 | file: mp3File, 8 | bitrate: 352, 9 | hasCover: false, 10 | }; 11 | 12 | export default track2; 13 | -------------------------------------------------------------------------------- /src/shared/data/assets/track3.js: -------------------------------------------------------------------------------- 1 | import { mp3CoverFile } from './files'; 2 | 3 | const track3 = { 4 | title: 'Main Theme', 5 | album: 'Untitled Love Story', 6 | artist: 'Borrtex', 7 | file: mp3CoverFile, 8 | bitrate: 320, 9 | hasCover: true, 10 | }; 11 | 12 | export default track3; 13 | -------------------------------------------------------------------------------- /src/shared/utils/checkFsFileMime.js: -------------------------------------------------------------------------------- 1 | import mime from 'mime'; 2 | 3 | const checkFsFileMime = (filePath, mimePrefix) => { 4 | const type = mime.getType(filePath); 5 | if (type) { 6 | return type.startsWith(mimePrefix); 7 | } 8 | return false; 9 | }; 10 | 11 | export default checkFsFileMime; 12 | -------------------------------------------------------------------------------- /src/shared/utils/normalizeAlbumTrackForPlaylist.js: -------------------------------------------------------------------------------- 1 | 2 | import getRandomString from '~shared/utils/getRandomString'; 3 | 4 | const normalizeAlbumTrackForPlaylist = ({ title, artist, audio }) => ({ 5 | title, artist, audio, id: getRandomString(), 6 | }); 7 | 8 | export default normalizeAlbumTrackForPlaylist; 9 | -------------------------------------------------------------------------------- /src/shared/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "node": true, 6 | "browser": true 7 | }, 8 | "settings": { 9 | "import/core-modules": [ 10 | "electron" 11 | ] 12 | }, 13 | "rules": { 14 | "import/no-unresolved": "off" 15 | } 16 | } -------------------------------------------------------------------------------- /src/shared/utils/getLatestRelease.js: -------------------------------------------------------------------------------- 1 | const assetsApiLink = 'https://api.github.com/repos/pathephone/pathephone-desktop/releases/latest'; 2 | 3 | const getLatestRelease = async () => { 4 | const res = await fetch(assetsApiLink, { method: 'GET' }); 5 | return res.json(); 6 | }; 7 | 8 | export default getLatestRelease; 9 | -------------------------------------------------------------------------------- /src/main/methods/startMainWindowLifecycle.js: -------------------------------------------------------------------------------- 1 | const startMainWindowLifecycle = ({ mainWindow }) => { 2 | mainWindow.on('close', (e) => { 3 | if (!process.platform === 'linux') { 4 | mainWindow.hide(); 5 | e.preventDefault(); 6 | } 7 | }); 8 | }; 9 | 10 | export default startMainWindowLifecycle; 11 | -------------------------------------------------------------------------------- /src/renderer/rootSaga.js: -------------------------------------------------------------------------------- 1 | import { take, call } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import startApp from './sagas/startApp'; 6 | 7 | function* rootSaga() { 8 | yield take(actions.systemAppRootMounted); 9 | yield call(startApp); 10 | } 11 | 12 | export default rootSaga; 13 | -------------------------------------------------------------------------------- /src/renderer/view/App/LockScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './LockScreen.css'; 4 | import e2e from '~shared/data/e2e'; 5 | 6 | const LockScreen = () => ( 7 |
8 | processing... 9 |
10 | ); 11 | 12 | export default LockScreen; 13 | -------------------------------------------------------------------------------- /src/shared/utils/normalizeCollectionAlbum.js: -------------------------------------------------------------------------------- 1 | const normalizeCollectionAlbum = ({ 2 | cid, data: { 3 | cover, artist, title, 4 | }, 5 | }) => ({ 6 | albumCid: cid, 7 | albumArtist: artist, 8 | albumTitle: title, 9 | albumCoverCid: cover.image, 10 | }); 11 | 12 | export default normalizeCollectionAlbum; 13 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar/TrackInfo.css: -------------------------------------------------------------------------------- 1 | .playerTrackInfo { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | padding: 0 1em; 6 | } 7 | 8 | .playerTrackArtist { 9 | color: grey; 10 | } 11 | 12 | .playerTrackInfoRight { 13 | color: grey; 14 | } -------------------------------------------------------------------------------- /src/renderer/view/App/Root.css: -------------------------------------------------------------------------------- 1 | .root { 2 | width: 100%; 3 | height: 100%; 4 | font-size: calc(1em + (1vw / 5)); 5 | } 6 | 7 | .root * { 8 | word-wrap: break-word; 9 | max-width: 100%; 10 | min-width: 0; 11 | } 12 | 13 | .root img { 14 | display: block; 15 | flex-shrink: 0; 16 | align-self: inherit; 17 | } -------------------------------------------------------------------------------- /src/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "~shared/*": ["./shared/*"], 6 | "~e2e/*": ["./e2e/*"], 7 | "~reusable/*": ["./e2e/reusable/*"], 8 | } 9 | }, 10 | "include": [ 11 | "../e2e/**/*", 12 | "../shared/**/*" 13 | ] 14 | } -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread.js: -------------------------------------------------------------------------------- 1 | import createThreadReducer from '~shared/utils/createThreadReducer'; 2 | 3 | import getAlbumCandidatesFromFsItems from './getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems'; 4 | 5 | createThreadReducer(({ payload }) => getAlbumCandidatesFromFsItems(payload)); 6 | -------------------------------------------------------------------------------- /src/renderer/components/CoverPreview.css: -------------------------------------------------------------------------------- 1 | .coverPreviewContainer { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .coverPreviewImage { 10 | width: 100%; 11 | } 12 | 13 | .coverPreviewNoCoverIcon { 14 | font-size: 5em; 15 | color: lightgray; 16 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist.css: -------------------------------------------------------------------------------- 1 | 2 | .playlist { 3 | font-size: 0.85em; 4 | grid-area: playlist; 5 | background-color: #f2f2f2; 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .playlist__empty-message { 11 | padding: 1em; 12 | text-align: center; 13 | text-transform: uppercase; 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/data/defaultIPFSDaemonConfig.js: -------------------------------------------------------------------------------- 1 | 2 | const defaultIPFSDaemonConfig = { 3 | Addresses: { 4 | API: '/ip4/127.0.0.1/tcp/0', 5 | Gateway: '/ip4/127.0.0.1/tcp/0', 6 | Swarm: [ 7 | '/ip4/0.0.0.0/tcp/0', 8 | '/ip6/::/tcp/0', 9 | ], 10 | }, 11 | }; 12 | 13 | export default defaultIPFSDaemonConfig; 14 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/PlayerConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import Player from './Player'; 6 | 7 | const mapStateToProps = state => ({ 8 | isActive: selectors.isPlayerActive(state), 9 | }); 10 | 11 | export default connect(mapStateToProps)(Player); 12 | -------------------------------------------------------------------------------- /src/shared/utils/getAudioMetadataFromFsFile.js: -------------------------------------------------------------------------------- 1 | import { parseFile } from 'music-metadata'; 2 | 3 | const defaultOptions = { 4 | skipCovers: true, 5 | }; 6 | 7 | const getAudioMetadataFromFsFile = (filePath, options = defaultOptions) => ( 8 | parseFile(filePath, options) 9 | ); 10 | 11 | export default getAudioMetadataFromFsFile; 12 | -------------------------------------------------------------------------------- /.appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Visual Studio 2017 2 | 3 | platform: 4 | - x64 5 | 6 | cache: 7 | - node_modules -> package.json 8 | - '%USERPROFILE%\.electron' 9 | 10 | install: 11 | - ps: Install-Product node 9 x64 12 | - yarn 13 | 14 | build_script: 15 | - yarn test 16 | 17 | test: false 18 | 19 | branches: 20 | only: 21 | - master -------------------------------------------------------------------------------- /src/main/threads/getTracksFromFsFiles.thread.js: -------------------------------------------------------------------------------- 1 | import createThreadReducer from '~shared/utils/createThreadReducer'; 2 | 3 | import getTracksFromFsFiles from './getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidatesFromFiles/getTracksFromFsFiles'; 4 | 5 | createThreadReducer(({ payload }) => getTracksFromFsFiles(payload)); 6 | -------------------------------------------------------------------------------- /src/shared/utils/getLocaleCode.js: -------------------------------------------------------------------------------- 1 | import { IS_PRODUCTION } from '~shared/config'; 2 | 3 | const getLocaleCode = () => { 4 | let code = 'en'; 5 | if (IS_PRODUCTION) { 6 | if (navigator && navigator.language.startsWith('ru')) { 7 | code = 'ru'; 8 | } 9 | } 10 | return code; 11 | }; 12 | 13 | export default getLocaleCode; 14 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/SelectedActions.css: -------------------------------------------------------------------------------- 1 | 2 | .selectedActions { 3 | border-top: 1px solid #ededed; 4 | width: 100%; 5 | display: flex; 6 | align-items: center; 7 | flex-shrink: 0; 8 | } 9 | 10 | .selectedActions > * { 11 | margin: 0.5em; 12 | } 13 | 14 | .selectedActionsRight { 15 | margin-left: auto; 16 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero.css: -------------------------------------------------------------------------------- 1 | 2 | .aboutHero { 3 | width: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | } 8 | 9 | .aboutHeroSocial { 10 | padding: 1em 0; 11 | margin: 2.5em 0; 12 | display: flex; 13 | justify-content: center; 14 | border-top: 1px solid lightgrey; 15 | } -------------------------------------------------------------------------------- /src/renderer/view/App/Root.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './Root.css'; 5 | 6 | const Root = ({ children }) => ( 7 |
8 | {children} 9 |
10 | ); 11 | 12 | Root.propTypes = { 13 | children: propTypes.node.isRequired, 14 | }; 15 | 16 | export default Root; 17 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar.css: -------------------------------------------------------------------------------- 1 | .playerTrackBar { 2 | position: relative; 3 | display: flex; 4 | position: relative; 5 | align-items: center; 6 | flex-basis: 70%; 7 | height: 100%; 8 | } 9 | 10 | .playerTrackBar > * { 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | width: 100%; 15 | height: 100%; 16 | } -------------------------------------------------------------------------------- /src/shared/utils/reduxSagaTicker.js: -------------------------------------------------------------------------------- 1 | 2 | import { eventChannel } from 'redux-saga'; 3 | 4 | function reduxSagaTicker(interval) { 5 | return eventChannel((emit) => { 6 | const intervalId = setInterval(() => emit(true), interval); 7 | return () => { 8 | clearInterval(intervalId); 9 | }; 10 | }); 11 | } 12 | 13 | export default reduxSagaTicker; 14 | -------------------------------------------------------------------------------- /src/renderer/components/CustomTextInput.css: -------------------------------------------------------------------------------- 1 | 2 | .customTextInput { 3 | padding: 1em 0; 4 | outline: none; 5 | border: none; 6 | background-color: transparent; 7 | border-bottom: 1px solid lightgrey; 8 | width: 20em; 9 | } 10 | 11 | 12 | .customTextInput:invalid { 13 | border-color: red; 14 | } 15 | 16 | .customTextInput:focus { 17 | border-color: orange; 18 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/NewReleaseCard.css: -------------------------------------------------------------------------------- 1 | .newReleaseCardContainer { 2 | text-align: center; 3 | border-radius: 2px; 4 | border: 1px solid var(--accent-color); 5 | padding: 0 5em 2em 5em; 6 | } 7 | 8 | .newReleaseCardTitle { 9 | text-transform: uppercase; 10 | } 11 | 12 | .githubReleaseLink { 13 | color: var(--accent-color); 14 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHeroConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import AboutHero from './AboutHero'; 6 | 7 | const mapStateToProps = state => ({ 8 | hasNewReleaseCard: !!selectors.getNewRelease(state), 9 | }); 10 | 11 | export default connect(mapStateToProps)(AboutHero); 12 | -------------------------------------------------------------------------------- /src/shared/data/assets/files.js: -------------------------------------------------------------------------------- 1 | export { default as txtFile } from './files/txt.txt'; 2 | export { default as svgFile } from './files/svg.svg'; 3 | export { default as jpgFile } from './files/jpg.jpg'; 4 | export { default as mp3File } from './files/mp3.mp3'; 5 | export { default as mp3CoverFile } from './files/mp3_cover.mp3'; 6 | export { default as flacFile } from './files/flac.flac'; 7 | -------------------------------------------------------------------------------- /src/e2e/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "globals": { 9 | "expect": true 10 | }, 11 | "rules": { 12 | "import/no-unresolved": "off", 13 | "import/no-extraneous-dependencies": "off", 14 | "func-names": "off", 15 | "global-require": "off" 16 | } 17 | } -------------------------------------------------------------------------------- /src/e2e/tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import chai from 'chai'; 3 | import chaiAsPromised from 'chai-as-promised'; 4 | import 'chai/register-expect'; // Using Expect style 5 | 6 | chai.use(chaiAsPromised); 7 | 8 | describe('testing app', function () { 9 | this.timeout(30000); 10 | require('./tests/sharePageTests'); 11 | require('./tests/discoverPageTests'); 12 | }); 13 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/DiscoverPageBody/NoSearchResultsScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ParagraphScreen from '~components/ParagraphScreen'; 3 | import i18n from '~shared/data/i18n'; 4 | 5 | const NoSearchResultsScreen = () => ( 6 | 7 | ); 8 | 9 | export default NoSearchResultsScreen; 10 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/TracklistConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import Tracklist from './Tracklist'; 6 | 7 | 8 | const mapStateToProps = state => ({ 9 | tracksIndexes: selectors.getPlaylistTracksIndexes(state), 10 | }); 11 | 12 | export default connect(mapStateToProps)(Tracklist); 13 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/PlaylistControlsConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import actions from '#actions'; 4 | 5 | import PlaylistControls from './PlaylistControls'; 6 | 7 | const mapDispatchToProps = { 8 | onClearPlaylist: actions.uiPlaylistCleared, 9 | }; 10 | 11 | export default connect(null, mapDispatchToProps)(PlaylistControls); 12 | -------------------------------------------------------------------------------- /src/shared/data/assets.js: -------------------------------------------------------------------------------- 1 | import track1 from './assets/track1'; 2 | import track2 from './assets/track2'; 3 | import track3 from './assets/track3'; 4 | 5 | import album1 from './assets/album1'; 6 | import album2 from './assets/album2'; 7 | import album3 from './assets/album3'; 8 | 9 | export const albums = [album1, album2, album3]; 10 | export const tracks = [track1, track2, track3]; 11 | -------------------------------------------------------------------------------- /src/e2e/reusable/sharePage/shareDropZone.js: -------------------------------------------------------------------------------- 1 | import e2e from '~shared/data/e2e'; 2 | 3 | export function shareWaitForDropZoneExists() { 4 | const { app } = this; 5 | return app.client.waitForExist(e2e.SHARE_DROP_ZONE_ID); 6 | } 7 | 8 | export function shareDropZoneSelect(filePath) { 9 | const { app } = this; 10 | return app.client.chooseFile(e2e.SHARE_DROP_ZONE_ID, filePath); 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/ProgressBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './ProgressBar.css'; 3 | 4 | const ProgressBar = () => ( 5 |
6 |
7 |
8 |
9 |
10 | ); 11 | 12 | export default ProgressBar; 13 | -------------------------------------------------------------------------------- /src/renderer/components/ErrorMessage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './ErrorMessage.css'; 5 | 6 | const ErrorMessage = ({ message }) => ( 7 |

8 | {message} 9 |

10 | ); 11 | 12 | ErrorMessage.propTypes = { 13 | message: propTypes.string.isRequired, 14 | }; 15 | 16 | export default ErrorMessage; 17 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage.css: -------------------------------------------------------------------------------- 1 | 2 | .donatePage { 3 | padding: 1em; 4 | } 5 | 6 | .donate-pill { 7 | display: flex; 8 | } 9 | .donate-pill__coin { 10 | padding: 0.5em; 11 | background-color: lightskyblue; 12 | text-transform: capitalize; 13 | } 14 | .donate-pill__address { 15 | padding: 0.5em; 16 | background-color: bisque; 17 | margin-left: auto; 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen.css: -------------------------------------------------------------------------------- 1 | .readyScreen { 2 | width: 100%; 3 | height: 100%; 4 | display: grid; 5 | justify-items: stretch; 6 | align-items: stretch; 7 | grid-template-columns: 17.5em auto 17.5em; 8 | grid-template-rows: calc(100% - 8em) 6em 2em; 9 | grid-template-areas: 10 | "navigation page playlist" 11 | "player player player" 12 | "indibar indibar indibar"; 13 | } -------------------------------------------------------------------------------- /src/shared/utils/formatBytes.js: -------------------------------------------------------------------------------- 1 | function formatBytes(a, b) { 2 | if (a === 0) { 3 | return '0 Bytes'; 4 | } 5 | const c = 1024; 6 | const d = b || 2; 7 | const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 8 | const f = Math.floor(Math.log(a) / Math.log(c)); 9 | return `${parseFloat((a / (c ** f)).toFixed(d))} ${e[f]}`; 10 | } 11 | 12 | export default formatBytes; 13 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareForm.css: -------------------------------------------------------------------------------- 1 | .shareForm { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .shareFormBody { 8 | padding: 1em; 9 | height: 100%; 10 | overflow-y: auto; 11 | } 12 | 13 | .shareFormControls { 14 | border-top: 1px solid lightgrey; 15 | padding: 0.5em; 16 | } 17 | 18 | .shareFormControls > * { 19 | margin: 0.5em; 20 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareDropZone.css: -------------------------------------------------------------------------------- 1 | .shareDropZoneContainer { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | .shareDropZoneIcon { 11 | font-size: 2.5em; 12 | } 13 | 14 | .shareDropZoneText { 15 | width: 15em; 16 | text-align: center; 17 | text-transform: uppercase; 18 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/NavigationConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withRouter } from 'react-router-dom'; 3 | 4 | import selectors from '#selectors'; 5 | 6 | import Navigation from './Navigation'; 7 | 8 | const mapStateToProps = state => ({ 9 | hasUpdateIndicator: !!selectors.getNewRelease(state), 10 | }); 11 | 12 | export default withRouter(connect(mapStateToProps)(Navigation)); 13 | -------------------------------------------------------------------------------- /src/shared/utils/getQualityCode.js: -------------------------------------------------------------------------------- 1 | import { 2 | QUALITY_LABEL_LOW, 3 | QUALITY_LABEL_HIGH, 4 | QUALITY_LABEL_LOSSLESS, 5 | } from '~shared/data/constants'; 6 | 7 | const getQualityCode = (bitrate) => { 8 | if (bitrate < 256) { 9 | return QUALITY_LABEL_LOW; 10 | } if (bitrate < 500) { 11 | return QUALITY_LABEL_HIGH; 12 | } 13 | return QUALITY_LABEL_LOSSLESS; 14 | }; 15 | 16 | export default getQualityCode; 17 | -------------------------------------------------------------------------------- /src/e2e/reusable/lockScreen.js: -------------------------------------------------------------------------------- 1 | import e2e from '~shared/data/e2e'; 2 | 3 | /* eslint-disable import/prefer-default-export */ 4 | 5 | export async function lockScreenWaitForNotExists() { 6 | await this.app.client.waitUntil(async () => { 7 | const isExisting = await this.app.client.isExisting(e2e.LOCK_SCREEN_ID); 8 | return !isExisting; 9 | }, 30000); 10 | await new Promise(resolve => setTimeout(resolve, 500)); 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/components/ProcessingScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import e2e from '~shared/data/e2e'; 3 | 4 | import './ProcessingScreen.css'; 5 | 6 | const ProcessingScreen = () => ( 7 |
11 | 12 | processing... 13 | 14 |
15 | ); 16 | 17 | export default ProcessingScreen; 18 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/PlaylistControls.css: -------------------------------------------------------------------------------- 1 | .playlist-controls { 2 | padding: 0.75em; 3 | flex-shrink: 0; 4 | border-bottom: 1px solid lightgray; 5 | } 6 | 7 | .playlist__clear-button:hover { 8 | background-color: rgba(10,10,10,0.1); 9 | } 10 | .playlist__clear-button:focus { 11 | outline: none; 12 | border: 1px solid orange; 13 | } 14 | .playlist__clear-button:active { 15 | color: var(--accent-color); 16 | } -------------------------------------------------------------------------------- /src/main/methods/createMainWindow/withNoNavigation.js: -------------------------------------------------------------------------------- 1 | import { shell } from 'electron'; 2 | 3 | const withNoNavigation = (mainWindow) => { 4 | const handleWillNavigate = (e, url) => { 5 | e.preventDefault(); 6 | if (url !== mainWindow.webContents.getURL()) { 7 | shell.openExternal(url); 8 | } 9 | }; 10 | mainWindow.webContents.on('will-navigate', handleWillNavigate); 11 | }; 12 | 13 | export default withNoNavigation; 14 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/PendingPlayer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import i18n from '~shared/data/i18n'; 3 | import e2e from '~shared/data/e2e'; 4 | 5 | const PendingPlayer = () => ( 6 |
10 | 11 | {i18n.NO_PLAYBACK} 12 | 13 |
14 | ); 15 | 16 | export default PendingPlayer; 17 | -------------------------------------------------------------------------------- /src/renderer/components/ParagraphScreen.css: -------------------------------------------------------------------------------- 1 | .paragraph-screen { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | flex-shrink: 1; 8 | margin: auto; 9 | } 10 | 11 | .paragraph-screen__container { 12 | max-width: 30em; 13 | text-align: center; 14 | display: inline-block; 15 | } 16 | 17 | .paragraph-screen__title { 18 | text-transform: uppercase; 19 | color: grey; 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "~shared/*": ["./shared/*"], 6 | "~components/*": ["./renderer/components/*"], 7 | "#actions": ["./renderer/state/actions.js"], 8 | "#selectors": ["./renderer/state/selectors.js"], 9 | }, 10 | "jsx": "react" 11 | }, 12 | "include": [ 13 | "../renderer/**/*", 14 | "../shared/**/*", 15 | ] 16 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/PlaylistTrackContainerConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import PlaylistTrackContainer from './PlaylistTrackContainer'; 6 | 7 | const mapStateToProps = (_, ownProps) => state => ({ 8 | isRemoved: !!selectors.getPlaylistRemovedByIndex(state)[ownProps.index], 9 | }); 10 | 11 | export default connect(mapStateToProps)(PlaylistTrackContainer); 12 | -------------------------------------------------------------------------------- /src/shared/utils/validateAlbum.js: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import albumInstanceSchema from '~shared/data/schemas/album/albumInstanceSchema'; 3 | 4 | const validateAlbum = (albumCandidate) => { 5 | const validator = new Ajv({ 6 | allErrors: true, 7 | }); 8 | const valid = validator.validate(albumInstanceSchema, albumCandidate); 9 | return { 10 | valid, errors: validator.errors, 11 | }; 12 | }; 13 | 14 | export default validateAlbum; 15 | -------------------------------------------------------------------------------- /src/shared/utils/filterFsFilesByMime.js: -------------------------------------------------------------------------------- 1 | import checkFsFileMime from './checkFsFileMime'; 2 | 3 | const filterFsFilesByMime = async (files, mime) => { 4 | const result = await Promise.all( 5 | files.map(async (file) => { 6 | const isMatch = await checkFsFileMime(file, mime); 7 | if (isMatch) return file; 8 | return undefined; 9 | }), 10 | ); 11 | return result.filter(f => !!f); 12 | }; 13 | 14 | export default filterFsFilesByMime; 15 | -------------------------------------------------------------------------------- /src/renderer/components/CustomTextInputRedux.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import CustomTextInput from '~components/CustomTextInput'; 5 | 6 | const CustomTextInputRedux = ({ input }) => ( 7 | 8 | ); 9 | 10 | CustomTextInputRedux.propTypes = { 11 | input: propTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types 12 | }; 13 | 14 | export default CustomTextInputRedux; 15 | -------------------------------------------------------------------------------- /src/renderer/view/App/StartScreen/ProgressBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './ProgressBar.css'; 5 | 6 | const ProgressBar = ({ percent }) => ( 7 |
8 |
9 |
10 | ); 11 | 12 | ProgressBar.propTypes = { 13 | percent: propTypes.number.isRequired, 14 | }; 15 | 16 | export default ProgressBar; 17 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/SocialLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './SocialLink.css'; 5 | 6 | const SocialLink = ({ link, children }) => ( 7 | 8 | {children} 9 | 10 | ); 11 | 12 | SocialLink.propTypes = { 13 | link: propTypes.string.isRequired, 14 | children: propTypes.node.isRequired, 15 | }; 16 | 17 | export default SocialLink; 18 | -------------------------------------------------------------------------------- /src/main/methods/setAppMenu.js: -------------------------------------------------------------------------------- 1 | import { Menu } from 'electron'; 2 | 3 | import { devMenuTemplate } from '../modules/menu/dev_menu_template'; 4 | import { editMenuTemplate } from '../modules/menu/edit_menu_template'; 5 | 6 | const setAppMenu = (env) => { 7 | const menus = [editMenuTemplate]; 8 | if (env.name !== 'production') { 9 | menus.push(devMenuTemplate); 10 | } 11 | Menu.setApplicationMenu(Menu.buildFromTemplate(menus)); 12 | }; 13 | 14 | export default setAppMenu; 15 | -------------------------------------------------------------------------------- /src/renderer/state/domains/volume.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'volume'; 4 | 5 | const initialState = 0.7; 6 | 7 | export const getVolume = state => state[DOMAIN]; 8 | 9 | const reducer = (state = initialState, action) => { 10 | const { type, payload } = action; 11 | switch (type) { 12 | case actions.uiVolumeChanged.toString(): 13 | return payload; 14 | default: 15 | return state; 16 | } 17 | }; 18 | 19 | export default reducer; 20 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/NewReleaseCardConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import NewReleaseCard from './NewReleaseCard'; 6 | 7 | const mapStateToProps = (state) => { 8 | const { name, assets } = selectors.getNewRelease(state); 9 | return { 10 | newReleaseName: name, 11 | newReleaseAssets: assets, 12 | }; 13 | }; 14 | 15 | export default connect(mapStateToProps)(NewReleaseCard); 16 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidatesFromFiles/sortTracks.js: -------------------------------------------------------------------------------- 1 | 2 | const handleSort = (a, b) => { 3 | const skip = typeof a.trackNumber !== 'number' || typeof b.trackNumber !== 'number'; 4 | if (skip) { 5 | return 0; 6 | } 7 | if (a.trackNumber < b.trackNumber) { 8 | return -1; 9 | } 10 | return 1; 11 | }; 12 | 13 | const sortTracks = (tracks) => { 14 | tracks.sort(handleSort); 15 | }; 16 | 17 | export default sortTracks; 18 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/PlaylistConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import Playlist from './Playlist'; 4 | 5 | import selectors from '#selectors'; 6 | import actions from '#actions'; 7 | 8 | const mapStateToProps = state => ({ 9 | hasTracklist: !selectors.isPlaylistEmpty(state), 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | onClearPlaylist: actions.uiPlaylistCleared, 14 | }; 15 | 16 | export default connect(mapStateToProps, mapDispatchToProps)(Playlist); 17 | -------------------------------------------------------------------------------- /src/renderer/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true 6 | }, 7 | "settings": { 8 | "import/core-modules": [ 9 | "electron" 10 | ] 11 | }, 12 | "rules": { 13 | "no-nested-ternary": "off", 14 | "import/no-unresolved": "off", 15 | "react/destructuring-assignment": "off", 16 | "jsx-a11y/label-has-for": [ "error", { 17 | "required": { 18 | "every": [ "id" ] 19 | } 20 | }] 21 | } 22 | } -------------------------------------------------------------------------------- /src/renderer/components/QualityLabel.css: -------------------------------------------------------------------------------- 1 | .quality-label--low, 2 | .quality-label--high, 3 | .quality-label--lossless { 4 | font-size: 0.6em; 5 | padding: 0.25em 0.5em; 6 | font-weight: bold; 7 | cursor: default; 8 | } 9 | 10 | .quality-label--low { 11 | background: orange; 12 | color: black; 13 | } 14 | 15 | .quality-label--high { 16 | background: var(--secondary-color); 17 | color: white; 18 | } 19 | 20 | .quality-label--lossless { 21 | background: lightseagreen; 22 | color: white; 23 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/SocialLink.css: -------------------------------------------------------------------------------- 1 | 2 | .socialLink { 3 | display: flex; 4 | align-items: center; 5 | border-radius: 2px; 6 | color: black; 7 | margin: 0 1em; 8 | padding: 0.5em 0.75em; 9 | text-decoration: none; 10 | } 11 | 12 | .socialLink:hover { 13 | background-color: rgba(10,10,10,0.1); 14 | } 15 | .socialLink:active { 16 | background-color: var(--accent-color); 17 | color: white; 18 | } 19 | 20 | .socialLink span { 21 | margin-left: 0.5em; 22 | } -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIndicatorsBarService.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | import startMetabinPeersRetriever from './startIndicatorBarService/startMetabinPeersRetriever'; 3 | import startIPFSStatsRetriever from './startIndicatorBarService/startIPFSStatsRetriever'; 4 | 5 | function* startIndicatorsBarService(args) { 6 | yield fork(startIPFSStatsRetriever, args); 7 | yield fork(startMetabinPeersRetriever, args); 8 | } 9 | 10 | export default startIndicatorsBarService; 11 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/DiscoverPageBody/NoAlbumsScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import i18n from '~shared/data/i18n'; 4 | import e2e from '~shared/data/e2e'; 5 | 6 | import ParagraphScreen from '~components/ParagraphScreen'; 7 | 8 | const NoAlbumsScreen = () => ( 9 | 14 | ); 15 | 16 | export default NoAlbumsScreen; 17 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PageContainer from '~components/PageContainer'; 4 | 5 | import DonateCard from './DonatePage/DonateCard'; 6 | import AboutHeroConnected from './DonatePage/AboutHeroConnected'; 7 | 8 | import './DonatePage.css'; 9 | 10 | const DonatePage = () => ( 11 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default DonatePage; 18 | -------------------------------------------------------------------------------- /src/renderer/state/domains/legalAgreement.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'legalAgreement'; 4 | 5 | const initialState = false; 6 | 7 | export const isLegalAgreementGranted = state => state[DOMAIN]; 8 | 9 | const reducer = (state = initialState, action) => { 10 | const { type } = action; 11 | switch (type) { 12 | case actions.uiLegalAgreementGranted.toString(): 13 | return true; 14 | default: 15 | return state; 16 | } 17 | }; 18 | 19 | export default reducer; 20 | -------------------------------------------------------------------------------- /src/renderer/state/domains/newRelease.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'newRelease'; 4 | 5 | const initialState = null; 6 | 7 | export const getNewRelease = state => state[DOMAIN]; 8 | 9 | const reducer = (state = initialState, action) => { 10 | const { type, payload } = action; 11 | switch (type) { 12 | case actions.systemNewRelaseDetected.toString(): 13 | return payload.release; 14 | default: 15 | return state; 16 | } 17 | }; 18 | 19 | export default reducer; 20 | -------------------------------------------------------------------------------- /src/shared/utils/getSagaChannelsMap.js: -------------------------------------------------------------------------------- 1 | import { channel } from 'redux-saga'; 2 | 3 | const getSagaChannelsMap = async (names) => { 4 | const handleMap = () => channel(); 5 | const channelsSet = await Promise.all( 6 | names.map(handleMap), 7 | ); 8 | const handleReduce = (aggr, name, index) => { 9 | aggr[name] = channelsSet[index]; // eslint-disable-line no-param-reassign 10 | return aggr; 11 | }; 12 | return names.reduce(handleReduce, {}); 13 | }; 14 | 15 | export default getSagaChannelsMap; 16 | -------------------------------------------------------------------------------- /src/e2e/reusable/navigation.js: -------------------------------------------------------------------------------- 1 | import e2e from '~shared/data/e2e'; 2 | 3 | export async function openSharePage() { 4 | const { app } = this; 5 | await app.client.waitForExist(e2e.NAV_SHARE_LINK_ID); 6 | await app.client.click(e2e.NAV_SHARE_LINK_ID); 7 | return app.client.waitForExist(e2e.SHARE_PAGE_ID); 8 | } 9 | 10 | export async function openDiscoverPage() { 11 | const { app } = this; 12 | await app.client.click(e2e.NAV_DISCOVER_LINK_ID); 13 | await app.client.waitForExist(e2e.DISCOVER_PAGE_ID); 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/NewReleaseCard/AssetsButtons/AssetDownloadButton.css: -------------------------------------------------------------------------------- 1 | .assetDownloadButton { 2 | border-radius: 2px; 3 | color: var(--accent-color); 4 | border: 1px solid var(--accent-color); 5 | margin: 0.5em; 6 | padding: 0.5em 0.75em; 7 | text-decoration: none; 8 | } 9 | 10 | .assetDownloadButton:hover { 11 | background-color: rgba(10,10,10,0.1); 12 | } 13 | .assetDownloadButton:active { 14 | background-color: var(--accent-color); 15 | color: white; 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/components/CustomButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './CustomButton.css'; 5 | 6 | /* eslint-disable react/button-has-type */ 7 | 8 | const CustomButton = ({ children, ...restProps }) => ( 9 | 12 | ); 13 | 14 | CustomButton.propTypes = { 15 | children: propTypes.node.isRequired, 16 | type: propTypes.string.isRequired, 17 | }; 18 | 19 | export default CustomButton; 20 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsCollectionInfo.js: -------------------------------------------------------------------------------- 1 | import { put, call } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | function* updateAlbumsCollectionInfo({ getAlbumsCollectionInfo }) { 6 | const dbInfo = yield call(getAlbumsCollectionInfo); 7 | yield put(actions.systemAlbumsCollectionInfoRecieved(dbInfo)); 8 | } 9 | 10 | function* startAlbumsCollectionInfo(apis) { 11 | yield updateAlbumsCollectionInfo(apis); 12 | } 13 | 14 | export default startAlbumsCollectionInfo; 15 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/VolumeInputConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import VolumeInput from './VolumeInput'; 7 | 8 | const mapStateToProps = state => ({ 9 | currentVolume: selectors.getVolume(state), 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | onVolumeChange: actions.uiVolumeChanged, 14 | }; 15 | 16 | export default connect(mapStateToProps, mapDispatchToProps)(VolumeInput); 17 | -------------------------------------------------------------------------------- /src/shared/utils/getCurrentOSIcon.js: -------------------------------------------------------------------------------- 1 | import FaWindows from 'react-icons/lib/fa/windows'; 2 | import FaLinux from 'react-icons/lib/fa/linux'; 3 | import FaApple from 'react-icons/lib/fa/apple'; 4 | import { IS_WINDOWS, IS_MAC, IS_LINUX } from '~shared/config'; 5 | 6 | const getCurrentOSIcon = () => { 7 | if (IS_WINDOWS) { 8 | return FaWindows; 9 | } if (IS_MAC) { 10 | return FaApple; 11 | } if (IS_LINUX) { 12 | return FaLinux; 13 | } 14 | return undefined; 15 | }; 16 | 17 | export default getCurrentOSIcon; 18 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/NotificationsConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import Notifications from './Notifications'; 7 | 8 | const mapStateToProps = state => ({ 9 | notifications: selectors.getNotifications(state), 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | onToastClick: actions.uiNotificationToastRemoved, 14 | }; 15 | 16 | export default connect(mapStateToProps, mapDispatchToProps)(Notifications); 17 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | import TrackInfo from './TrackBar/TrackInfo'; 5 | import TrackTimeline from './TrackBar/TrackTimeline'; 6 | import TrackBuffer from './TrackBar/TrackBuffer'; 7 | 8 | import './TrackBar.css'; 9 | 10 | const TrackBar = props => ( 11 |
12 | 13 | 14 | 15 |
16 | ); 17 | 18 | export default TrackBar; 19 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startPlaybackService.js: -------------------------------------------------------------------------------- 1 | import { take, select, put } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | import selectors from '#selectors'; 5 | 6 | function* startPlaybackService() { 7 | while (true) { 8 | yield take(actions.systemAudioEnded); 9 | const isRepeat = yield select(selectors.shouldPlaylistBeRepeated); 10 | if (isRepeat) { 11 | yield put(actions.systemRepeatedPlaylistEnded()); 12 | } 13 | } 14 | } 15 | 16 | export default startPlaybackService; 17 | -------------------------------------------------------------------------------- /src/shared/utils/createWorkerReducer.js: -------------------------------------------------------------------------------- 1 | 2 | const createWorkerReducer = (handler) => { 3 | onmessage = async ({ data }) => { 4 | const { requestId, ...restParams } = data; 5 | if (requestId) { 6 | const responseId = requestId; 7 | try { 8 | const output = await handler(restParams); 9 | postMessage({ responseId, payload: output }); 10 | } catch (e) { 11 | postMessage({ responseId, errorMessage: e.message }); 12 | } 13 | } 14 | }; 15 | }; 16 | 17 | export default createWorkerReducer; 18 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIPFSCachingService/startIPFSFilesCatch/cachePlaylistTracks.js: -------------------------------------------------------------------------------- 1 | import { call, select } from 'redux-saga/effects'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | function* cachePlaylistTracks(api) { 6 | const { cacheIPFSFilesByCIDs } = api; 7 | const uncachedCIDs = yield select(selectors.getPlaylistUncachedTracksCIDs); 8 | try { 9 | yield call(cacheIPFSFilesByCIDs, uncachedCIDs); 10 | } catch (e) { 11 | console.error(e); 12 | } 13 | } 14 | 15 | export default cachePlaylistTracks; 16 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/getApis/getStorageApi.js: -------------------------------------------------------------------------------- 1 | import createWorkerController from '~shared/utils/createWorkerController'; 2 | import ipc from '~shared/data/ipc'; 3 | 4 | import DbApiWorker from './getStorageApi/dbApi.worker'; 5 | import getAlbumsCollectionApi from './getStorageApi/getAlbumsCollectionApi'; 6 | 7 | const getStorageApi = async () => { 8 | const worker = createWorkerController(DbApiWorker); 9 | await worker.call({ type: ipc.START_DB }); 10 | return getAlbumsCollectionApi(worker); 11 | }; 12 | 13 | export default getStorageApi; 14 | -------------------------------------------------------------------------------- /src/renderer/components/PageContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './PageContainer.css'; 5 | 6 | const PageContainer = ({ children, className, ...restProps }) => ( 7 |
8 | {children} 9 |
10 | ); 11 | 12 | PageContainer.defaultProps = { 13 | className: '', 14 | }; 15 | 16 | PageContainer.propTypes = { 17 | children: propTypes.node.isRequired, 18 | className: propTypes.string, 19 | }; 20 | 21 | export default PageContainer; 22 | -------------------------------------------------------------------------------- /src/renderer/state/domains/cachedCIDs.js: -------------------------------------------------------------------------------- 1 | 2 | import actions from '#actions'; 3 | 4 | const DOMAIN = 'cachedCIDs'; 5 | 6 | const initialState = {}; 7 | 8 | export const getCachedCIDs = state => state[DOMAIN]; 9 | 10 | const reducer = (state = initialState, action) => { 11 | const { type, payload } = action; 12 | switch (type) { 13 | case actions.systemIPFSFileCached.toString(): 14 | return { 15 | ...state, 16 | [payload]: true, 17 | }; 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | export default reducer; 24 | -------------------------------------------------------------------------------- /src/main/methods/withSingleInstanceBehaviour.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | 3 | const withSingleInstanceBehaviour = (state) => { 4 | const isSecondInstance = app.makeSingleInstance(() => { 5 | // Someone tried to run a second instance, we should focus our window. 6 | const { mainWindow } = state; 7 | if (mainWindow) { 8 | mainWindow.show(); 9 | } 10 | }); 11 | 12 | if (isSecondInstance) { 13 | console.log('-- second instance detected, exit'); 14 | app.exit(); 15 | } 16 | }; 17 | 18 | export default withSingleInstanceBehaviour; 19 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIPFSCachingService.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | import startCachedIPFSFilesReciever from './startIPFSCachingService/startCachedIPFSFilesReciever'; 3 | import startIPFSFilesCatch from './startIPFSCachingService/startIPFSFilesCatch'; 4 | 5 | function* startIPFSCachingService(api) { 6 | try { 7 | yield fork(startCachedIPFSFilesReciever, api); 8 | yield fork(startIPFSFilesCatch, api); 9 | } catch (e) { 10 | console.error(e); 11 | } 12 | } 13 | 14 | export default startIPFSCachingService; 15 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import ActivePlayerConnected from './Player/ActivePlayerConnected'; 5 | import PendingPlayer from './Player/PendingPlayer'; 6 | 7 | import './Player.css'; 8 | 9 | const Player = ({ isActive }) => { 10 | if (isActive) { 11 | return ( 12 | 13 | ); 14 | } 15 | return ; 16 | }; 17 | 18 | Player.propTypes = { 19 | isActive: propTypes.bool.isRequired, 20 | }; 21 | 22 | export default Player; 23 | -------------------------------------------------------------------------------- /src/main/methods/loadMainWindow.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import url from 'url'; 3 | 4 | import { IS_DEVELOPMENT } from '~shared/config'; 5 | 6 | const loadMainWindow = (mainWindow) => { 7 | if (IS_DEVELOPMENT) { 8 | mainWindow.loadURL(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`); 9 | } else { 10 | mainWindow.loadURL( 11 | url.format({ 12 | pathname: path.join(__dirname, 'index.html'), 13 | protocol: 'file', 14 | slashes: true, 15 | }), 16 | ); 17 | } 18 | }; 19 | 20 | export default loadMainWindow; 21 | -------------------------------------------------------------------------------- /src/renderer/state/selectors/simple.js: -------------------------------------------------------------------------------- 1 | export * from '../domains/appStart'; 2 | export * from '../domains/audio'; 3 | export * from '../domains/playlist'; 4 | export * from '../domains/volume'; 5 | export * from '../domains/discoverPage'; 6 | export * from '../domains/discoverSelected'; 7 | export * from '../domains/share'; 8 | export * from '../domains/cachedCIDs'; 9 | export * from '../domains/notifications'; 10 | export * from '../domains/ipfsInfo'; 11 | export * from '../domains/legalAgreement'; 12 | export * from '../domains/albumsInfo'; 13 | export * from '../domains/newRelease'; 14 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePageConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import SharePage from './SharePage'; 7 | 8 | const mapStateToProps = state => ({ 9 | hasProcessingScreen: selectors.isShareProcessing(state), 10 | hasEditForm: selectors.isShareCandidatesRecieved(state), 11 | }); 12 | 13 | const mapDispatchToProps = { 14 | onFilesSelect: actions.uiShareItemsSelected, 15 | }; 16 | 17 | export default connect(mapStateToProps, mapDispatchToProps)(SharePage); 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | jobs: 3 | include: 4 | - if: branch = master 5 | os: osx 6 | osx_image: xcode9.0 7 | language: node_js 8 | node_js: "9" 9 | 10 | cache: 11 | yarn: true 12 | directories: 13 | - node_modules 14 | 15 | before_install: git pull 16 | 17 | before_install: 18 | - brew install rpm 19 | - brew install dpkg 20 | - brew install jq 21 | 22 | script: 23 | - yarn test 24 | 25 | deploy: 26 | provider: script 27 | skip_cleanup: true 28 | script: 29 | - npx travis-deploy-once "npx semantic-release@15" 30 | on: 31 | branch: master -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/DonateCard/DonatePill.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | const DonatePill = ({ coin, address }) => ( 5 |
6 |
7 | 8 | {coin} 9 | 10 |
11 |
12 | {address} 13 |
14 |
15 | ); 16 | 17 | DonatePill.propTypes = { 18 | coin: propTypes.string.isRequired, 19 | address: propTypes.string.isRequired, 20 | }; 21 | 22 | export default DonatePill; 23 | -------------------------------------------------------------------------------- /src/shared/utils/getFolderContents.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const handleItemsFilter = file => !file.startsWith('.'); 5 | 6 | const getFolderContents = folderPath => new Promise((resolve, reject) => { 7 | fs.readdir(folderPath, async (err, items) => { 8 | if (err) reject(err); 9 | const filteredItems = items.filter(handleItemsFilter); 10 | const handleFullPathJoin = itemName => path.join(folderPath, itemName); 11 | const output = filteredItems.map(handleFullPathJoin); 12 | resolve(output); 13 | }); 14 | }); 15 | 16 | export default getFolderContents; 17 | -------------------------------------------------------------------------------- /src/shared/data/schemas/album/shareCandidateSchema.js: -------------------------------------------------------------------------------- 1 | import dotProp from 'dot-prop-immutable'; 2 | 3 | import albumInstanceSchema from '~shared/data/schemas/album/albumInstanceSchema'; 4 | 5 | const localFileSchema = { type: 'string', minLength: 1 }; 6 | 7 | const tempShareCandidateSchema = dotProp.set( 8 | albumInstanceSchema, 9 | 'properties.cover.properties.image', 10 | localFileSchema, 11 | ); 12 | 13 | const shareCandidateSchema = dotProp.set( 14 | tempShareCandidateSchema, 15 | 'properties.tracks.items.properties.audio', 16 | localFileSchema, 17 | ); 18 | 19 | export default shareCandidateSchema; 20 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/PlaylistTrackContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | import PlaylistTrackConnected from './PlaylistTrackConnected'; 4 | 5 | const PlaylistTrackContainer = (props) => { 6 | const { 7 | isRemoved, 8 | index, 9 | } = props; 10 | if (isRemoved) return null; 11 | return ( 12 | 13 | ); 14 | }; 15 | 16 | PlaylistTrackContainer.propTypes = { 17 | isRemoved: propTypes.bool.isRequired, 18 | index: propTypes.string.isRequired, 19 | }; 20 | 21 | export default PlaylistTrackContainer; 22 | -------------------------------------------------------------------------------- /src/shared/utils/getPlaylistTracksFromAlbums.js: -------------------------------------------------------------------------------- 1 | import normalizeAlbumTrackForPlaylist from '~shared/utils/normalizeAlbumTrackForPlaylist'; 2 | 3 | const getPlaylistTracksFromAlbums = async ({ findAlbumsInCollectionByCids }, cids) => { 4 | const docs = await findAlbumsInCollectionByCids(cids); 5 | const handleReduce = (acc, { data }) => { 6 | const handleEach = (track) => { 7 | acc.push(normalizeAlbumTrackForPlaylist(track)); 8 | }; 9 | data.tracks.forEach(handleEach); 10 | return acc; 11 | }; 12 | return docs.reduce(handleReduce, []); 13 | }; 14 | 15 | export default getPlaylistTracksFromAlbums; 16 | -------------------------------------------------------------------------------- /src/renderer/view/App/CloseScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import ErrorMessage from '~components/ErrorMessage'; 5 | 6 | import './CloseScreen.css'; 7 | 8 | const CloseScreen = ({ errorMessage }) => ( 9 |
10 |

11 | closing app 12 |

13 | { 14 | errorMessage && ( 15 | 16 | ) 17 | } 18 |
19 | ); 20 | 21 | CloseScreen.propTypes = { 22 | errorMessage: propTypes.string.isRequired, 23 | }; 24 | 25 | export default CloseScreen; 26 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidatesFromFiles/writeMetadataPictureToFs.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | 5 | const writeMetadataPictureToFs = ({ data }) => new Promise((resolve, reject) => { 6 | const nowString = `${new Date().getTime()}`; 7 | const pathToWrite = path.resolve(os.tmpdir(), nowString); 8 | fs.writeFile(pathToWrite, data, (err) => { 9 | if (err) { 10 | reject(err); 11 | } else { 12 | resolve(pathToWrite); 13 | } 14 | }); 15 | }); 16 | 17 | export default writeMetadataPictureToFs; 18 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIPFSCachingService/startIPFSFilesCatch/cacheDiscoverAlbumsCovers.js: -------------------------------------------------------------------------------- 1 | import { call } from 'redux-saga/effects'; 2 | 3 | const handleMap = ({ albumCoverCid }) => albumCoverCid; 4 | 5 | const getAlbumsCoversCIDs = albums => albums.map(handleMap); 6 | 7 | function* cacheDiscoverAlbumsCovers(api, { payload }) { 8 | const { cacheIPFSFilesByCIDs } = api; 9 | const uncachedCIDs = getAlbumsCoversCIDs(payload); 10 | try { 11 | yield call(cacheIPFSFilesByCIDs, uncachedCIDs); 12 | } catch (e) { 13 | console.error(e); 14 | } 15 | } 16 | 17 | export default cacheDiscoverAlbumsCovers; 18 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/ControlsLeftConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import ControlsLeft from './ControlsLeft'; 7 | 8 | const mapStateToProps = state => ({ 9 | hasPauseIcon: !selectors.isPaused(state), 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | onPlayNextClick: actions.uiNextTrackPlayed, 14 | onPlayPreviousClick: actions.uiPreviousTrackPlayed, 15 | onPlaybackToggle: actions.uiPlaybackToggled, 16 | }; 17 | 18 | export default connect(mapStateToProps, mapDispatchToProps)(ControlsLeft); 19 | -------------------------------------------------------------------------------- /src/shared/utils/createThreadReducer.js: -------------------------------------------------------------------------------- 1 | 2 | const createThreadReducer = (handler) => { 3 | const handleMessage = async (data) => { 4 | const { requestId, ...restParams } = data; 5 | if (requestId) { 6 | const responseId = requestId; 7 | try { 8 | const output = await handler(restParams); 9 | process.send({ responseId, payload: output }); 10 | } catch (error) { 11 | console.error(error); 12 | process.send({ responseId, errorMessage: error.message }); 13 | } 14 | } 15 | }; 16 | process.on('message', handleMessage); 17 | }; 18 | 19 | export default createThreadReducer; 20 | -------------------------------------------------------------------------------- /src/shared/utils/getTargetReleaseAsset.js: -------------------------------------------------------------------------------- 1 | import { IS_WINDOWS, IS_MAC, IS_LINUX } from '~shared/config'; 2 | 3 | const getTargetReleaseAsset = (assets) => { 4 | const handleFind = (asset) => { 5 | if (IS_WINDOWS) { 6 | return asset.name.endsWith('.exe'); 7 | } if (IS_MAC) { 8 | return asset.name.endsWith('mac.zip') || asset.name.endsWith('.dmg'); 9 | } if (IS_LINUX) { 10 | return asset.name.endsWith('.rpm') || asset.name.endsWith('.deb') || asset.name.endsWith('.AppImage'); 11 | } 12 | return undefined; 13 | }; 14 | return assets.filter(handleFind); 15 | }; 16 | 17 | export default getTargetReleaseAsset; 18 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/ControlsRightConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import ControlsRight from './ControlsRight'; 7 | 8 | const mapStateToProps = state => ({ 9 | isShuffleTurnedOn: selectors.isShuffleTurnedOn(state), 10 | isRepeatTurnedOn: selectors.isRepeatTurnedOn(state), 11 | }); 12 | 13 | const mapDispatchToProps = { 14 | onToggleShuffle: actions.uiShuffleToggled, 15 | onToggleRepeat: actions.uiRepeatToggled, 16 | }; 17 | 18 | export default connect(mapStateToProps, mapDispatchToProps)(ControlsRight); 19 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/EqualizerIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './EqualizerIcon.css'; 3 | 4 | const EqualizerIcon = () => ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default EqualizerIcon; 16 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Notifications.css: -------------------------------------------------------------------------------- 1 | .notificationsContainer { 2 | position: fixed; 3 | top: 0; 4 | left: 50%; 5 | transform: translateX(-50%); 6 | } 7 | 8 | .notificationToastError, 9 | .notificationToastWarning, 10 | .notificationToastSuccess { 11 | display: block; 12 | width: 20em; 13 | border-radius: 2px; 14 | box-shadow: var(--material-shadow-2); 15 | color: white; 16 | padding: 1em; 17 | } 18 | 19 | .notificationToastError { 20 | background-color: red; 21 | } 22 | 23 | .notificationToastWarning { 24 | background-color: orange; 25 | } 26 | 27 | .notificationToastSuccess { 28 | background-color: green; 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/utils/getAudioMetadataFromFsFile.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import assert from 'assert'; 3 | 4 | import { tracks } from '~shared/data/assets'; 5 | 6 | import getAudioMetadataFromFsFile from './getAudioMetadataFromFsFile'; 7 | 8 | describe('readAudioMetadata()', () => { 9 | tracks.forEach((track) => { 10 | describe('read file metadata', () => { 11 | it('metadata matches', async () => { 12 | const { common } = await getAudioMetadataFromFsFile(track.file); 13 | assert.strictEqual(common.title, track.title); 14 | assert.strictEqual(common.album, track.album); 15 | }); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/shared/utils/secondsTohhmmss.js: -------------------------------------------------------------------------------- 1 | 2 | const secondsTohhmmss = (totalSeconds) => { 3 | const hours = Math.floor(totalSeconds / 3600); 4 | const minutes = Math.floor((totalSeconds - (hours * 3600)) / 60); 5 | let seconds = Math.floor(totalSeconds - (hours * 3600) - (minutes * 60)); 6 | 7 | // round seconds 8 | seconds = Math.round(seconds * 100) / 100; 9 | 10 | let result = (hours === 0 ? '' : `${hours < 10 ? `0${hours}` : hours}:`); 11 | result += (minutes === 0 ? '' : `${minutes < 10 ? `0${minutes}` : minutes}:`); 12 | result += seconds < 10 ? `0${seconds}` : seconds; 13 | return result; 14 | }; 15 | 16 | export default secondsTohhmmss; 17 | -------------------------------------------------------------------------------- /src/main/methods/startCommunication/startIpfsProcess.js: -------------------------------------------------------------------------------- 1 | import createThreadController from '~shared/utils/createThreadController'; 2 | 3 | import ipc from '~shared/data/ipc'; 4 | import beforeIpfsDaemonStart from './startIpfsProcess/beforeIpfsDaemonStart'; 5 | import getIpfsDaemonParams from './startIpfsProcess/getIpfsDaemonParams'; 6 | 7 | const startIpfsProcess = async () => { 8 | beforeIpfsDaemonStart(); 9 | const daemonParams = getIpfsDaemonParams(); 10 | const ipfsProcess = createThreadController('ipfs'); 11 | await ipfsProcess.call({ type: ipc.IPFS_START, payload: daemonParams }); 12 | return ipfsProcess; 13 | }; 14 | 15 | export default startIpfsProcess; 16 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidatesFromFiles/getCoverFromFsFiles.js: -------------------------------------------------------------------------------- 1 | import { basename } from 'path'; 2 | import filterFsFilesByMime from '~shared/utils/filterFsFilesByMime'; 3 | 4 | const getCoverFromFsFiles = async (files) => { 5 | const images = await filterFsFilesByMime(files, 'image/'); 6 | if (images.length > 0) { 7 | let frontCover; 8 | if (images.length > 1) { 9 | frontCover = images.find(filePath => basename(filePath).includes('front')); 10 | } 11 | if (frontCover) return frontCover; 12 | return images[0]; 13 | } 14 | return null; 15 | }; 16 | 17 | export default getCoverFromFsFiles; 18 | -------------------------------------------------------------------------------- /src/e2e/reusable/playlist.js: -------------------------------------------------------------------------------- 1 | import e2e from '~shared/data/e2e'; 2 | 3 | export function playlistWaitForTracklist() { 4 | return this.app.client.waitForExist(e2e.PLAYLIST_TRAKLIST_ID); 5 | } 6 | 7 | export async function playlistTracklistLengthEquals(expected) { 8 | const elements = await this.app.client 9 | .$$(`${e2e.PLAYLIST_TRAKLIST_ID} > *`); 10 | expect(elements.length).equal(expected); 11 | } 12 | 13 | export function playlistWaitForTrackByOrder(order) { 14 | return this.app.client 15 | .$(`${e2e.PLAYLIST_TRAKLIST_ID} > *:nth-child(${order})`); 16 | } 17 | 18 | export function playlistClear() { 19 | return this.app.client.click(e2e.PLAYLIST_CLEAR_BUTTON_ID); 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/utils/getBufferedAudioMap.js: -------------------------------------------------------------------------------- 1 | 2 | const getBufferedAudioMap = ({ buffered, duration }) => { 3 | if (!duration) return undefined; 4 | const bufferedMap = []; 5 | const percent = duration / 100; 6 | if (buffered.length > 0) { 7 | for (let i = 0; i < buffered.length; i += 1) { 8 | const start = buffered.start(i) / percent; 9 | const end = buffered.end(i) / percent; 10 | bufferedMap.push([start, end]); 11 | } 12 | } else { 13 | const start = buffered.start(0) / percent; 14 | const end = buffered.end(0) / percent; 15 | bufferedMap.push([start, end]); 16 | } 17 | return bufferedMap; 18 | }; 19 | 20 | export default getBufferedAudioMap; 21 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareFilesSelect/getAlbumCandidatesFromItems/getCandidatesFromFiles/getCoverFromFiles.js: -------------------------------------------------------------------------------- 1 | import { basename } from 'path'; 2 | 3 | const getCoverFromFiles = async (apis, files) => { 4 | const { filterFsFilesByMime } = apis; 5 | const images = await filterFsFilesByMime(files, 'image/'); 6 | if (images.length > 0) { 7 | let frontCover; 8 | if (images.length > 1) { 9 | frontCover = images.find(filePath => basename(filePath).includes('front')); 10 | } 11 | if (frontCover) return frontCover; 12 | return images[0]; 13 | } 14 | return undefined; 15 | }; 16 | 17 | export default getCoverFromFiles; 18 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/SearchBarConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import SearchBar from './SearchBar'; 7 | 8 | const mapStateToProps = state => ({ 9 | searchValue: selectors.getDiscoverSearchValue(state), 10 | albumsCount: selectors.getAlbumsCount(state), 11 | }); 12 | 13 | const mapDispatchToProps = { 14 | onInputChange: actions.uiDiscoverSearchValueChanged, 15 | onCancelSearch: actions.uiDiscoverSearchCleared, 16 | onFormSubmit: actions.uiDiscoverSearchPerformed, 17 | }; 18 | 19 | export default connect(mapStateToProps, mapDispatchToProps)(SearchBar); 20 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startTracksCache.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, fork, call } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import cachePlaylistTracks from './startTracksCache/cachePlaylistTracks'; 6 | import startCachedCIDsReciever from './startTracksCache/startCachedCIDsReciever'; 7 | 8 | function* startTracksCache(api) { 9 | yield fork(startCachedCIDsReciever, api); 10 | yield call(cachePlaylistTracks, api); 11 | yield takeEvery( 12 | [ 13 | actions.actions.systemPlayedTracksRecieved, 14 | actions.actions.systemQueuedTracksRecieved, 15 | ], cachePlaylistTracks, api, 16 | ); 17 | } 18 | 19 | export default startTracksCache; 20 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "verifyConditions": [ 3 | "@semantic-release/changelog", 4 | "@semantic-release/git" 5 | ], 6 | "prepare": [ 7 | "@semantic-release/npm", 8 | "@semantic-release/changelog", 9 | { 10 | "path": "@semantic-release/exec", 11 | "cmd": "yarn get-ipfs:mac && yarn build -m && yarn get-ipfs:win && yarn build -w && yarn get-ipfs:lin && yarn build -l" 12 | }, 13 | { 14 | "path": "@semantic-release/git", 15 | "assets": ["CHANGELOG.md"] 16 | } 17 | ], 18 | "publish": ["@semantic-release/github"], 19 | "success": ["@semantic-release/github"], 20 | "assets": "dist/*.{zip,dmg,blockmap,AppImage,rpm,deb,exe}", 21 | "npmPublish": false 22 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPageConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import DiscoverPage from './DiscoverPage'; 7 | 8 | const mapStateToProps = (state) => { 9 | const isSelected = selectors.isDiscoverSelected(state); 10 | return { 11 | hasSelectedActions: isSelected, 12 | hasSearchBar: selectors.getAlbumsCount(state) > 0, 13 | }; 14 | }; 15 | 16 | const mapDispatchToProps = { 17 | onWillMount: actions.systemDiscoverAlbumsFetch, 18 | onWillUnmount: actions.uiDiscoverPageClosed, 19 | }; 20 | 21 | export default connect(mapStateToProps, mapDispatchToProps)(DiscoverPage); 22 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/Tracklist.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import e2e from '~shared/data/e2e'; 5 | 6 | import PlaylistTrackContainerConnected from './PlaylistTrackContainerConnected'; 7 | 8 | import './Tracklist.css'; 9 | 10 | const Tracklist = ({ tracksIndexes }) => ( 11 |
12 | { 13 | tracksIndexes.map(index => ( 14 | 15 | )) 16 | } 17 |
18 | ); 19 | 20 | Tracklist.propTypes = { 21 | tracksIndexes: propTypes.arrayOf(propTypes.string).isRequired, 22 | }; 23 | 24 | export default Tracklist; 25 | -------------------------------------------------------------------------------- /src/renderer/components/CoverPreview.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | import MdAlbum from 'react-icons/lib/md/album'; 4 | 5 | import './CoverPreview.css'; 6 | 7 | const CoverPreview = ({ coverSrc }) => ( 8 |
9 | { 10 | coverSrc ? ( 11 | cover preview 12 | ) : ( 13 | 14 | ) 15 | } 16 |
17 | ); 18 | 19 | CoverPreview.defaultProps = { 20 | coverSrc: null, 21 | }; 22 | 23 | CoverPreview.propTypes = { 24 | coverSrc: propTypes.string, 25 | }; 26 | 27 | export default CoverPreview; 28 | -------------------------------------------------------------------------------- /src/renderer/state/domains/notifications.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'notifications'; 4 | 5 | const initialState = []; 6 | 7 | export const getNotifications = state => state[DOMAIN]; 8 | 9 | const reducer = (state = initialState, action) => { 10 | const { type, payload } = action; 11 | switch (type) { 12 | case actions.systemNotificationRecieved.toString(): 13 | return [...state, payload]; 14 | case actions.systemNotificationExpired.toString(): 15 | case actions.uiNotificationToastRemoved.toString(): 16 | console.log(payload); 17 | return state.filter(n => n.id !== payload); 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | export default reducer; 24 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/checkLegalAgreement.js: -------------------------------------------------------------------------------- 1 | 2 | import { select, put } from 'redux-saga/effects'; 3 | 4 | import { IS_TESTING } from '~shared/config'; 5 | import i18n from '~shared/data/i18n'; 6 | 7 | import actions from '#actions'; 8 | import selectors from '#selectors'; 9 | 10 | /* eslint-disable global-require, no-alert */ 11 | 12 | function* checkLegalAgreement() { 13 | const isGranted = yield select(selectors.isLegalAgreementGranted); 14 | if (!IS_TESTING && !isGranted) { 15 | if (window.confirm(i18n.LEGAL_NOTICE)) { 16 | yield put(actions.uiLegalAgreementGranted()); 17 | } else { 18 | require('electron').remote.app.quit(); 19 | } 20 | } 21 | } 22 | 23 | export default checkLegalAgreement; 24 | -------------------------------------------------------------------------------- /src/main/methods/createMainWindow.js: -------------------------------------------------------------------------------- 1 | import createElectronWindow from '~shared/utils/createElectronWindow'; 2 | 3 | import withTray from './createMainWindow/withTray'; 4 | import withNoNavigation from './createMainWindow/withNoNavigation'; 5 | import withMenu from './createMainWindow/withMenu'; 6 | import { HAS_TRAY } from '~shared/config'; 7 | 8 | const MAIN_WINDOW_NAME = 'main'; 9 | 10 | const createMainWindow = () => { 11 | const window = createElectronWindow(MAIN_WINDOW_NAME); 12 | 13 | if (HAS_TRAY) { 14 | console.log('-- tray support enabled'); 15 | withTray(window); 16 | } 17 | 18 | withNoNavigation(window); 19 | 20 | withMenu(window); 21 | 22 | return window; 23 | }; 24 | 25 | export default createMainWindow; 26 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIPFSCachingService/startCachedIPFSFilesReciever.js: -------------------------------------------------------------------------------- 1 | import { call, put, take } from 'redux-saga/effects'; 2 | import actions from '#actions'; 3 | 4 | function* startCachedIPFSFilesReciever(api) { 5 | const { getCachedIPFSFilesChannel, openCachedIPFSFilesStream } = api; 6 | yield call(openCachedIPFSFilesStream); 7 | const channel = yield call(getCachedIPFSFilesChannel); 8 | while (true) { 9 | const { errorMessage, payload } = yield take(channel); 10 | if (!errorMessage) { 11 | yield put(actions.systemIPFSFileCached(payload)); 12 | } else { 13 | console.error(new Error(errorMessage)); 14 | } 15 | } 16 | } 17 | 18 | export default startCachedIPFSFilesReciever; 19 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/IndicatorsBar/Indicator.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './Indicator.css'; 5 | 6 | const Indicator = ({ 7 | text, Icon, tooltip, isAccented, 8 | }) => ( 9 | 10 | { 11 | Icon && 12 | } 13 | {text} 14 | 15 | ); 16 | 17 | Indicator.defaultProps = { 18 | Icon: null, 19 | tooltip: null, 20 | isAccented: false, 21 | }; 22 | 23 | Indicator.propTypes = { 24 | text: propTypes.string.isRequired, 25 | Icon: propTypes.func, 26 | tooltip: propTypes.string, 27 | isAccented: propTypes.bool, 28 | }; 29 | 30 | export default Indicator; 31 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/getApis/getStorageApi/dbApi.worker/albumsCollectionApi/creationStream.js: -------------------------------------------------------------------------------- 1 | import ipc from '~shared/data/ipc'; 2 | 3 | const sendMessage = () => { 4 | postMessage({ 5 | type: ipc.ALBUM_CREATED, 6 | }); 7 | }; 8 | 9 | let closeStream = null; 10 | 11 | export const openAlbumsCreationStream = async (dbApis) => { 12 | if (closeStream) { 13 | closeStream(); 14 | } 15 | const handleHook = (args) => { 16 | args[2].on('complete', sendMessage); 17 | }; 18 | dbApis.albumsCollection.hook('creating', handleHook); 19 | closeStream = () => { 20 | dbApis.albumsCollection.hook('creating').unsubscribe(handleHook); 21 | }; 22 | }; 23 | 24 | export const closeAlbumsCreationStream = () => { 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/SelectedActionsConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import actions from '#actions'; 6 | 7 | import SelectedActions from './SelectedActions'; 8 | 9 | const mapStateToProps = state => ({ 10 | selectedAlbumsCount: selectors.getDiscoverSelectedCount(state), 11 | }); 12 | 13 | const mapDispatchToProps = { 14 | onCancelSelection: actions.uiDiscoverSelectedCanceled, 15 | onPlaySelected: actions.uiDiscoverSelectedPlayed, 16 | onAddSelected: actions.uiDiscoverSelectedQueued, 17 | onDeleteSelected: actions.uiDiscoverSelectedDeleted, 18 | }; 19 | 20 | export default connect(mapStateToProps, mapDispatchToProps)(SelectedActions); 21 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareFormConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import actions from '#actions'; 4 | import selectors from '#selectors'; 5 | 6 | import ShareForm from './ShareForm'; 7 | 8 | const mapStateToProps = state => ({ 9 | values: selectors.getShareFormValue(state), 10 | coverSrc: selectors.getShareCoverSrc(state), 11 | isDisabled: selectors.isShareProcessing(state), 12 | }); 13 | 14 | const mapDispatchToProps = { 15 | onSubmit: actions.uiShareFormSubmited, 16 | onCancel: actions.uiShareFormCanceled, 17 | onChange: actions.uiShareFormChanged, 18 | onReset: actions.uiShareFormReseted, 19 | }; 20 | 21 | export default connect(mapStateToProps, mapDispatchToProps)(ShareForm); 22 | -------------------------------------------------------------------------------- /src/renderer/state/domains/audio.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'audio'; 4 | 5 | const initialState = { 6 | isPaused: true, 7 | }; 8 | 9 | export const isPaused = state => state[DOMAIN].isPaused; 10 | 11 | const reducer = (state = initialState, action) => { 12 | const { type } = action; 13 | switch (type) { 14 | case actions.uiPlaybackToggled.toString(): 15 | return { ...state, isPaused: !state.isPaused }; 16 | case actions.uiDiscoverSelectedPlayed.toString(): 17 | case actions.uiAlbumPlayed.toString(): 18 | case actions.uiPlaylistTrackPlayed.toString(): 19 | return { ...state, isPaused: false }; 20 | default: 21 | return state; 22 | } 23 | }; 24 | 25 | export default reducer; 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Electron: Main", 8 | "protocol": "inspector", 9 | "port": 5858, 10 | "timeout": 30000 11 | }, 12 | { 13 | "name": "Electron: Renderer", 14 | "type": "node", 15 | "request": "attach", 16 | "port": 9223, 17 | "protocol":"inspector", 18 | "timeout": 30000 19 | } 20 | ], 21 | "compounds": [ 22 | { 23 | "name": "Electron: All", 24 | "configurations": [ 25 | "Electron: Main", 26 | "Electron: Renderer" 27 | ] 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /src/renderer/sagas/startApp.js: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import { IS_TESTING } from '~shared/config'; 6 | 7 | import startServices from './startApp/startServices'; 8 | import getApis from './startApp/getApis'; 9 | import checkLegalAgreement from './startApp/checkLegalAgreement'; 10 | 11 | function* startApp() { 12 | try { 13 | if (!IS_TESTING) { 14 | yield call(checkLegalAgreement); 15 | } 16 | const apis = yield call(getApis); 17 | yield call(startServices, apis); 18 | yield put(actions.systemAppStartSucceed()); 19 | } catch (e) { 20 | console.error(e); 21 | yield put(actions.systemAppStartFailed(e.message)); 22 | } 23 | } 24 | 25 | export default startApp; 26 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidatesFromFiles/extractAlbumInfoFromTracks.js: -------------------------------------------------------------------------------- 1 | 2 | const extractAlbumInfoFromTracks = (tracks) => { 3 | let title; 4 | let artist; 5 | for (let i = 0; i < tracks.length; i += 1) { 6 | const track = tracks[i]; 7 | if (title === undefined) { 8 | title = track.album; 9 | } else 10 | if (title !== track.album) { 11 | title = null; 12 | } 13 | if (artist === undefined) { 14 | ({ artist } = track); 15 | } else 16 | if (artist !== track.artist) { 17 | artist = null; 18 | } 19 | if (title === null && artist === null) break; 20 | } 21 | return { title, artist }; 22 | }; 23 | 24 | export default extractAlbumInfoFromTracks; 25 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIPFSCachingService/startIPFSFilesCatch.js: -------------------------------------------------------------------------------- 1 | import { call, takeEvery } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import cachePlaylistTracks from './startIPFSFilesCatch/cachePlaylistTracks'; 6 | import cacheDiscoverAlbumsCovers from './startIPFSFilesCatch/cacheDiscoverAlbumsCovers'; 7 | 8 | function* startIPFSFilesCatch(api) { 9 | yield call(cachePlaylistTracks, api); 10 | yield takeEvery( 11 | [ 12 | actions.systemPlayedTracksRecieved, 13 | actions.systemQueuedTracksRecieved, 14 | ], cachePlaylistTracks, api, 15 | ); 16 | yield takeEvery( 17 | actions.systemDiscoverAlbumsFetchSucceed, cacheDiscoverAlbumsCovers, api, 18 | ); 19 | } 20 | 21 | export default startIPFSFilesCatch; 22 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/EqualizerIcon.css: -------------------------------------------------------------------------------- 1 | @keyframes eq-uppydowny { 2 | 0% { 3 | //height: 20px; 4 | transform: scaleY(1); 5 | } 6 | 50% { 7 | //height: 3px; 8 | transform: scaleY(.5); 9 | } 10 | 100% { 11 | //height: 20px; 12 | transform: scaleY(1); 13 | } 14 | } 15 | 16 | .eq__bar { 17 | position: absolute; 18 | bottom: 0px; 19 | fill: #434343; 20 | animation-name: eq-uppydowny; 21 | animation-fill: forwards; 22 | animation-iteration-count: infinite; 23 | transform-origin: 8px 16px; 24 | -webkit-transform-origin: 8px 16px; 25 | rx:3; 26 | ry:3; 27 | } 28 | 29 | #eq1 { 30 | animation-duration: 0.5s; 31 | } 32 | 33 | #eq2 { 34 | animation-duration: 1.3s; 35 | } 36 | 37 | #eq3 { 38 | animation-duration: 0.7s; 39 | } -------------------------------------------------------------------------------- /src/shared/utils/splitFoldersAndFiles.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const splitFoldersAndFiles = async (files) => { 4 | const data = { 5 | files: [], 6 | folders: [], 7 | }; 8 | const resolveFile = filePath => new Promise((resolve, reject) => { 9 | fs.lstat(filePath, (err, stat) => { 10 | if (err) { 11 | reject(err); 12 | } else { 13 | if (stat.isDirectory()) { 14 | data.folders.push(filePath); 15 | } else { 16 | data.files.push(filePath); 17 | } 18 | resolve(); 19 | } 20 | }); 21 | }); 22 | for (let i = 0; i < files.length; i += 1) { 23 | await resolveFile(files[i]); // eslint-disable-line no-await-in-loop 24 | } 25 | return data; 26 | }; 27 | 28 | export default splitFoldersAndFiles; 29 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService.js: -------------------------------------------------------------------------------- 1 | import { all, takeEvery } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import handleShareFormChange from './startAlbumsSharingService/handleShareFormChange'; 6 | import handleShareFormSubmit from './startAlbumsSharingService/handleShareFormSubmit'; 7 | import handleShareItemsSelect from './startAlbumsSharingService/handleShareItemsSelect'; 8 | 9 | function* startAlbumsSharingService(apis) { 10 | yield all([ 11 | takeEvery(actions.uiShareItemsSelected, handleShareItemsSelect, apis), 12 | takeEvery(actions.uiShareFormSubmited, handleShareFormSubmit, apis), 13 | takeEvery(actions.uiShareFormChanged, handleShareFormChange, apis), 14 | ]); 15 | } 16 | 17 | export default startAlbumsSharingService; 18 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareFilesSelect/getAlbumCandidatesFromItems/getCandidatesFromFiles/extractAlbumInfoFromTracks.js: -------------------------------------------------------------------------------- 1 | 2 | const extractAlbumInfoFromTracks = (tracks) => { 3 | let title; 4 | let artist; 5 | for (let i = 0; i < tracks.length; i += 1) { 6 | const track = tracks[i]; 7 | if (title === undefined) { 8 | title = track.album; 9 | } else 10 | if (title !== track.album) { 11 | title = null; 12 | } 13 | if (artist === undefined) { 14 | ({ artist } = track); 15 | } else 16 | if (artist !== track.artist) { 17 | artist = null; 18 | } 19 | if (title === null && artist === null) break; 20 | } 21 | return { title, artist }; 22 | }; 23 | 24 | export default extractAlbumInfoFromTracks; 25 | -------------------------------------------------------------------------------- /src/e2e/tests/sharing/selectTrackFiles.js: -------------------------------------------------------------------------------- 1 | import { 2 | shareCancelForm, 3 | shareWaitForDropZoneExists, 4 | shareDropZoneSelect, 5 | shareWaitForFormExists, 6 | } from '~reusable/sharePage'; 7 | 8 | import { tracks } from '~shared/data/assets'; 9 | 10 | describe('select track files', () => { 11 | tracks.forEach((track, index) => { 12 | describe(`track #${index}`, () => { 13 | it('throws no error', function () { 14 | return shareDropZoneSelect.call(this, track.file); 15 | }); 16 | it('share form appears', function () { 17 | return shareWaitForFormExists.call(this); 18 | }); 19 | after(async function () { 20 | await shareCancelForm.call(this); 21 | await shareWaitForDropZoneExists.call(this); 22 | }); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Notifications.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import Toast from './Notifications/Toast'; 5 | 6 | import './Notifications.css'; 7 | import e2e from '~shared/data/e2e'; 8 | 9 | const Notifications = ({ notifications, onToastClick }) => ( 10 |
14 | { 15 | notifications.map(data => ( 16 | 17 | )) 18 | } 19 |
20 | ); 21 | 22 | Notifications.propTypes = { 23 | notifications: propTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types 24 | onToastClick: propTypes.func.isRequired, 25 | }; 26 | 27 | export default Notifications; 28 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareForm/TracklistFieldset/TrackControlsRight.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import MdClear from 'react-icons/lib/md/clear'; 5 | 6 | import e2e from '~shared/data/e2e'; 7 | 8 | const TrackControls = (props) => { 9 | const { 10 | onRemoveClick, 11 | } = props; 12 | return ( 13 | 14 | 22 | 23 | ); 24 | }; 25 | 26 | TrackControls.propTypes = { 27 | onRemoveClick: propTypes.func.isRequired, 28 | }; 29 | 30 | export default TrackControls; 31 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIndicatorBarService/startMetabinPeersRetriever.js: -------------------------------------------------------------------------------- 1 | import { take, call, put } from 'redux-saga/effects'; 2 | import reduxSagaTicker from '~shared/utils/reduxSagaTicker'; 3 | import actions from '#actions'; 4 | 5 | function* startMetabinPeersRetriever({ getMetabinPeersCount }) { 6 | const ticker = yield call(reduxSagaTicker, 10000); 7 | try { 8 | while (true) { 9 | yield take(ticker); 10 | const peersCount = yield call(getMetabinPeersCount); 11 | yield put(actions.systemMetabinPeersRecieved({ metabinPeersCount: peersCount })); 12 | } 13 | } catch (e) { 14 | console.error(e); 15 | yield put(actions.systemMetabinPeersRecieved({ metabinPeersCount: null })); 16 | ticker.close(); 17 | } 18 | } 19 | 20 | export default startMetabinPeersRetriever; 21 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayerConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import actions from '#actions'; 4 | import selectors from '#selectors'; 5 | 6 | import ActivePlayer from './ActivePlayer'; 7 | 8 | const mapStateToProps = (state) => { 9 | const { title, artist } = selectors.getCurrentTrack(state); 10 | return { 11 | title, 12 | artist, 13 | source: selectors.getCurrentTrackSource(state), 14 | volume: selectors.getVolume(state), 15 | isPaused: selectors.isPaused(state), 16 | }; 17 | }; 18 | 19 | const mapDispatchToProps = { 20 | onAudioEnded: actions.systemAudioEnded, 21 | onAudioPlayed: actions.systemAudioPlayed, 22 | onAudioPaused: actions.systemAudioPaused, 23 | }; 24 | 25 | export default connect(mapStateToProps, mapDispatchToProps)(ActivePlayer); 26 | -------------------------------------------------------------------------------- /src/renderer/view/AppConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withRouter } from 'react-router-dom'; 3 | 4 | import selectors from '#selectors'; 5 | import actions from '#actions'; 6 | 7 | import App from './App'; 8 | 9 | const mapStateToProps = (state) => { 10 | const appIsReady = selectors.isAppReady(state); 11 | return { 12 | hasStartScreen: !appIsReady, 13 | hasReadyScreen: appIsReady, 14 | hasCloseScreen: false, 15 | hasLockScreen: selectors.isAppLocked(state), 16 | errorMessage: selectors.getAppStartErrorMessage(state), 17 | progress: selectors.getAppStartProgress(state), 18 | }; 19 | }; 20 | 21 | const mapDispatchToProps = { 22 | onDidMount: actions.systemAppRootMounted, 23 | }; 24 | 25 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(App)); 26 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/getApis/getRestRemoteApis.js: -------------------------------------------------------------------------------- 1 | import { take, call } from 'redux-saga/effects'; 2 | 3 | import { rendererCalls, rendererCallsSaga } from '~shared/utils/ipcRenderer'; 4 | 5 | import ipc from '~shared/data/ipc'; 6 | 7 | function getRestRemoteApis() { 8 | function* getAlbumCandidatesFromFs(...args) { 9 | const chan = yield call(rendererCallsSaga, ipc.GET_ALBUM_CANDIDATES_FROM_FS_ITEMS, ...args); 10 | const { error, payload } = yield take(chan); 11 | if (error) { 12 | throw new Error(error); 13 | } 14 | return payload; 15 | } 16 | 17 | const getTracksFromFsFiles = (...args) => rendererCalls(ipc.GET_TRACKS_FROM_FS_FILES, ...args); 18 | 19 | return { 20 | getAlbumCandidatesFromFs, 21 | getTracksFromFsFiles, 22 | }; 23 | } 24 | 25 | export default getRestRemoteApis; 26 | -------------------------------------------------------------------------------- /src/renderer/state/domains/discoverSelected.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'discoverSelected'; 4 | 5 | const initialState = []; 6 | 7 | // SELECTORS 8 | 9 | export const getDiscoverSelectedIds = state => state[DOMAIN]; 10 | 11 | // ACTIONS 12 | 13 | const reducer = (state = initialState, action) => { 14 | const { type, payload } = action; 15 | switch (type) { 16 | case actions.uiDiscoverAlbumSelected.toString(): 17 | return [...state, payload]; 18 | case actions.uiDiscoverAlbumDeselected.toString(): 19 | return state.filter(cid => cid !== payload); 20 | case actions.systemDiscoverSelectedActionSucceed.toString(): 21 | case actions.uiDiscoverSelectedCanceled.toString(): 22 | return []; 23 | default: 24 | return state; 25 | } 26 | }; 27 | 28 | export default reducer; 29 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/PlaylistControls.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | import MdClear from 'react-icons/lib/md/clear-all'; 4 | 5 | import i18n from '~shared/data/i18n'; 6 | import e2e from '~shared/data/e2e'; 7 | 8 | import './PlaylistControls.css'; 9 | 10 | const PlaylistControls = ({ onClearPlaylist }) => ( 11 |
12 | 21 |
22 | ); 23 | 24 | PlaylistControls.propTypes = { 25 | onClearPlaylist: propTypes.func.isRequired, 26 | }; 27 | 28 | export default PlaylistControls; 29 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar/CustomRangeInput.css: -------------------------------------------------------------------------------- 1 | 2 | .custom-range-input:focus::-webkit-slider-thumb { 3 | box-shadow: 0 0 8px rgba(239,45,86,0.8); 4 | } 5 | 6 | .custom-range-input::-webkit-slider-thumb { 7 | -webkit-appearance: none; 8 | height: 12.5px; 9 | width: 12.5px; 10 | border-radius: 50%; 11 | margin-top: -4px; 12 | background: var(--accent-color); 13 | } 14 | 15 | .custom-range-input::-webkit-slider-runnable-track { 16 | width: 100%; 17 | height: 4px; 18 | background: rgb(230,230,230); 19 | border-radius: 2px; 20 | } 21 | 22 | .custom-range-input:active::-webkit-slider-runnable-track, 23 | .custom-range-input:hover::-webkit-slider-runnable-track { 24 | background: rgb(220,220,220); 25 | outline: none; 26 | border: none; 27 | } 28 | 29 | .custom-range-input { 30 | width: 100%; 31 | } -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startDiscoverPageService/deleteSelectedAlbums.js: -------------------------------------------------------------------------------- 1 | import { call, put, select } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | import selectors from '#selectors'; 5 | 6 | function* handleDiscoverSelectedDelete(apis) { 7 | const { deleteAlbumsFromCollection } = apis; 8 | yield put(actions.systemUiLocked()); 9 | try { 10 | const selectedAlbums = yield select(selectors.getDiscoverSelectedCids); 11 | const collectionStat = yield call(deleteAlbumsFromCollection, selectedAlbums); 12 | yield put(actions.systemDiscoverSelectedActionSucceed(collectionStat)); 13 | } catch (e) { 14 | yield put(actions.systemDiscoverSelectedActionFailed({ errorMessage: e.message })); 15 | } 16 | yield put(actions.systemUiUnlocked()); 17 | } 18 | 19 | export default handleDiscoverSelectedDelete; 20 | -------------------------------------------------------------------------------- /src/renderer/state/reducers.js: -------------------------------------------------------------------------------- 1 | export { default as appStart } from './domains/appStart'; 2 | export { default as audio } from './domains/audio'; 3 | export { default as playlist } from './domains/playlist'; 4 | export { default as volume } from './domains/volume'; 5 | export { default as discoverPage } from './domains/discoverPage'; 6 | export { default as discoverSelected } from './domains/discoverSelected'; 7 | export { default as share } from './domains/share'; 8 | export { default as cachedCIDs } from './domains/cachedCIDs'; 9 | export { default as notifications } from './domains/notifications'; 10 | export { default as ipfsInfo } from './domains/ipfsInfo'; 11 | export { default as legalAgreement } from './domains/legalAgreement'; 12 | export { default as albumsInfo } from './domains/albumsInfo'; 13 | export { default as newRelease } from './domains/newRelease'; 14 | -------------------------------------------------------------------------------- /src/main/methods/startCommunication.js: -------------------------------------------------------------------------------- 1 | import startFsBridge from './startCommunication/startFsBridge'; 2 | import startIpfsBridge from './startCommunication/startIpfsBridge'; 3 | import startMetabinBridge from './startCommunication/startMetabinBridge'; 4 | import startIpfsProcess from './startCommunication/startIpfsProcess'; 5 | 6 | const startCommunication = () => { 7 | const ipfsProcessPromise = startIpfsProcess(); 8 | 9 | const stopFsBridge = startFsBridge(); 10 | const stopIpfsBridge = startIpfsBridge({ ipfsProcessPromise }); 11 | const stopMetabinBridge = startMetabinBridge({ ipfsProcessPromise }); 12 | return async () => { 13 | stopFsBridge(); 14 | stopIpfsBridge(); 15 | stopMetabinBridge(); 16 | const ipfsProcess = await ipfsProcessPromise; 17 | ipfsProcess.disconnect(); 18 | }; 19 | }; 20 | 21 | export default startCommunication; 22 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/SearchBar.css: -------------------------------------------------------------------------------- 1 | 2 | .albums-page__search-bar { 3 | padding: 0.5em 1em; 4 | display: flex; 5 | flex-shrink: 0; 6 | border-bottom: 1px solid #dedede; 7 | } 8 | 9 | .albums-page__search-input { 10 | height: 2em; 11 | padding-left: 1em; 12 | flex-basis: 100%; 13 | border: none; 14 | outline: none; 15 | } 16 | 17 | .albums-page__cancel-search { 18 | color: var(--accent-color); 19 | background: none; 20 | border: 1px solid white; 21 | transition: color 0.2s; 22 | } 23 | 24 | .albums-page__cancel-search:disabled { 25 | color: lightgray; 26 | cursor: default; 27 | } 28 | 29 | .albums-page__cancel-search:focus { 30 | border: 1px solid orange; 31 | outline: none; 32 | } 33 | 34 | .albums-page__cancel-search:hover:not(:disabled) { 35 | background: var(--accent-color); 36 | color: white; 37 | } -------------------------------------------------------------------------------- /src/shared/utils/validateShareCandidate.js: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { shareCandidateSchema } from '~shared/data/schemas/album'; 3 | 4 | const normalizeDataPath = (dataPath) => { 5 | const noDot = dataPath.slice(1, dataPath.length); 6 | return noDot 7 | .replace('[', '.') 8 | .replace(']', ''); 9 | }; 10 | 11 | const normalizeErrors = errors => ( 12 | errors.reduce((acc, { dataPath, message }) => { 13 | acc[normalizeDataPath(dataPath)] = message; 14 | return acc; 15 | }, {}) 16 | ); 17 | 18 | const validateShareCandidate = (candidate) => { 19 | const validator = new Ajv({ 20 | allErrors: true, 21 | }); 22 | const valid = validator.validate(shareCandidateSchema, candidate); 23 | if (!valid) { 24 | return normalizeErrors(validator.errors); 25 | } 26 | return false; 27 | }; 28 | 29 | export default validateShareCandidate; 30 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startDiscoverPageService/playOrQueueAlbum.js: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import getPlaylistTracksFromAlbums from '~shared/utils/getPlaylistTracksFromAlbums'; 6 | 7 | function* playOrQueueAlbum(args, { type, payload }) { 8 | yield put(actions.systemUiLocked()); 9 | try { 10 | const tracks = yield call(getPlaylistTracksFromAlbums, args, [payload]); 11 | if (type === actions.uiAlbumPlayed.toString()) { 12 | yield put(actions.systemPlayedTracksRecieved(tracks)); 13 | } 14 | if (type === actions.uiAlbumQueued.toString()) { 15 | yield put(actions.systemQueuedTracksRecieved(tracks)); 16 | } 17 | } catch (e) { 18 | console.error(e); 19 | } 20 | yield put(actions.systemUiUnlocked()); 21 | } 22 | 23 | export default playOrQueueAlbum; 24 | -------------------------------------------------------------------------------- /src/renderer/view/App/StartScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import ErrorMessage from '~components/ErrorMessage'; 5 | 6 | import ProgressBar from './StartScreen/ProgressBar'; 7 | 8 | import './StartScreen.css'; 9 | 10 | const StartScreen = ({ errorMessage, infoMessage, progress }) => ( 11 |
12 | 13 | { 14 | errorMessage && ( 15 | 16 | ) 17 | } 18 |
19 | ); 20 | 21 | StartScreen.defaultProps = { 22 | errorMessage: null, 23 | infoMessage: null, 24 | }; 25 | 26 | StartScreen.propTypes = { 27 | errorMessage: propTypes.string, 28 | progress: propTypes.number.isRequired, 29 | infoMessage: propTypes.string, 30 | }; 31 | 32 | export default StartScreen; 33 | -------------------------------------------------------------------------------- /src/shared/data/constants.js: -------------------------------------------------------------------------------- 1 | 2 | export const APP_STATUS_START = 0; 3 | export const APP_STATUS_READY = 1; 4 | export const APP_STATUS_CLOSE = 2; 5 | 6 | export const ROUTE_ALBUMS = '/albums'; 7 | export const ROUTE_ADD_ALBUM = '/add-album'; 8 | export const ROUTE_DONATE = '/donate'; 9 | export const ROUTE_HOME = ROUTE_ALBUMS; 10 | 11 | export const ALBUMS_APPEARENCE_INTERVAL = 60000 * 2; 12 | export const ALBUMS_PUBLISH_INTERVAL = 60000 * 1; 13 | export const CHECK_FOR_UPDATE_INTERVAL = 60000 * 30; 14 | 15 | export const QUALITY_LABEL_LOW = 0; 16 | export const QUALITY_LABEL_HIGH = 1; 17 | export const QUALITY_LABEL_LOSSLESS = 2; 18 | 19 | export const DISCOVER_FEED_LIMIT = 50; 20 | export const ALBUMS_COLLECTION_LIMIT = 50000; 21 | 22 | export const NOTIFICATION_TYPE_ERROR = 0; 23 | export const NOTIFICATION_TYPE_WARNING = 1; 24 | export const NOTIFICATION_TYPE_SUCCESS = 2; 25 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Navigation/NavigationItem.css: -------------------------------------------------------------------------------- 1 | 2 | .navigation__item { 3 | margin: 0.5em 0; 4 | display: flex; 5 | align-items: center; 6 | text-transform: uppercase; 7 | text-decoration: none; 8 | color: white; 9 | padding: 1em; 10 | transition: 0.2s; 11 | border-left: 2px solid rgba(0,0,0,0); 12 | cursor: pointer; 13 | } 14 | 15 | .navigation__item > *:first-child { 16 | margin: 0 1.5em 0 1em; 17 | } 18 | 19 | .navigation__item--active { 20 | border-left-color: var(--accent-color); 21 | } 22 | .navigation__item:hover { 23 | background-color: var(--white-shade-1); 24 | } 25 | .navigation__item:focus { 26 | color: var(--accent-color); 27 | outline: none; 28 | } 29 | 30 | .navigationItemIndicator { 31 | margin-left: auto; 32 | height: 7px; 33 | width: 7px; 34 | border-radius: 50%; 35 | background-color: var(--accent-color); 36 | } -------------------------------------------------------------------------------- /src/main/methods/withEnvironment.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import jetpack from 'fs-jetpack'; 3 | import path from 'path'; 4 | 5 | import { 6 | ENVIRONMENT, IS_PRODUCTION, IS_TESTING, IS_DEVELOPMENT, 7 | } from '~shared/config'; 8 | 9 | const withEnvronment = () => { 10 | const userDataPath = app.getPath('userData'); 11 | const nextDataPath = path.resolve( 12 | userDataPath, `../Pathephone${!IS_PRODUCTION ? ` (${ENVIRONMENT})` : ''}`, 13 | ); 14 | if (IS_TESTING) { 15 | jetpack.remove(nextDataPath); 16 | } 17 | app.setPath('userData', nextDataPath); 18 | 19 | if (IS_DEVELOPMENT) { 20 | app.commandLine.appendSwitch('remote-debugging-port', '9223'); 21 | // require('electron-debug')({showDevTools: true}) 22 | require('electron-context-menu')({}); // eslint-disable-line global-require 23 | } 24 | }; 25 | 26 | export default withEnvronment; 27 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareFilesSelect/getAlbumCandidatesFromItems/getCandidateFromFiles.js: -------------------------------------------------------------------------------- 1 | import getTracksFromFiles from '~shared/utils/getTracksFromFiles'; 2 | 3 | import getCoverFromFiles from './getCandidatesFromFiles/getCoverFromFiles'; 4 | import extractAlbumInfoFromTracks from './getCandidatesFromFiles/extractAlbumInfoFromTracks'; 5 | 6 | async function getCandidateFromFiles(apis, files) { 7 | if (files.length === 0) return undefined; 8 | const [tracks, coverImage] = await Promise.all([ 9 | getTracksFromFiles(apis, files), 10 | getCoverFromFiles(apis, files), 11 | ]); 12 | if (!tracks) return undefined; 13 | const { title, artist } = extractAlbumInfoFromTracks(tracks); 14 | return { 15 | tracks, cover: { image: coverImage }, artist, title, 16 | }; 17 | } 18 | 19 | export default getCandidateFromFiles; 20 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import e2e from '~shared/data/e2e'; 4 | 5 | import Page from './ReadyScreen/Page'; 6 | import PlaylistConnected from './ReadyScreen/PlaylistConnected'; 7 | import PlayerConnected from './ReadyScreen/PlayerConnected'; 8 | import NavigationConnected from './ReadyScreen/NavigationConnected'; 9 | import NotificationsConnected from './ReadyScreen/NotificationsConnected'; 10 | import IndicatorsBarConnected from './ReadyScreen/IndicatorsBarConnected'; 11 | 12 | import './ReadyScreen.css'; 13 | 14 | const ReadyScreen = () => ( 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | ); 24 | 25 | export default ReadyScreen; 26 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar/TrackBuffer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './TrackBuffer.css'; 5 | 6 | const handleMapBuffer = ([start, end]) => { 7 | const style = { 8 | width: `${end - start}%`, 9 | left: `${start}%`, 10 | }; 11 | return
; 12 | }; 13 | 14 | const TrackBuffer = ({ bufferedMap }) => ( 15 |
16 | { 17 | bufferedMap 18 | && bufferedMap.map(handleMapBuffer) 19 | } 20 |
21 | ); 22 | 23 | TrackBuffer.defaultProps = { 24 | bufferedMap: null, 25 | }; 26 | 27 | TrackBuffer.propTypes = { 28 | bufferedMap: propTypes.arrayOf( 29 | propTypes.arrayOf(propTypes.number), 30 | ), 31 | }; 32 | 33 | export default TrackBuffer; 34 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/NewReleaseCard/AssetsButtons/AssetDownloadButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | import MdDownload from 'react-icons/lib/md/file-download'; 4 | 5 | import getCurrentOSIcon from '~shared/utils/getCurrentOSIcon'; 6 | 7 | import './AssetDownloadButton.css'; 8 | 9 | let Icon = getCurrentOSIcon(); 10 | 11 | if (!Icon) { 12 | Icon = MdDownload; 13 | } 14 | 15 | const AssetDownloadButton = ({ downloadURL, assetName }) => ( 16 | 20 | 21 | {' '} 22 | 23 | {assetName} 24 | 25 | 26 | ); 27 | 28 | AssetDownloadButton.propTypes = { 29 | downloadURL: propTypes.string.isRequired, 30 | assetName: propTypes.string.isRequired, 31 | }; 32 | 33 | export default AssetDownloadButton; 34 | -------------------------------------------------------------------------------- /src/renderer/state/domains/albumsInfo.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'albumsInfo'; 4 | 5 | const initialState = { 6 | albumsCount: null, 7 | }; 8 | 9 | export const getAlbumsCount = state => state[DOMAIN].albumsCount; 10 | 11 | const reducer = (state = initialState, action) => { 12 | const { type, payload } = action; 13 | switch (type) { 14 | case actions.systemAlbumsCollectionInfoRecieved.toString(): 15 | case actions.systemShareCandidateSaveSucceed.toString(): 16 | case actions.systemAlbumsRecievedCacheTransited.toString(): 17 | return { albumsCount: payload.albumsCount }; 18 | case actions.systemDiscoverSelectedActionSucceed.toString(): 19 | if (payload) { 20 | return { albumsCount: payload.albumsCount }; 21 | } 22 | return state; 23 | default: 24 | return state; 25 | } 26 | }; 27 | 28 | export default reducer; 29 | -------------------------------------------------------------------------------- /src/shared/utils/getMetabinDataChannel.js: -------------------------------------------------------------------------------- 1 | import { eventChannel } from 'redux-saga'; 2 | 3 | import { ipcRenderer } from 'electron'; 4 | import { rendererCalls } from './ipcRenderer'; 5 | 6 | import ipc from '~shared/data/ipc'; 7 | 8 | async function getMetabinDataChannel(channelName) { 9 | await rendererCalls(ipc.METABIN_GATE_SUBSCRIBE, channelName); 10 | return eventChannel((emitt) => { 11 | const handleIncomingMessage = (event, { schemaName, payload }) => { 12 | if (schemaName === channelName) { 13 | emitt(payload); 14 | } 15 | }; 16 | ipcRenderer.on(ipc.METABIN_GATE_DATA_RECIEVED, handleIncomingMessage); 17 | return () => { 18 | ipcRenderer.removeListener(ipc.METABIN_GATE_DATA_RECIEVED, handleIncomingMessage); 19 | return rendererCalls(ipc.METABIN_GATE_UNLISTEN, channelName); 20 | }; 21 | }); 22 | } 23 | 24 | export default getMetabinDataChannel; 25 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareDropZone.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | import MdDrop from 'react-icons/lib/md/arrow-downward'; 4 | 5 | import i18n from '~shared/data/i18n'; 6 | import e2e from '~shared/data/e2e'; 7 | 8 | import DNDarea from '~components/DNDarea'; 9 | 10 | import './ShareDropZone.css'; 11 | 12 | const ShareDropZone = ({ onFilesSelect }) => ( 13 | 14 |
15 | 16 |
17 |
18 | {i18n.SELECT_OR_DND} 19 |
20 |
21 |
22 | ); 23 | 24 | ShareDropZone.propTypes = { 25 | onFilesSelect: propTypes.func.isRequired, 26 | }; 27 | 28 | export default ShareDropZone; 29 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import TracklistConnected from './Playlist/TracklistConnected'; 5 | import PlaylistControlsConnected from './Playlist/PlaylistControlsConnected'; 6 | 7 | import './Playlist.css'; 8 | import i18n from '~shared/data/i18n'; 9 | 10 | const Playlist = ({ hasTracklist }) => ( 11 |
12 | { 13 | hasTracklist ? ( 14 | 15 | 16 | 17 | 18 | ) : ( 19 |
20 | {i18n.PLAYLIST_IS_EMPTY} 21 |
22 | ) 23 | } 24 |
25 | ); 26 | 27 | Playlist.propTypes = { 28 | hasTracklist: propTypes.bool.isRequired, 29 | }; 30 | 31 | export default Playlist; 32 | -------------------------------------------------------------------------------- /src/renderer/state/domains/localCoversCIDs.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'localCoversCIDs'; 4 | 5 | const initialState = {}; 6 | 7 | export const getLocalCoversCIDs = state => state[DOMAIN]; 8 | 9 | const handleReduce = (acc, album) => { 10 | acc[album.albumCoverCid] = false; 11 | return acc; 12 | }; 13 | 14 | const reducer = (state = initialState, action) => { 15 | const { type, payload } = action; 16 | switch (type) { 17 | case actions.systemIPFSFileCached.toString(): 18 | if (state[payload] === false) { 19 | return { ...state, [payload]: true }; 20 | } 21 | return state; 22 | case actions.systemDiscoverAlbumsFetchSucceed.toString(): 23 | return payload.reduce(handleReduce, {}); 24 | case actions.uiDiscoverPageClosed.toString(): 25 | return {}; 26 | default: 27 | return state; 28 | } 29 | }; 30 | 31 | export default reducer; 32 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route, Redirect } from 'react-router-dom'; 3 | 4 | import { 5 | ROUTE_ALBUMS, ROUTE_ADD_ALBUM, ROUTE_DONATE, ROUTE_HOME, 6 | } from '~shared/data/constants'; 7 | 8 | import DiscoverPageConnected from './Page/DiscoverPageConnected'; 9 | import SharePageConnected from './Page/SharePageConnected'; 10 | import DonatePage from './Page/DonatePage'; 11 | 12 | const Page = () => ( 13 | 14 | } 18 | /> 19 | 23 | 27 | 31 | 32 | ); 33 | 34 | export default Page; 35 | -------------------------------------------------------------------------------- /src/main/methods/startCommunication/startFsBridge.js: -------------------------------------------------------------------------------- 1 | import createThreadController from '~shared/utils/createThreadController'; 2 | import { ipcMainTake } from '~shared/utils/ipcMain'; 3 | 4 | import ipc from '~shared/data/ipc'; 5 | 6 | const callAndClose = async (name, payload) => { 7 | const thread = createThreadController(name); 8 | const data = await thread.call({ payload }); 9 | thread.disconnect(); 10 | return data; 11 | }; 12 | 13 | const startFsBridge = () => { 14 | const apiUnlisteners = [ 15 | ipcMainTake( 16 | ipc.GET_ALBUM_CANDIDATES_FROM_FS_ITEMS, 17 | fsItems => callAndClose('getAlbumCandidatesFromFsItems', fsItems), 18 | ), 19 | ipcMainTake( 20 | ipc.GET_TRACKS_FROM_FS_FILES, 21 | files => callAndClose('getTracksFromFsFiles', files), 22 | ), 23 | ]; 24 | return () => { 25 | apiUnlisteners.forEach((unlisten) => { unlisten(); }); 26 | }; 27 | }; 28 | 29 | export default startFsBridge; 30 | -------------------------------------------------------------------------------- /src/shared/utils/fsFileToHTMLFile.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import fileType from 'file-type'; 4 | import readChunk from 'read-chunk'; 5 | 6 | const fsFileToHTMLFile = (filePath) => { 7 | const fileStat = fs.statSync(filePath); 8 | const fileName = path.basename(filePath); 9 | let type; 10 | let buffer; 11 | if (fileStat.isDirectory()) { 12 | type = ''; 13 | } else { 14 | buffer = readChunk.sync(filePath, 0, 4100); 15 | const output = fileType(buffer); 16 | type = output ? output.mime : ''; 17 | } 18 | if (type.startsWith('image/')) { 19 | buffer = fs.readFileSync(filePath); 20 | } 21 | const fileObj = new File([buffer], fileName, { type }); 22 | Object.defineProperties( 23 | fileObj, 24 | { 25 | path: { 26 | value: filePath, 27 | writable: false, 28 | }, 29 | }, 30 | ); 31 | return fileObj; 32 | }; 33 | 34 | export default fsFileToHTMLFile; 35 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/DiscoverPageBody/FeedScreenConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import actions from '#actions'; 4 | import i18n from '~shared/data/i18n'; 5 | 6 | import selectors from '#selectors'; 7 | 8 | import FeedScreen from './FeedScreen'; 9 | 10 | const mapStateToProps = (state) => { 11 | const searchValue = selectors.getDiscoverSearchValue(state); 12 | let title; 13 | if (searchValue) { 14 | title = `${i18n.SEARCH_RESULTS_FOR} "${searchValue}"`; 15 | } else { 16 | title = i18n.LATEST_ALBUMS; 17 | } 18 | return { 19 | albumsIds: selectors.getDiscoverAlbumsIds(state), 20 | hasRefreshButton: !searchValue && selectors.isDiscoverAlbumsOutdated(state), 21 | title, 22 | }; 23 | }; 24 | 25 | const mapDispatchToProps = { 26 | onRefreshButtonClick: actions.systemDiscoverAlbumsFetch, 27 | }; 28 | 29 | export default connect(mapStateToProps, mapDispatchToProps)(FeedScreen); 30 | -------------------------------------------------------------------------------- /src/renderer/view.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { HashRouter } from 'react-router-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { PersistGate } from 'redux-persist/integration/react'; 6 | 7 | import AppConnected from './view/AppConnected'; 8 | 9 | import store, { persistor } from './store'; 10 | 11 | const renderRoot = (Root) => { 12 | render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | , 20 | document.getElementById('app'), 21 | ); 22 | }; 23 | 24 | renderRoot(AppConnected); 25 | 26 | if (module.hot) { 27 | module.hot.accept('./view/AppConnected', () => { 28 | const NextAppConnected = require('./view/AppConnected').default; // eslint-disable-line global-require 29 | renderRoot(NextAppConnected); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/e2e/reusable/app.js: -------------------------------------------------------------------------------- 1 | import { Application } from 'spectron'; 2 | 3 | import e2e from '~shared/data/e2e'; 4 | 5 | const { platform } = process; 6 | 7 | let pathToBin; 8 | 9 | if (platform === 'darwin') { 10 | pathToBin = '../../dist/mac/Pathephone.app/Contents/MacOS/Pathephone'; 11 | } else 12 | if (platform === 'linux') { 13 | pathToBin = '../../dist/linux-unpacked/pathephone-desktop'; 14 | } else 15 | if (platform === 'win32') { 16 | pathToBin = '../../dist/win-unpacked/Pathephone.exe'; 17 | } 18 | 19 | export const startApp = async function () { 20 | this.timeout(30000); 21 | this.app = new Application({ 22 | path: pathToBin, 23 | args: ['.'], 24 | waitTimeout: 30000, 25 | }); 26 | await this.app.start(); 27 | return this.app.client.waitForExist(e2e.READY_SCREEN_ID); 28 | }; 29 | 30 | export const closeApp = function () { 31 | if (this.app && this.app.isRunning()) { 32 | return this.app.stop(); 33 | } 34 | return undefined; 35 | }; 36 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems.js: -------------------------------------------------------------------------------- 1 | 2 | import getFolderContents from '~shared/utils/getFolderContents'; 3 | import splitFoldersAndFiles from '~shared/utils/splitFoldersAndFiles'; 4 | 5 | import getCandidateFromFiles from './getAlbumCandidatesFromFsItems/getCandidateFromFiles'; 6 | 7 | const getAlbumCandidatesFromFsItems = async (fsItems, candidates = []) => { 8 | const { folders, files } = await splitFoldersAndFiles(fsItems); 9 | const candidateFromFiles = await getCandidateFromFiles(files); 10 | if (candidateFromFiles) { 11 | candidates.push(candidateFromFiles); 12 | } 13 | const handleMap = async (folderPath) => { 14 | const nextFsItems = await getFolderContents(folderPath); 15 | await getAlbumCandidatesFromFsItems(nextFsItems, candidates); 16 | }; 17 | await Promise.all( 18 | folders.map(handleMap), 19 | ); 20 | return candidates; 21 | }; 22 | 23 | export default getAlbumCandidatesFromFsItems; 24 | -------------------------------------------------------------------------------- /src/shared/utils/createWorkerController.js: -------------------------------------------------------------------------------- 1 | 2 | const createWorkerController = (Worker) => { 3 | let inc = 0; 4 | const worker = new Worker(); 5 | worker.call = ({ type, payload }) => { 6 | const requestId = `${inc += 1}`; 7 | return new Promise((resolve, reject) => { 8 | const handleMessage = ({ data }) => { 9 | const { responseId, payload: messagePayload, errorMessage } = data; 10 | if (responseId === requestId) { 11 | worker.removeEventListener('message', handleMessage); 12 | if (errorMessage) { 13 | reject(new Error(errorMessage)); 14 | } else { 15 | resolve(messagePayload); 16 | } 17 | } 18 | }; 19 | worker.addEventListener('message', handleMessage); 20 | worker.postMessage({ 21 | requestId, 22 | type, 23 | payload, 24 | }); 25 | }); 26 | }; 27 | return worker; 28 | }; 29 | 30 | export default createWorkerController; 31 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareForm/TracklistFieldset.css: -------------------------------------------------------------------------------- 1 | .addTracksLabel { 2 | display: block; 3 | padding: 1em; 4 | width: 100%; 5 | background-color: antiquewhite; 6 | border: 1px solid antiquewhite; 7 | color: black; 8 | text-align: center; 9 | text-transform: uppercase; 10 | cursor: pointer; 11 | } 12 | .addTracksLabel:hover { 13 | filter: contrast(90%); 14 | } 15 | 16 | .tracklistFieldset { 17 | border: none; 18 | width: 45em; 19 | margin: 0 auto; 20 | } 21 | 22 | .addTracksInputContainer { 23 | position: relative; 24 | } 25 | 26 | .addTracksInput { 27 | position: absolute; 28 | top: 0; 29 | left: 0; 30 | opacity: 0; 31 | } 32 | .addTracksInput:focus ~ .addTracksLabel { 33 | border-color: orange; 34 | } 35 | 36 | .noTracksMessage { 37 | color: red; 38 | text-transform: uppercase; 39 | font-size: 0.75em; 40 | } 41 | 42 | .addTracksInput:not(:invalid) ~ .addTracksLabel .noTracksMessage { 43 | display: none; 44 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareForm/AboutFieldset.css: -------------------------------------------------------------------------------- 1 | .aboutTextInputs { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | padding-right: 1em; 6 | margin-right: auto; 7 | } 8 | 9 | .coverInputContainer { 10 | position: relative; 11 | height: 12.5em; 12 | width: 12.5em; 13 | } 14 | 15 | .coverLabel { 16 | position: absolute; 17 | height: 100%; 18 | width: 100%; 19 | top: 0; 20 | left: 0; 21 | border: 1px solid #d3d3d3; 22 | padding: 0.25em; 23 | flex-shrink: 0; 24 | cursor: pointer; 25 | } 26 | 27 | .coverInput { 28 | opacity: 0; 29 | } 30 | .coverInput:invalid ~ .coverLabel { 31 | border-color: red; 32 | } 33 | .coverInput:focus ~ .coverLabel { 34 | border-color: orange; 35 | } 36 | 37 | .shareFormAboutFieldset { 38 | width: 40em; 39 | margin: 0 auto; 40 | border: none; 41 | display: flex; 42 | } 43 | 44 | .shareFormAboutFieldsetInline { 45 | display: flex; 46 | align-items: center; 47 | } -------------------------------------------------------------------------------- /src/main/methods/createMainWindow/withMenu.js: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, Menu, app } from 'electron'; 2 | 3 | import { IS_PRODUCTION } from '~shared/config'; 4 | 5 | const devMenuTemplate = { 6 | label: 'Development', 7 | submenu: [ 8 | { 9 | label: 'Reload', 10 | accelerator: 'CmdOrCtrl+R', 11 | click: () => { 12 | BrowserWindow.getFocusedWindow().webContents.reloadIgnoringCache(); 13 | }, 14 | }, 15 | { 16 | label: 'Toggle DevTools', 17 | accelerator: 'Alt+CmdOrCtrl+I', 18 | click: () => { 19 | BrowserWindow.getFocusedWindow().toggleDevTools(); 20 | }, 21 | }, 22 | { 23 | label: 'Quit', 24 | accelerator: 'CmdOrCtrl+Q', 25 | click: () => { 26 | app.quit(); 27 | }, 28 | }, 29 | ], 30 | }; 31 | 32 | const withMenu = (window) => { 33 | if (!IS_PRODUCTION) { 34 | window.setMenu(Menu.buildFromTemplate([devMenuTemplate])); 35 | } 36 | }; 37 | 38 | export default withMenu; 39 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareFormChange.js: -------------------------------------------------------------------------------- 1 | import { all, call, put } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | function* handleMap(api, track) { 6 | const { getTracksFromFsFiles } = api; 7 | if (track.artist === undefined && track.title === undefined) { 8 | const tracks = yield call(getTracksFromFsFiles, [track.audio]); 9 | return tracks[0]; 10 | } 11 | return track; 12 | } 13 | 14 | function* handleShareFormChange(apis, { payload }) { 15 | yield put(actions.systemUiLocked()); 16 | try { 17 | const tracks = yield all( 18 | payload.tracks.map(track => handleMap(apis, track)), 19 | ); 20 | const album = { 21 | ...payload, 22 | tracks, 23 | }; 24 | 25 | yield put(actions.systemShareFormChanged(album)); 26 | } catch (e) { 27 | console.error(e); 28 | } 29 | yield put(actions.systemUiUnlocked()); 30 | } 31 | 32 | export default handleShareFormChange; 33 | -------------------------------------------------------------------------------- /src/shared/utils/ipcMain.js: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron'; 2 | 3 | export const ipcMainTake = (channel, handler) => { 4 | ipcMain.on(channel, async (event, id, ...args) => { 5 | try { 6 | const payload = await handler(...args, event); 7 | event.sender.send(id, { payload }); 8 | } catch (error) { 9 | event.sender.send(id, { error: error.message }); 10 | } 11 | }); 12 | return () => { 13 | ipcMain.removeListener(channel, handler); 14 | }; 15 | }; 16 | 17 | export const ipcMainTakeSync = (channel, handler) => { 18 | ipcMain.on(channel, async (event, ...args) => { 19 | try { 20 | const payload = await handler(...args, event); 21 | event.returnValue = { payload }; // eslint-disable-line no-param-reassign 22 | } catch (error) { 23 | event.returnValue = { error: error.message }; // eslint-disable-line no-param-reassign 24 | } 25 | }); 26 | return () => { 27 | ipcMain.removeListener(channel, handler); 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/e2e/tests/discoverPageTests.js: -------------------------------------------------------------------------------- 1 | import { shareAlbum } from '~reusable/sharePage'; 2 | 3 | import { openSharePage, openDiscoverPage } from '~reusable/navigation'; 4 | 5 | import e2e from '~shared/data/e2e'; 6 | 7 | import album1 from '~shared/data/assets/album2'; 8 | import { startApp, closeApp } from '~reusable/app'; 9 | 10 | describe('DISCOVER PAGE TESTS', () => { 11 | // ALBUM SELECTION 12 | 13 | before(async function () { 14 | await startApp.call(this); 15 | await openSharePage.call(this); 16 | await shareAlbum.call(this, album1); 17 | await openDiscoverPage.call(this); 18 | await this.app.client.waitForExist(e2e.DISCOVER_FEED_ID); 19 | }); 20 | 21 | require('./discoverPageTests/albumActionsTests'); 22 | require('./discoverPageTests/selectAlbumTests'); 23 | require('./discoverPageTests/selectedActionsTests'); 24 | require('./discoverPageTests/searchTests.js'); 25 | require('./discoverPageTests/paginationTests.js'); 26 | 27 | after(closeApp); 28 | }); 29 | -------------------------------------------------------------------------------- /src/renderer/components/ParagraphScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './ParagraphScreen.css'; 5 | 6 | const ParagraphScreen = ({ title, paragraph, id }) => ( 7 |
11 |
12 | { 13 | title && ( 14 |

15 | {title} 16 |

17 | ) 18 | } 19 | { 20 | paragraph && ( 21 |

22 | {paragraph} 23 |

24 | ) 25 | } 26 |
27 |
28 | ); 29 | 30 | ParagraphScreen.defaultProps = { 31 | title: null, 32 | paragraph: null, 33 | id: null, 34 | }; 35 | 36 | ParagraphScreen.propTypes = { 37 | title: propTypes.string, 38 | paragraph: propTypes.string, 39 | id: propTypes.string, 40 | }; 41 | 42 | export default ParagraphScreen; 43 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/getApis/getAlbumsGateApi.js: -------------------------------------------------------------------------------- 1 | import ipc from '~shared/data/ipc'; 2 | 3 | import { rendererCalls } from '~shared/utils/ipcRenderer'; 4 | 5 | const schemaName = 'albumSchema'; 6 | 7 | const getAlbumsGateApi = async () => { 8 | const publishAlbumsByCIDs = cids => rendererCalls(ipc.METABIN_GATE_SEND_EACH, schemaName, cids); 9 | const subscribeToAlbumsGate = () => rendererCalls(ipc.METABIN_GATE_SUBSCRIBE, schemaName); 10 | const unsubscribeFromAlbumsGate = () => rendererCalls(ipc.METABIN_GATE_UNSUBSCRIBE, schemaName); 11 | const getRecievedAlbumsCache = () => ( 12 | rendererCalls(ipc.METABIN_GET_RECIEVED_DATA_CACHE, schemaName) 13 | ); 14 | const getMetabinPeersCount = () => rendererCalls(ipc.METABIN_GET_PEERS_COUNT, schemaName); 15 | 16 | return { 17 | publishAlbumsByCIDs, 18 | subscribeToAlbumsGate, 19 | unsubscribeFromAlbumsGate, 20 | getRecievedAlbumsCache, 21 | getMetabinPeersCount, 22 | }; 23 | }; 24 | 25 | export default getAlbumsGateApi; 26 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/IndicatorsBarConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | 5 | import IndicatorsBar from './IndicatorsBar'; 6 | 7 | const mapStateToProps = state => ({ 8 | isOffline: selectors.getIpfsIsOffline(state), 9 | ipfsPeers: selectors.getIpfsPeers(state), 10 | metabinPeers: selectors.getMetabinPeers(state), 11 | ipfsRepoStat: selectors.getIPFSRepoStat(state), 12 | ipfsBandwidthStat: selectors.getIPFSBandwidthStat(state), 13 | }); 14 | 15 | const mergeProps = ({ ipfsRepoStat, ipfsBandwidthStat, ...restProps }) => ({ 16 | ...restProps, 17 | ipfsRepoUsage: ( 18 | ipfsRepoStat !== null ? `${ipfsRepoStat.used} / ${ipfsRepoStat.limit}` : null 19 | ), 20 | ipfsBandwidthIn: ( 21 | ipfsBandwidthStat && ipfsBandwidthStat.in 22 | ), 23 | ipfsBandwidthOut: ( 24 | ipfsBandwidthStat && ipfsBandwidthStat.out 25 | ), 26 | }); 27 | 28 | export default connect(mapStateToProps, null, mergeProps)(IndicatorsBar); 29 | -------------------------------------------------------------------------------- /src/e2e/reusable/sharePage.js: -------------------------------------------------------------------------------- 1 | import { 2 | shareFormSelectCover, 3 | shareFormSubmit, 4 | shareFormSetAlbumTitle, 5 | shareWaitForFormExists, 6 | } from '~reusable/sharePage/shareForm'; 7 | import { 8 | shareDropZoneSelect, 9 | shareWaitForDropZoneExists, 10 | } from '~reusable/sharePage/shareDropZone'; 11 | import { hideNotificationMessage, waitForNotification } from '~reusable/notifications'; 12 | 13 | export * from './sharePage/shareDropZone'; 14 | export * from './sharePage/shareForm'; 15 | 16 | export async function shareAlbum(album, customTitle) { 17 | await shareDropZoneSelect.call(this, album.tracks[0].file); 18 | await shareWaitForFormExists.call(this); 19 | await shareFormSelectCover.call(this, album.cover); 20 | if (customTitle) { 21 | await shareFormSetAlbumTitle.call(this, customTitle); 22 | } 23 | await shareFormSubmit.call(this); 24 | await waitForNotification.call(this); 25 | await hideNotificationMessage.call(this); 26 | await shareWaitForDropZoneExists.call(this); 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareItemsSelect.js: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import i18n from '~shared/data/i18n'; 6 | 7 | function* handleShareItemsSelect(apis, { payload }) { 8 | const { getAlbumCandidatesFromFs } = apis; 9 | try { 10 | const selectedFsItems = Array.from(payload) 11 | .map(file => file.path); 12 | const candidates = yield call(getAlbumCandidatesFromFs, selectedFsItems); 13 | 14 | if (candidates.length > 0) { 15 | yield put(actions.systemShareCandidatesRecieved(candidates)); 16 | } else { 17 | yield put(actions.systemShareCandidatesNotFound( 18 | { warningMessage: i18n.NO_ALBUMS_FOUND }, 19 | )); 20 | } 21 | } catch (e) { 22 | console.error(e); 23 | yield put(actions.systemShareFilesProcessingFailed( 24 | { errorMessage: i18n.ERROR_PROCESSING_FILES }, 25 | )); 26 | } 27 | } 28 | 29 | export default handleShareItemsSelect; 30 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/getApis.js: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | 3 | import getCustomIpfsApi from './getApis/getCustomIpfsApi'; 4 | import getStorageApi from './getApis/getStorageApi'; 5 | import getAlbumsGateApi from './getApis/getAlbumsGateApi'; 6 | 7 | import actions from '#actions'; 8 | import getRestRemoteApis from './getApis/getRestRemoteApis'; 9 | 10 | function* getApis() { 11 | yield put(actions.systemAppStartProceed(11)); 12 | const [storageApi, ipfsApi] = yield [ 13 | call(getStorageApi), call(getCustomIpfsApi), 14 | ]; 15 | yield put(actions.systemAppStartProceed(33)); 16 | const [albumsGateApi] = yield [ 17 | call(getAlbumsGateApi, ipfsApi), 18 | ]; 19 | yield put(actions.systemAppStartProceed(44)); 20 | 21 | const restRemoteApis = getRestRemoteApis(); 22 | 23 | yield put(actions.systemAppStartProceed(55)); 24 | 25 | return { 26 | ...storageApi, 27 | ...albumsGateApi, 28 | ...ipfsApi, 29 | ...restRemoteApis, 30 | }; 31 | } 32 | 33 | export default getApis; 34 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage/ShareForm/TracklistFieldset/TrackInput.css: -------------------------------------------------------------------------------- 1 | .trackInput { 2 | display: flex; 3 | align-items: stretch; 4 | position: relative; 5 | background-color: antiquewhite; 6 | border-radius: 2px; 7 | margin-bottom: 0.5em; 8 | } 9 | 10 | .trackInputBody { 11 | padding: 1em; 12 | width: 100%; 13 | } 14 | 15 | .trackInputSplit { 16 | display: grid; 17 | grid-template-columns: 50% 50%; 18 | } 19 | 20 | .trackInputControlsRight, 21 | .trackInputControlsLeft { 22 | display: flex; 23 | flex-direction: column; 24 | flex-shrink: 0; 25 | } 26 | .trackInputControlsRight { 27 | border-left: 1px solid rgba(10,10,10,0.1); 28 | } 29 | .trackInputControlsLeft { 30 | border-right: 1px solid rgba(10,10,10,0.1); 31 | } 32 | 33 | .trackInputControlButton { 34 | padding: 1em; 35 | height: 100%; 36 | display: flex; 37 | justify-content: center; 38 | background-color: antiquewhite; 39 | align-items: center; 40 | } 41 | 42 | .trackInputControlButton:hover { 43 | filter: contrast(90%); 44 | } -------------------------------------------------------------------------------- /src/renderer/state/domains/appStart.js: -------------------------------------------------------------------------------- 1 | 2 | import actions from '#actions'; 3 | 4 | const DOMAIN = 'appStart'; 5 | 6 | export const getAppStartErrorMessage = state => state[DOMAIN].errorMessage; 7 | export const getAppStartProgress = state => state[DOMAIN].progress; 8 | export const isAppLocked = state => state[DOMAIN].isLocked; 9 | 10 | const initialState = { 11 | progress: 0, 12 | errorMessage: null, 13 | isLocked: false, 14 | }; 15 | 16 | const reducer = (state = initialState, action) => { 17 | const { type, payload } = action; 18 | switch (type) { 19 | case actions.systemAppStartProceed.toString(): 20 | return { ...state, progress: payload }; 21 | case actions.systemAppStartFailed.toString(): 22 | return { ...state, errorMessage: payload }; 23 | case actions.systemUiLocked.toString(): 24 | return { ...state, isLocked: true }; 25 | case actions.systemUiUnlocked.toString(): 26 | return { ...state, isLocked: false }; 27 | default: 28 | return state; 29 | } 30 | }; 31 | 32 | export default reducer; 33 | -------------------------------------------------------------------------------- /src/renderer/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import reduxLogger from 'redux-logger'; 4 | import { persistStore, persistReducer } from 'redux-persist'; 5 | import storage from 'redux-persist/lib/storage'; 6 | 7 | import rootReducer from './rootReducer'; 8 | import rootSaga from './rootSaga'; 9 | 10 | import { IS_DEVELOPMENT } from '~shared/config'; 11 | 12 | const persistConfig = { 13 | key: 'root', 14 | storage, 15 | whitelist: ['volume', 'playlist', 'legalAgreement'], 16 | }; 17 | 18 | const persistedReducer = persistReducer(persistConfig, rootReducer); 19 | 20 | const sagaMiddleware = createSagaMiddleware(); 21 | 22 | const middlewares = [sagaMiddleware]; 23 | 24 | if (IS_DEVELOPMENT) { 25 | middlewares.push(reduxLogger); 26 | } 27 | 28 | const store = createStore( 29 | persistedReducer, 30 | applyMiddleware(...middlewares), 31 | ); 32 | 33 | export const persistor = persistStore(store); 34 | 35 | sagaMiddleware.run(rootSaga); 36 | 37 | export default store; 38 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/SharePage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import e2e from '~shared/data/e2e'; 5 | 6 | import PageContainer from '~components/PageContainer'; 7 | import ProcessingScreen from '~components/ProcessingScreen'; 8 | 9 | import ShareDropZone from './SharePage/ShareDropZone'; 10 | import ShareFormConnected from './SharePage/ShareFormConnected'; 11 | 12 | const SharePage = (props) => { 13 | const { hasProcessingScreen, hasEditForm, ...restProps } = props; 14 | return ( 15 | 16 | { 17 | hasProcessingScreen ? ( 18 | 19 | ) : hasEditForm ? ( 20 | 21 | ) : ( 22 | 23 | ) 24 | } 25 | 26 | ); 27 | }; 28 | 29 | SharePage.propTypes = { 30 | hasProcessingScreen: propTypes.bool.isRequired, 31 | hasEditForm: propTypes.bool.isRequired, 32 | }; 33 | 34 | export default SharePage; 35 | -------------------------------------------------------------------------------- /src/e2e/tests/sharing/selectWrongFiles.js: -------------------------------------------------------------------------------- 1 | import i18n from '~shared/data/i18n'; 2 | import { txtFile, svgFile } from '~shared/data/assets/files'; 3 | 4 | import { 5 | getNotificationMessage, 6 | hideNotificationMessage, 7 | } from '~reusable/notifications'; 8 | 9 | import { 10 | shareDropZoneSelect, 11 | shareWaitForDropZoneExists, 12 | } from '~reusable/sharePage'; 13 | 14 | describe('select wrong files', () => { 15 | [txtFile, svgFile].forEach((file, index) => { 16 | describe(`wrong file #${index}`, () => { 17 | it('throws no error', function () { 18 | return shareDropZoneSelect.call(this, file); 19 | }); 20 | it('share drop zone remains', async function () { 21 | return shareWaitForDropZoneExists.call(this); 22 | }); 23 | it('correct notification message appears', async function () { 24 | const message = await getNotificationMessage.call(this); 25 | await hideNotificationMessage.call(this); 26 | expect(message).equal(i18n.NO_ALBUMS_FOUND); 27 | }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidatesFromFiles/getTracksFromFsFiles.js: -------------------------------------------------------------------------------- 1 | import filterFsFilesByMime from '~shared/utils/filterFsFilesByMime'; 2 | import getAudioMetadataFromFsFile from '~shared/utils/getAudioMetadataFromFsFile'; 3 | import sortTracks from './sortTracks'; 4 | 5 | const normalizeMetadata = ({ 6 | common: { 7 | track, title, album, artist, 8 | }, 9 | }) => ({ 10 | title, artist, album, trackNumber: track && track.no, 11 | }); 12 | 13 | const getTracksFromFsFiles = async (files) => { 14 | const audioFiles = await filterFsFilesByMime(files, 'audio/'); 15 | if (audioFiles.length === 0) { 16 | return undefined; 17 | } 18 | const handleMap = async (file) => { 19 | const metadata = await getAudioMetadataFromFsFile(file); 20 | return { audio: file, ...normalizeMetadata(metadata) }; 21 | }; 22 | const tracks = await Promise.all( 23 | audioFiles.map(handleMap), 24 | ); 25 | sortTracks(tracks); 26 | return tracks; 27 | }; 28 | 29 | export default getTracksFromFsFiles; 30 | -------------------------------------------------------------------------------- /src/shared/utils/createThreadController.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'child_process'; 2 | import path from 'path'; 3 | 4 | const createThreadController = (threadName) => { 5 | let inc = 0; 6 | const thread = fork(path.resolve(__dirname, `threads/${threadName}.thread.js`)); 7 | thread.call = ({ type, payload }) => { 8 | const requestId = `${inc += 1}`; 9 | return new Promise((resolve, reject) => { 10 | const handleMessage = (data) => { 11 | const { responseId, payload: messagePayload, errorMessage } = data; 12 | if (responseId === requestId) { 13 | thread.removeListener('message', handleMessage); 14 | if (errorMessage) { 15 | reject(new Error(errorMessage)); 16 | } else { 17 | resolve(messagePayload); 18 | } 19 | } 20 | }; 21 | thread.on('message', handleMessage); 22 | thread.send({ 23 | requestId, 24 | type, 25 | payload, 26 | }); 27 | }); 28 | }; 29 | return thread; 30 | }; 31 | 32 | export default createThreadController; 33 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Navigation/NavigationItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | import './NavigationItem.css'; 6 | 7 | const NavigationItem = ({ 8 | path, title, icon, id, hasIndicator, 9 | }) => ( 10 | 16 | {icon} 17 | 18 | {title} 19 | 20 | { 21 | hasIndicator && ( 22 |
23 | ) 24 | } 25 | 26 | ); 27 | 28 | NavigationItem.defaultProps = { 29 | hasIndicator: false, 30 | id: null, 31 | }; 32 | 33 | NavigationItem.propTypes = { 34 | path: propTypes.string.isRequired, 35 | title: propTypes.string.isRequired, 36 | icon: propTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types 37 | hasIndicator: propTypes.bool, 38 | id: propTypes.string, 39 | }; 40 | 41 | export default NavigationItem; 42 | -------------------------------------------------------------------------------- /src/renderer/components/DNDarea.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import './DNDarea.css'; 5 | 6 | class DragAndDropArea extends React.Component { 7 | handleChange = (e) => { 8 | const { files } = e.currentTarget; 9 | this.props.onChange(files); 10 | } 11 | 12 | render() { 13 | const { children, style, ...input } = this.props; 14 | return ( 15 |
16 | {children} 17 | { this.input = c; }} 20 | onChange={this.handleChange} 21 | type="file" 22 | className="DNDAreaInput" 23 | /> 24 |
25 | ); 26 | } 27 | } 28 | 29 | DragAndDropArea.defaultProps = { 30 | style: null, 31 | children: null, 32 | }; 33 | 34 | 35 | DragAndDropArea.propTypes = { 36 | onChange: propTypes.func.isRequired, 37 | children: propTypes.node, 38 | style: propTypes.object, // eslint-disable-line react/forbid-prop-types 39 | }; 40 | 41 | export default DragAndDropArea; 42 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startDiscoverPageService/fetchDiscoverAlbums.js: -------------------------------------------------------------------------------- 1 | import { put, call, take } from 'redux-saga/effects'; 2 | 3 | import normalizeCollectionAlbum from '~shared/utils/normalizeCollectionAlbum'; 4 | 5 | import actions from '#actions'; 6 | 7 | import { DISCOVER_FEED_LIMIT } from '~shared/data/constants'; 8 | 9 | function* fetchDiscoverAlbums(apis, { payload }) { 10 | const { 11 | findAlbumsInCollection, 12 | } = apis; 13 | const params = { limit: DISCOVER_FEED_LIMIT, text: payload }; 14 | const albumsSource = yield call(findAlbumsInCollection, params); 15 | try { 16 | while (true) { 17 | const { albums, error } = yield take(albumsSource); 18 | if (error) throw error; 19 | const normalizedAlbums = albums.map(normalizeCollectionAlbum); 20 | yield put(actions.systemDiscoverAlbumsFetchSucceed(normalizedAlbums)); 21 | } 22 | } catch (e) { 23 | console.error(e); 24 | yield put(actions.systemDiscoverAlbumsFetchFailed({ errorMessage: e.message })); 25 | } 26 | } 27 | 28 | export default fetchDiscoverAlbums; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 Jakub Szwacz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareFilesSelect/getAlbumCandidatesFromItems/getCandidatesFromFolders.js: -------------------------------------------------------------------------------- 1 | import getCandidateFromFiles from './getCandidateFromFiles'; 2 | 3 | async function getCandidatesFromFolders(apis, foldersCandidates) { 4 | if (foldersCandidates.length === 0) return undefined; 5 | const { getFolderContents } = apis; 6 | const candidates = []; 7 | const handleMapFolders = async (folder) => { 8 | const { files, folders } = await getFolderContents(folder); 9 | const [ 10 | candidateFromFiles, 11 | candidatesFromFolders, 12 | ] = await Promise.all([ 13 | getCandidateFromFiles(apis, files), 14 | getCandidatesFromFolders(apis, folders), 15 | ]); 16 | if (candidateFromFiles) { 17 | candidates.push(candidateFromFiles); 18 | } 19 | if (candidatesFromFolders) { 20 | candidates.push(...candidatesFromFolders); 21 | } 22 | }; 23 | await Promise.all( 24 | foldersCandidates.map(handleMapFolders), 25 | ); 26 | return candidates; 27 | } 28 | 29 | export default getCandidatesFromFolders; 30 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAblumsReciever.js: -------------------------------------------------------------------------------- 1 | import { call, takeEvery, put } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | import { IS_OFFLINE } from '~shared/config'; 5 | 6 | import reduxSagaTicker from '~shared/utils/reduxSagaTicker'; 7 | 8 | function* transitCachedAlbumsToStore(apis) { 9 | const { 10 | saveOrUpdateAlbums, 11 | getRecievedAlbumsCache, 12 | } = apis; 13 | try { 14 | const albums = yield call(getRecievedAlbumsCache); 15 | if (albums.length > 0) { 16 | const collectionStat = yield call(saveOrUpdateAlbums, albums); 17 | yield put(actions.systemAlbumsRecievedCacheTransited(collectionStat)); 18 | } 19 | } catch (e) { 20 | console.error(e); 21 | } 22 | } 23 | 24 | function* startAlbumsReciever(apis) { 25 | const { 26 | subscribeToAlbumsGate, 27 | } = apis; 28 | if (!IS_OFFLINE) { 29 | yield call(subscribeToAlbumsGate); 30 | const ticker = yield call(reduxSagaTicker, 10000); 31 | yield takeEvery(ticker, transitCachedAlbumsToStore, apis); 32 | } 33 | } 34 | 35 | export default startAlbumsReciever; 36 | -------------------------------------------------------------------------------- /src/e2e/tests/sharePageTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { startApp, closeApp } from '~reusable/app'; 3 | import { 4 | openDiscoverPage, 5 | openSharePage, 6 | } from '~reusable/navigation'; 7 | import { 8 | discoverFeedDoesNotExist, 9 | } from '~reusable/discoverPage'; 10 | 11 | describe('SHARE PAGE TESTS', () => { 12 | before(async function () { 13 | await startApp.call(this); 14 | await openSharePage.call(this); 15 | }); 16 | 17 | require('./sharing/selectWrongFiles'); 18 | require('./sharing/selectTrackFiles'); 19 | // TODO: require('./sharePage/selectFolderWithoutAudio') 20 | // TODO: require('./sharePage/selectFolderWithAudio') 21 | require('./sharing/checkFormValues'); 22 | require('./sharing/checkFormValidation'); 23 | require('./sharing/checkCoverInput'); 24 | require('./sharing/checkTracksOperations'); 25 | 26 | describe('check discover feed', () => { 27 | before(openDiscoverPage); 28 | it('discover feed is empty', discoverFeedDoesNotExist); 29 | after(openSharePage); 30 | }); 31 | 32 | require('./sharing/checkSubmitValidForm'); 33 | after(closeApp); 34 | }); 35 | -------------------------------------------------------------------------------- /src/renderer/state/domains/localAudiosCIDs.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'localAudiosCIDs'; 4 | 5 | const initialState = {}; 6 | 7 | export const getLocalAudiosCIDs = state => state[DOMAIN]; 8 | 9 | const handleReduceTracks = (acc, track) => { 10 | acc[track.audio] = false; 11 | return acc; 12 | }; 13 | 14 | const reducer = (state = initialState, action) => { 15 | const { type, payload } = action; 16 | switch (type) { 17 | case actions.systemIPFSFileCached.toString(): 18 | if (state[payload] === false) { 19 | return { ...state, [payload]: true }; 20 | } 21 | return state; 22 | case actions.systemPlayedTracksRecieved.toString(): 23 | return payload.reduce(handleReduceTracks, {}); 24 | case actions.systemQueuedTracksRecieved.toString(): { 25 | const newCids = payload.reduce(handleReduceTracks, {}); 26 | return { 27 | ...newCids, 28 | ...state, 29 | }; 30 | } 31 | case actions.uiPlaylistCleared.toString(): 32 | return {}; 33 | default: 34 | return state; 35 | } 36 | }; 37 | 38 | export default reducer; 39 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:9-browsers 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v3-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v3-dependencies- 28 | 29 | - run: yarn install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v3-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: yarn test 38 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsSharingService/handleShareFilesSelect/getAlbumCandidatesFromItems.js: -------------------------------------------------------------------------------- 1 | import { all, call } from 'redux-saga/effects'; 2 | 3 | import getCandidateFromFiles from './getAlbumCandidatesFromItems/getCandidateFromFiles'; 4 | import getAlbumCandidatesFromFolders from './getAlbumCandidatesFromItems/getCandidatesFromFolders'; 5 | 6 | // Rewrite to pure File solution once https://github.com/electron/electron/issues/839 resolved 7 | 8 | function* getAlbumCandidatesFromItems(apis, selectedItems) { 9 | const { splitFoldersAndFiles } = apis; 10 | const { folders, files } = yield call(splitFoldersAndFiles, selectedItems); 11 | const [candidateFromFiles, candidatesFromFolders] = yield all([ 12 | call(getCandidateFromFiles, apis, files), 13 | call(getAlbumCandidatesFromFolders, apis, folders), 14 | ]); 15 | const candidates = []; 16 | if (candidateFromFiles) { 17 | candidates.push(candidateFromFiles); 18 | } 19 | if (candidatesFromFolders) { 20 | candidates.push(...candidatesFromFolders); 21 | } 22 | return candidates; 23 | } 24 | 25 | export default getAlbumCandidatesFromItems; 26 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startDiscoverPageService/playOrQueueSelectedAlbums.js: -------------------------------------------------------------------------------- 1 | import { call, put, select } from 'redux-saga/effects'; 2 | 3 | import getPlaylistTracksFromAlbums from '~shared/utils/getPlaylistTracksFromAlbums'; 4 | 5 | import actions from '#actions'; 6 | import selectors from '#selectors'; 7 | 8 | function* playOrQueueSelectedAlbums(args, { type }) { 9 | yield put(actions.systemUiLocked()); 10 | try { 11 | const selectedAlbums = yield select(selectors.getDiscoverSelectedCids); 12 | const tracks = yield call(getPlaylistTracksFromAlbums, args, selectedAlbums); 13 | if (type === actions.uiDiscoverSelectedPlayed.toString()) { 14 | yield put(actions.systemPlayedTracksRecieved(tracks)); 15 | } 16 | if (type === actions.uiDiscoverSelectedQueued.toString()) { 17 | yield put(actions.systemQueuedTracksRecieved(tracks)); 18 | } 19 | yield put(actions.systemDiscoverSelectedActionSucceed()); 20 | } catch (e) { 21 | yield put(actions.systemDiscoverSelectedActionFailed(e.message)); 22 | } 23 | yield put(actions.systemUiUnlocked()); 24 | } 25 | 26 | export default playOrQueueSelectedAlbums; 27 | -------------------------------------------------------------------------------- /src/shared/utils/createAlbumsQuery.js: -------------------------------------------------------------------------------- 1 | const getQueryFields = $regex => [ 2 | { 3 | 'data.artist': { $regex }, 4 | }, 5 | { 6 | 'data.title': { $regex }, 7 | }, 8 | ]; 9 | 10 | const createAlbumsQuery = (searchText) => { 11 | if (searchText) { 12 | const words = searchText.split(' '); 13 | const handleFilter = s => s; 14 | const pureWords = words.filter(handleFilter); 15 | if (pureWords.length === 1) { 16 | const regex = new RegExp(pureWords[0], 'i'); 17 | return { 18 | $or: getQueryFields(regex), 19 | }; 20 | } if (pureWords.length > 1) { 21 | let expression = '('; 22 | const handleEach = (word, index, array) => { 23 | if (index === array.length - 1) { 24 | expression = expression.concat(word, ')'); 25 | } else { 26 | expression = expression.concat(word, '|'); 27 | } 28 | }; 29 | pureWords.forEach(handleEach); 30 | const regex = new RegExp(expression, 'i'); 31 | return { 32 | $and: getQueryFields(regex), 33 | }; 34 | } 35 | } 36 | return undefined; 37 | }; 38 | 39 | export default createAlbumsQuery; 40 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/AboutHero/NewReleaseCard.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import propTypes from 'prop-types'; 4 | 5 | import i18n from '~shared/data/i18n'; 6 | 7 | import AssetsButtons from './NewReleaseCard/AssetsButtons'; 8 | import './NewReleaseCard.css'; 9 | 10 | const NewReleaseCard = (props) => { 11 | const { newReleaseName } = props; 12 | return ( 13 |
14 |

15 | {i18n.NEW_VERSION_AVAILABLE} 16 |

17 |
18 | {newReleaseName} 19 |
20 |
21 | 22 |
23 | 24 | 25 | {i18n.AVAILABLE_FOR_OS} 26 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | NewReleaseCard.propTypes = { 33 | newReleaseName: propTypes.string.isRequired, 34 | newReleaseAssets: propTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types 35 | }; 36 | 37 | export default NewReleaseCard; 38 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/ControlsRight.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import MdRepeat from 'react-icons/lib/md/repeat'; 5 | import MdShuffle from 'react-icons/lib/md/shuffle'; 6 | 7 | const ControlsRight = ({ 8 | isRepeatTurnedOn, 9 | onToggleRepeat, 10 | isShuffleTurnedOn, 11 | onToggleShuffle, 12 | }) => ( 13 |
14 | 21 | 28 |
29 | ); 30 | 31 | ControlsRight.propTypes = { 32 | isShuffleTurnedOn: propTypes.bool.isRequired, 33 | isRepeatTurnedOn: propTypes.bool.isRequired, 34 | onToggleRepeat: propTypes.func.isRequired, 35 | onToggleShuffle: propTypes.func.isRequired, 36 | }; 37 | 38 | export default ControlsRight; 39 | -------------------------------------------------------------------------------- /src/main/methods/createMainWindow/withTray.js: -------------------------------------------------------------------------------- 1 | import { app, Tray, Menu } from 'electron'; 2 | 3 | import { 4 | RESOURCES_PATH, IS_MAC, IS_LINUX, IS_WINDOWS, 5 | } from '~shared/config'; 6 | 7 | export default (mainWindow) => { 8 | let trayIconPath; 9 | if (IS_LINUX) { 10 | trayIconPath = `${RESOURCES_PATH}/indicator_icons/icon16x16.png`; 11 | } 12 | if (IS_WINDOWS) { 13 | trayIconPath = `${RESOURCES_PATH}/indicator_icons/icon16x16@2x.png`; 14 | } 15 | if (IS_MAC) { 16 | trayIconPath = `${RESOURCES_PATH}/indicator_icons/icon16x16Template.png`; 17 | } 18 | const tray = new Tray(trayIconPath); 19 | 20 | tray.on('click', () => { 21 | if (mainWindow.isVisible()) { 22 | mainWindow.hide(); 23 | } else { 24 | mainWindow.show(); 25 | } 26 | }); 27 | 28 | const contextMenu = Menu.buildFromTemplate([ 29 | { 30 | label: 'Show', 31 | click: () => { 32 | mainWindow.show(); 33 | }, 34 | }, 35 | { 36 | label: 'Quit', 37 | click: () => { 38 | app.quit(); 39 | }, 40 | }, 41 | ]); 42 | 43 | tray.setContextMenu(contextMenu); 44 | tray.setToolTip('Pathephone'); 45 | }; 46 | -------------------------------------------------------------------------------- /src/e2e/reusable/notifications.js: -------------------------------------------------------------------------------- 1 | import e2e from '~shared/data/e2e'; 2 | 3 | export function getNotificationMessage(number) { 4 | let selector; 5 | if (number) { 6 | selector = `${e2e.NOTIFICATIONS_CONTAINER_ID} > *:nth-child(${number})`; 7 | } else { 8 | selector = `${e2e.NOTIFICATIONS_CONTAINER_ID} > *:first-child`; 9 | } 10 | return this.app.client.getText(selector); 11 | } 12 | 13 | export async function hideNotificationMessage(number) { 14 | let selector; 15 | if (number) { 16 | selector = `${e2e.NOTIFICATIONS_CONTAINER_ID} > *:nth-child(${number})`; 17 | } else { 18 | selector = `${e2e.NOTIFICATIONS_CONTAINER_ID} > *:first-child`; 19 | } 20 | const isExisting = await this.app.client.isExisting(selector); 21 | if (isExisting) { 22 | return this.app.client.click(selector); 23 | } 24 | return undefined; 25 | } 26 | 27 | export function waitForNotification(number) { 28 | let selector; 29 | if (number) { 30 | selector = `${e2e.NOTIFICATIONS_CONTAINER_ID} > *:nth-child(${number})`; 31 | } else { 32 | selector = `${e2e.NOTIFICATIONS_CONTAINER_ID} > *:first-child`; 33 | } 34 | return this.app.client.waitForExist(selector); 35 | } 36 | -------------------------------------------------------------------------------- /src/main/methods/startCommunication/startIpfsProcess/getIpfsDaemonParams.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import path from 'path'; 3 | 4 | import defaultIPFSdaemonConfig from '~shared/data/defaultIPFSDaemonConfig'; 5 | 6 | import { 7 | IS_TESTING, 8 | IS_OFFLINE, 9 | IS_WINDOWS, 10 | RESOURCES_PATH, 11 | IS_DEVELOPMENT, 12 | } from '~shared/config'; 13 | 14 | const getIpfsDaemonParams = () => { 15 | const type = 'go'; 16 | let exec; 17 | if (!IS_DEVELOPMENT) { 18 | if (IS_WINDOWS) { 19 | exec = `${RESOURCES_PATH}/go-ipfs/ipfs.exe`; 20 | } else { 21 | exec = `${RESOURCES_PATH}/go-ipfs/ipfs`; 22 | } 23 | } 24 | const repoPath = path.join(app.getPath('userData'), 'ipfsRepo'); 25 | const disposable = IS_TESTING; 26 | const config = !IS_TESTING && defaultIPFSdaemonConfig; 27 | const startFlags = ['--enable-pubsub-experiment', '--migrate=true']; 28 | if (IS_OFFLINE) { 29 | startFlags.push('--offline'); 30 | } 31 | return { 32 | createParams: { 33 | type, exec, 34 | }, 35 | spawnParams: { 36 | repoPath, disposable, config, 37 | }, 38 | startFlags, 39 | }; 40 | }; 41 | 42 | export default getIpfsDaemonParams; 43 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/DiscoverPageBodyConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import selectors from '#selectors'; 4 | import actions from '#actions'; 5 | 6 | import DiscoverPageBody from './DiscoverPageBody'; 7 | 8 | const mapStateToProps = (state) => { 9 | const hasAlbums = selectors.isDiscoverHasAlbums(state); 10 | const hasError = selectors.isDiscoverHasFailed(state); 11 | const isSearchPerformed = selectors.isDiscoverSearchPerformed(state); 12 | const isProcessing = selectors.isDiscoverPageProcessing(state); 13 | const isAlbumsOutdated = selectors.isDiscoverAlbumsOutdated(state); 14 | return { 15 | hasNoAlbumsScreen: !hasAlbums && !hasError && !isSearchPerformed && !isProcessing, 16 | hasNoSearchResultsScreen: isSearchPerformed && !isProcessing && !hasError && !hasAlbums, 17 | hasFeedScreen: hasAlbums, 18 | hasProcessingScreen: isProcessing, 19 | isAlbumsUpdateNeeded: isAlbumsOutdated && !hasAlbums, 20 | }; 21 | }; 22 | 23 | const mapDispatchToProps = { 24 | onAlbumsUpdateRequest: actions.systemDiscoverAlbumsFetch, 25 | }; 26 | 27 | export default connect(mapStateToProps, mapDispatchToProps)(DiscoverPageBody); 28 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startDiscoverPageService.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, takeEvery, all } from 'redux-saga/effects'; 2 | 3 | import actions from '#actions'; 4 | 5 | import deleteSelectedAlbums from './startDiscoverPageService/deleteSelectedAlbums'; 6 | import playOrQueueSelectedAlbums from './startDiscoverPageService/playOrQueueSelectedAlbums'; 7 | import playOrQueueAlbum from './startDiscoverPageService/playOrQueueAlbum'; 8 | import fetchDiscoverAlbums from './startDiscoverPageService/fetchDiscoverAlbums'; 9 | 10 | function* startDiscoverPageService(apis) { 11 | yield all([ 12 | takeLatest([ 13 | actions.uiDiscoverSearchPerformed, 14 | actions.uiDiscoverSearchCleared, 15 | actions.systemDiscoverAlbumsFetch, 16 | ], fetchDiscoverAlbums, apis), 17 | takeEvery(actions.uiDiscoverSelectedDeleted, deleteSelectedAlbums, apis), 18 | takeEvery([ 19 | actions.uiDiscoverSelectedPlayed, 20 | actions.uiDiscoverSelectedQueued, 21 | ], playOrQueueSelectedAlbums, apis), 22 | takeEvery([ 23 | actions.uiAlbumPlayed, 24 | actions.uiAlbumQueued, 25 | ], playOrQueueAlbum, apis), 26 | ]); 27 | } 28 | 29 | export default startDiscoverPageService; 30 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DiscoverPage/DiscoverPageBody/FeedScreen.css: -------------------------------------------------------------------------------- 1 | .albums-page__feed { 2 | display: grid; 3 | grid-template-columns: repeat(auto-fill, 12.5em) ; 4 | justify-content: space-around; 5 | align-content: flex-start; 6 | grid-gap: 1em; 7 | padding: 1em; 8 | width: 100%; 9 | } 10 | 11 | .albums-page__title { 12 | text-transform: uppercase; 13 | color: grey; 14 | } 15 | 16 | .feed-screen__title-bar { 17 | display: flex; 18 | align-items: center; 19 | padding: 0 2em; 20 | } 21 | 22 | .feed-screen__refresh-button { 23 | display: flex; 24 | align-items: center; 25 | margin-left: auto; 26 | background-color: var(--secondary-color); 27 | padding: 0.25em 0.75em; 28 | border-radius: 0.85em; 29 | text-transform: uppercase; 30 | color: white; 31 | box-shadow: var(--material-shadow-1); 32 | border: 1px solid var(--secondary-color); 33 | } 34 | 35 | .feed-screen__refresh-button:focus { 36 | outline: none; 37 | border-color: orange; 38 | } 39 | 40 | .feed-screen__refresh-button:active { 41 | color: var(--secondary-color); 42 | background-color: white; 43 | box-shadow: none; 44 | } 45 | 46 | .feed-screen__refresh-text { 47 | margin-left: 1em; 48 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar/TrackInfo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import secondsTohhmmss from '~shared/utils//secondsTohhmmss'; 5 | 6 | import './TrackInfo.css'; 7 | 8 | const TrackInfo = ({ 9 | title, artist, duration, currentTime, 10 | }) => ( 11 |
12 |
13 |
14 | {title} 15 |
16 | 17 | by 18 | {' '} 19 | {artist} 20 | 21 |
22 |
23 | 24 | { 25 | currentTime > 0 && `${secondsTohhmmss(duration - currentTime)} / ` 26 | } 27 | 28 | {secondsTohhmmss(duration)} 29 | 30 | 31 |
32 |
33 | ); 34 | 35 | TrackInfo.propTypes = { 36 | title: propTypes.string.isRequired, 37 | artist: propTypes.string.isRequired, 38 | duration: propTypes.number.isRequired, 39 | currentTime: propTypes.number.isRequired, 40 | }; 41 | 42 | export default TrackInfo; 43 | -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startIndicatorBarService/startIPFSStatsRetriever.js: -------------------------------------------------------------------------------- 1 | import { take, call, put } from 'redux-saga/effects'; 2 | import reduxSagaTicker from '~shared/utils/reduxSagaTicker'; 3 | import formatBytes from '~shared/utils/formatBytes'; 4 | import actions from '#actions'; 5 | 6 | const normalizeStats = ({ repoStat, bandwidthStat, peersCount } = {}) => ({ 7 | repoStat: repoStat ? { 8 | used: formatBytes(repoStat.repoSize, 2), 9 | limit: formatBytes(repoStat.storageMax, 2), 10 | } : null, 11 | bandwidthStat: bandwidthStat ? { 12 | in: formatBytes(bandwidthStat.totalIn, 2), 13 | out: formatBytes(bandwidthStat.totalOut, 2), 14 | } : null, 15 | peersCount: peersCount || null, 16 | }); 17 | 18 | function* startIPFSStatsRetriever({ getIPFSStats }) { 19 | const ticker = yield call(reduxSagaTicker, 10000); 20 | try { 21 | while (true) { 22 | yield take(ticker); 23 | const stats = yield call(getIPFSStats); 24 | yield put(actions.systemIpfsStatsRecieved(normalizeStats(stats))); 25 | } 26 | } catch (e) { 27 | console.error(e); 28 | yield put(actions.systemIpfsStatsRecieved()); 29 | ticker.close(); 30 | } 31 | } 32 | 33 | export default startIPFSStatsRetriever; 34 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Page/DonatePage/DonateCard.css: -------------------------------------------------------------------------------- 1 | .donateCard { 2 | border-radius: 2px; 3 | } 4 | 5 | .donateGif { 6 | border-radius: 2px; 7 | width: 30em; 8 | margin: 0 auto; 9 | } 10 | 11 | .cryptoTable { 12 | border: 1px solid lightgrey; 13 | table-layout: fixed; 14 | border-collapse: collapse; 15 | margin: 0 auto; 16 | width: 100%; 17 | max-width: 40em; 18 | } 19 | 20 | .cryptoTable, 21 | .cryptoTable td { 22 | border: 1px solid lightgray; 23 | } 24 | 25 | .cryptoTable td { 26 | padding: 0.75em 1em; 27 | word-wrap: break-word; 28 | } 29 | 30 | .cryptoTable td:first-child { 31 | width: 30%; 32 | } 33 | 34 | .donateButtons { 35 | padding: 2em 0; 36 | display: flex; 37 | justify-content: center; 38 | } 39 | 40 | .donateCreditCardLink { 41 | color: inherit; 42 | padding: 0.5em 0.75em; 43 | text-decoration: none; 44 | border: 1px solid orange; 45 | border-radius: 2px; 46 | } 47 | 48 | .donateCreditCardLink:focus, 49 | .donateCreditCardLink:active { 50 | box-shadow: 0 0 0 0.2rem antiquewhite; 51 | outline: none; 52 | } 53 | 54 | .donateCreditCardLink:hover { 55 | background-color: rgba(10,10,10,0.1); 56 | } 57 | 58 | .donateCreditCardLink:active { 59 | background-color: orange; 60 | } -------------------------------------------------------------------------------- /src/renderer/sagas/startApp/startServices/startAlbumsPublisher.js: -------------------------------------------------------------------------------- 1 | import { call, takeEvery } from 'redux-saga/effects'; 2 | import { eventChannel } from 'redux-saga'; 3 | 4 | import { IS_OFFLINE } from '~shared/config'; 5 | 6 | import { ALBUMS_PUBLISH_INTERVAL, ALBUMS_APPEARENCE_INTERVAL } from '~shared/data/constants'; 7 | 8 | function getOutdatedAlbumsChannel(apis) { 9 | const { findOutdatedAlbumsInCollection } = apis; 10 | return eventChannel((emit) => { 11 | const handleTick = async () => { 12 | const period = new Date().getTime() - ALBUMS_APPEARENCE_INTERVAL; 13 | const albums = await findOutdatedAlbumsInCollection(period); 14 | emit(albums); 15 | }; 16 | const interval = setInterval(handleTick, ALBUMS_PUBLISH_INTERVAL); 17 | return () => { 18 | clearInterval(interval); 19 | }; 20 | }); 21 | } 22 | 23 | function* publishAlbum({ publishAlbumsByCIDs }, albums) { 24 | yield call(publishAlbumsByCIDs, albums); 25 | } 26 | 27 | function* startAlbumsPublisher(apis) { 28 | if (!IS_OFFLINE) { 29 | const outdatedAlbumsChannel = yield call(getOutdatedAlbumsChannel, apis); 30 | yield takeEvery(outdatedAlbumsChannel, publishAlbum, apis); 31 | } 32 | } 33 | 34 | export default startAlbumsPublisher; 35 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Player/ActivePlayer/TrackBar/TrackTimeline.css: -------------------------------------------------------------------------------- 1 | .timeline__input-container { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: stretch; 8 | justify-content: center; 9 | } 10 | 11 | .timelineInput { 12 | -webkit-appearance: none; /* Hides the slider so that custom slider can be made */ 13 | appearance: none; 14 | background: transparent; /* Otherwise white in Chrome */ 15 | margin: 0; 16 | flex-shrink: 0; 17 | height: 100%; 18 | cursor: pointer; 19 | } 20 | .timelineInput:focus { 21 | outline: none; 22 | } 23 | 24 | .timelineInput::-webkit-slider-runnable-track { 25 | background: none; 26 | border-left: 1px solid rgb(230,230,230); 27 | border-right: 1px solid rgb(230,230,230); 28 | width: 100%; 29 | height: 100%; 30 | } 31 | .timelineInput:hover::-webkit-slider-runnable-track, 32 | .timelineInput:active::-webkit-slider-runnable-track { 33 | } 34 | 35 | .timelineInput::-webkit-slider-thumb { 36 | height: 100%; 37 | width: 1px; 38 | background: var(--secondary-color); 39 | -webkit-appearance: none; 40 | appearance: none; 41 | } 42 | .timelineInput:focus::-webkit-slider-thumb { 43 | background: orange; 44 | } -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Notifications/Toast.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import propTypes from 'prop-types'; 3 | 4 | import { 5 | NOTIFICATION_TYPE_SUCCESS, 6 | NOTIFICATION_TYPE_WARNING, 7 | NOTIFICATION_TYPE_ERROR, 8 | } from '~shared/data/constants'; 9 | 10 | class Toast extends React.Component { 11 | handleToastClick = () => { 12 | const { onToastClick, id } = this.props; 13 | onToastClick(id); 14 | } 15 | 16 | render() { 17 | const { text, type } = this.props; 18 | return ( 19 | 33 | ); 34 | } 35 | } 36 | 37 | Toast.propTypes = { 38 | text: propTypes.string.isRequired, 39 | type: propTypes.number.isRequired, 40 | onToastClick: propTypes.func.isRequired, 41 | id: propTypes.number.isRequired, 42 | }; 43 | 44 | export default Toast; 45 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "electron": "1.7.12" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ], 14 | "plugins": [ 15 | "@babel/plugin-syntax-dynamic-import", 16 | "@babel/plugin-syntax-import-meta", 17 | "@babel/plugin-proposal-class-properties", 18 | "@babel/plugin-proposal-json-strings", 19 | [ 20 | "@babel/plugin-proposal-decorators", 21 | { 22 | "legacy": true 23 | } 24 | ], 25 | "@babel/plugin-proposal-function-sent", 26 | "@babel/plugin-proposal-export-namespace-from", 27 | "@babel/plugin-proposal-numeric-separator", 28 | "@babel/plugin-proposal-throw-expressions", 29 | "@babel/plugin-proposal-export-default-from", 30 | "@babel/plugin-proposal-logical-assignment-operators", 31 | "@babel/plugin-proposal-optional-chaining", 32 | [ 33 | "@babel/plugin-proposal-pipeline-operator", 34 | { 35 | "proposal": "minimal" 36 | } 37 | ], 38 | "@babel/plugin-proposal-nullish-coalescing-operator", 39 | "@babel/plugin-proposal-do-expressions", 40 | "@babel/plugin-proposal-function-bind" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/view/App/ReadyScreen/Playlist/PlaylistTrackConnected.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import actions from '#actions'; 4 | import selectors from '#selectors'; 5 | 6 | import PlaylistTrack from './PlaylistTrack'; 7 | 8 | const mapStateToProps = state => ({ 9 | currentTrackIndex: selectors.getCurrentTrackIndex(state), 10 | tracksByIndex: selectors.getPlaylistTracksByIndex(state), 11 | cachedCIDs: selectors.getCachedCIDs(state), 12 | }); 13 | 14 | const mapDispatchToProps = { 15 | onPlayTrack: actions.uiPlaylistTrackPlayed, 16 | onRemoveTrack: actions.uiPlaylistTrackRemoved, 17 | }; 18 | 19 | const mergeProps = (stateProps, dispatchProps, ownProps) => { 20 | const { cachedCIDs, tracksByIndex, currentTrackIndex } = stateProps; 21 | const { index } = ownProps; 22 | const { audio, ...trackData } = tracksByIndex[index]; 23 | return { 24 | ...trackData, 25 | isCurrent: index === currentTrackIndex, 26 | isDownloaded: !!cachedCIDs[audio], 27 | order: index, 28 | onPlayClick() { 29 | dispatchProps.onPlayTrack(index); 30 | }, 31 | onRemoveClick() { 32 | dispatchProps.onRemoveTrack(index); 33 | }, 34 | }; 35 | }; 36 | 37 | export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(PlaylistTrack); 38 | -------------------------------------------------------------------------------- /src/shared/data/schemas/album/albumInstanceSchema.js: -------------------------------------------------------------------------------- 1 | const CIDv0Schema = { 2 | type: 'string', 3 | pattern: '^(Qm)[1-9A-HJ-NP-Za-km-z]{44}$', 4 | }; 5 | 6 | const albumInstanceSchema = { 7 | type: 'object', 8 | properties: { 9 | title: { 10 | type: 'string', 11 | minLength: 1, 12 | maxLength: 100, 13 | }, 14 | artist: { 15 | type: 'string', 16 | minLength: 1, 17 | maxLength: 100, 18 | }, 19 | cover: { 20 | type: 'object', 21 | properties: { 22 | image: CIDv0Schema, 23 | }, 24 | required: ['image'], 25 | }, 26 | tracks: { 27 | type: 'array', 28 | minItems: 1, 29 | maxItems: 100, 30 | items: { 31 | type: 'object', 32 | properties: { 33 | title: { 34 | type: 'string', 35 | minLength: 1, 36 | maxLength: 100, 37 | }, 38 | artist: { 39 | type: 'string', 40 | minLength: 1, 41 | maxLength: 100, 42 | }, 43 | audio: CIDv0Schema, 44 | }, 45 | required: ['title', 'artist', 'audio'], 46 | }, 47 | }, 48 | }, 49 | required: ['title', 'artist', 'tracks', 'cover'], 50 | }; 51 | 52 | export default albumInstanceSchema; 53 | -------------------------------------------------------------------------------- /src/renderer/state/domains/ipfsInfo.js: -------------------------------------------------------------------------------- 1 | import actions from '#actions'; 2 | 3 | const DOMAIN = 'ipfsInfo'; 4 | 5 | const initialState = { 6 | isOffline: false, 7 | gateway: null, 8 | apiEndpoint: null, 9 | peersCount: null, 10 | metabinPeersCount: null, 11 | repoStat: null, 12 | bandwidthStat: null, 13 | }; 14 | 15 | export const getIpfsIsOffline = state => state[DOMAIN].isOffline; 16 | export const getIpfsGateway = state => state[DOMAIN].gateway; 17 | export const getIpfsApiEndpoint = state => state[DOMAIN].apiEndpoint; 18 | export const getIpfsPeers = state => state[DOMAIN].peersCount; 19 | export const getMetabinPeers = state => state[DOMAIN].metabinPeersCount; 20 | export const getIPFSRepoStat = state => state[DOMAIN].repoStat; 21 | export const getIPFSBandwidthStat = state => state[DOMAIN].bandwidthStat; 22 | 23 | const reducer = (state = initialState, action) => { 24 | const { type, payload } = action; 25 | switch (type) { 26 | case actions.systemIpfsInfoRecieved.toString(): 27 | case actions.systemIpfsStatsRecieved.toString(): 28 | case actions.systemMetabinPeersRecieved.toString(): 29 | return { 30 | ...state, 31 | ...payload, 32 | }; 33 | default: 34 | return state; 35 | } 36 | }; 37 | 38 | export default reducer; 39 | -------------------------------------------------------------------------------- /src/main/threads/getAlbumCandidatesFromFsItems.thread/getAlbumCandidatesFromFsItems/getCandidateFromFiles.js: -------------------------------------------------------------------------------- 1 | 2 | import getCoverFromFsFiles from './getCandidatesFromFiles/getCoverFromFsFiles'; 3 | import extractAlbumInfoFromTracks from './getCandidatesFromFiles/extractAlbumInfoFromTracks'; 4 | import filterFsFilesByMime from '~shared/utils/filterFsFilesByMime'; 5 | import getTracksAndCoverFromAudioFiles from './getCandidatesFromFiles/getTracksAndCoverFromAudioFiles'; 6 | import writeMetadataPictureToFs from './getCandidatesFromFiles/writeMetadataPictureToFs'; 7 | 8 | async function getCandidateFromFiles(files) { 9 | if (files.length === 0) return undefined; 10 | const audioFiles = await filterFsFilesByMime(files, 'audio/'); 11 | if (audioFiles.length === 0) return undefined; 12 | let coverImage; 13 | const { tracks, cover } = await getTracksAndCoverFromAudioFiles(audioFiles); 14 | if (tracks.length === 0) return undefined; 15 | if (!cover) { 16 | coverImage = await getCoverFromFsFiles(files); 17 | } else { 18 | coverImage = await writeMetadataPictureToFs(cover); 19 | } 20 | const { title, artist } = extractAlbumInfoFromTracks(tracks); 21 | return { 22 | tracks, cover: { image: coverImage }, artist, title, 23 | }; 24 | } 25 | 26 | export default getCandidateFromFiles; 27 | -------------------------------------------------------------------------------- /src/renderer/components/CustomButton.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Reset button styles. 3 | * It takes a bit of work to achieve a neutral look. 4 | */ 5 | button { 6 | padding: 0; 7 | border: none; 8 | font: inherit; 9 | color: inherit; 10 | background-color: transparent; 11 | /* show a hand cursor on hover; some argue that we 12 | should keep the default arrow cursor for buttons */ 13 | cursor: pointer; 14 | } 15 | .customButton { 16 | 17 | /* default for 20 | 31 | 38 |
39 | ); 40 | 41 | ControlsLeft.propTypes = { 42 | hasPauseIcon: propTypes.bool.isRequired, 43 | onPlayNextClick: propTypes.func.isRequired, 44 | onPlayPreviousClick: propTypes.func.isRequired, 45 | onPlaybackToggle: propTypes.func.isRequired, 46 | }; 47 | 48 | export default ControlsLeft; 49 | -------------------------------------------------------------------------------- /src/main/threads/ipfs.thread/startIpfsDaemon.js: -------------------------------------------------------------------------------- 1 | import IPFSFactory from 'ipfsd-ctl'; 2 | 3 | const startIpfsDaemon = ({ createParams, spawnParams, startFlags }) => ( 4 | new Promise((resolve, reject) => { 5 | const onError = reject; 6 | const onSuccess = resolve; 7 | 8 | const startIpfsNode = (node) => { 9 | const startCallback = (err) => { 10 | if (err) { 11 | onError(err); 12 | } else { 13 | onSuccess(node); 14 | console.log(` 15 | ipfs api running on ${node.apiAddr} 16 | ipfs gateway running on ${node.gatewayAddr} 17 | `); 18 | } 19 | }; 20 | node.start(startFlags, startCallback); 21 | }; 22 | const initIpfsNode = (node) => { 23 | const initCallback = (err) => { 24 | if (err) { 25 | onError(err); 26 | } else { 27 | startIpfsNode(node); 28 | } 29 | }; 30 | node.init(initCallback); 31 | }; 32 | const spawnCallback = (err, node) => { 33 | if (err) { 34 | onError(err); 35 | } else if (node.disposable) { 36 | onSuccess(node); 37 | } else 38 | if (node.initialized) { 39 | startIpfsNode(node); 40 | } else { 41 | initIpfsNode(node); 42 | } 43 | }; 44 | 45 | IPFSFactory 46 | .create(createParams) 47 | .spawn(spawnParams, spawnCallback); 48 | }) 49 | ); 50 | 51 | export default startIpfsDaemon; 52 | --------------------------------------------------------------------------------