├── CNAME
├── src
├── assets
│ ├── .gitkeep
│ ├── robots.txt
│ ├── service-worker.js
│ ├── icon
│ │ ├── 120x120.png
│ │ ├── favicon.ico
│ │ ├── apple-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon-96x96.png
│ │ ├── ms-icon-70x70.png
│ │ ├── android-icon-36.png
│ │ ├── android-icon-48.png
│ │ ├── android-icon-72.png
│ │ ├── android-icon-96.png
│ │ ├── ms-icon-144x144.png
│ │ ├── ms-icon-150x150.png
│ │ ├── ms-icon-310x310.png
│ │ ├── android-icon-144.png
│ │ ├── android-icon-192.png
│ │ ├── apple-icon-114x114.png
│ │ ├── apple-icon-120x120.png
│ │ ├── apple-icon-144x144.png
│ │ ├── apple-icon-152x152.png
│ │ ├── apple-icon-180x180.png
│ │ ├── apple-icon-57x57.png
│ │ ├── apple-icon-60x60.png
│ │ ├── apple-icon-72x72.png
│ │ ├── apple-icon-76x76.png
│ │ ├── apple-icon-precomposed.png
│ │ ├── browserconfig.xml
│ │ ├── icon.html
│ │ └── manifest.json
│ ├── humans.txt
│ └── manifest.json
├── app
│ ├── shared
│ │ ├── components
│ │ │ ├── btn
│ │ │ │ ├── index.ts
│ │ │ │ └── btn.directive.ts
│ │ │ ├── youtube-list
│ │ │ │ ├── index.ts
│ │ │ │ ├── youtube-list.scss
│ │ │ │ └── youtube-list.ts
│ │ │ ├── youtube-media
│ │ │ │ ├── index.ts
│ │ │ │ └── youtube-media.ts
│ │ │ ├── button-group
│ │ │ │ ├── index.ts
│ │ │ │ ├── button-group.component.scss
│ │ │ │ ├── button-group.component.spec.ts
│ │ │ │ └── button-group.component.ts
│ │ │ ├── button-icon
│ │ │ │ ├── index.ts
│ │ │ │ ├── button-icon.scss
│ │ │ │ └── button-icon.component.ts
│ │ │ ├── youtube-playlist
│ │ │ │ ├── index.ts
│ │ │ │ ├── youtube-playlist.html
│ │ │ │ └── youtube-playlist.ts
│ │ │ ├── loading-indicator
│ │ │ │ ├── index.ts
│ │ │ │ ├── loading-indicator.scss
│ │ │ │ └── loading-indicator.component.ts
│ │ │ ├── playlist-viewer
│ │ │ │ ├── playlist-viewer.scss
│ │ │ │ ├── index.ts
│ │ │ │ ├── playlist-cover.scss
│ │ │ │ ├── playlist-cover.component.ts
│ │ │ │ └── playlist-viewer.component.ts
│ │ │ └── index.ts
│ │ ├── directives
│ │ │ ├── icon
│ │ │ │ ├── index.ts
│ │ │ │ └── icon.directive.ts
│ │ │ └── index.ts
│ │ ├── animations
│ │ │ ├── index.ts
│ │ │ └── fade-in.animation.ts
│ │ ├── utils
│ │ │ ├── data.utils.ts
│ │ │ └── media.utils.ts
│ │ ├── pipes
│ │ │ ├── index.ts
│ │ │ ├── search.pipe.ts
│ │ │ ├── toFriendlyDuration.pipe.ts
│ │ │ └── toFriendlyDuration.pipe.spec.ts
│ │ └── index.ts
│ ├── containers
│ │ ├── playlist-view
│ │ │ ├── playlist-view.component.scss
│ │ │ ├── playlist-view.routing.ts
│ │ │ ├── index.ts
│ │ │ ├── playlist-view.proxy.ts
│ │ │ └── playlist-view.component.ts
│ │ ├── user
│ │ │ ├── playlists
│ │ │ │ ├── index.ts
│ │ │ │ └── playlists.component.ts
│ │ │ ├── user.scss
│ │ │ ├── index.ts
│ │ │ ├── user.routing.ts
│ │ │ ├── user.guard.ts
│ │ │ ├── user.component.ts
│ │ │ └── user-player.service.ts
│ │ ├── app-navbar
│ │ │ ├── app-navbar-menu
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-navbar-menu.component.scss
│ │ │ │ └── app-navbar-menu.component.spec.ts
│ │ │ ├── app-navbar-user
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-navbar-user.component.scss
│ │ │ │ ├── app-navbar-user.component.ts
│ │ │ │ └── app-navbar-user.component.spec.ts
│ │ │ └── index.ts
│ │ ├── app-search
│ │ │ ├── search-navigator
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-navigator.component.html
│ │ │ │ ├── search-navigator.component.spec.ts
│ │ │ │ ├── search-navigator.component.scss
│ │ │ │ └── search-navigator.component.ts
│ │ │ ├── app-search.scss
│ │ │ ├── youtube-videos.scss
│ │ │ ├── app-search.routing.ts
│ │ │ ├── index.ts
│ │ │ ├── player-search.scss
│ │ │ ├── youtube-videos.component.ts
│ │ │ ├── app-search.component.ts
│ │ │ └── youtube-playlists.component.ts
│ │ └── index.ts
│ ├── core
│ │ ├── components
│ │ │ ├── app-player
│ │ │ │ ├── image-blur
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── image-blur.scss
│ │ │ │ │ └── image-blur.component.ts
│ │ │ │ ├── media-info
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── media-info.scss
│ │ │ │ │ └── media-info.component.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── player-resizer
│ │ │ │ │ ├── player-resizer.component.ts
│ │ │ │ │ └── player-resizer.scss
│ │ │ │ └── player-controls
│ │ │ │ │ ├── player-controls.scss
│ │ │ │ │ └── player-controls.component.ts
│ │ │ ├── now-playing
│ │ │ │ ├── now-playlist-filter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── now-playlist-filter.scss
│ │ │ │ ├── now-playlist
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── now-playlist.scss
│ │ │ │ │ └── now-playlist-track.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── now-playing.scss
│ │ │ │ └── now-playing.component.ts
│ │ │ ├── index.ts
│ │ │ ├── app-brand
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-brand.component.ts
│ │ │ │ └── app-brand.scss
│ │ │ ├── app-navigator
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-navigator.scss
│ │ │ │ └── app-navigator.component.ts
│ │ │ └── app-sidebar
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-sidebar.proxy.ts
│ │ │ │ └── app-sidebar.component.ts
│ │ ├── store
│ │ │ ├── router-store
│ │ │ │ ├── index.ts
│ │ │ │ ├── router-store.reducer.ts
│ │ │ │ └── router-store.actions.ts
│ │ │ ├── app-layout
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-layout.selectors.ts
│ │ │ │ └── app-layout.actions.ts
│ │ │ ├── app-player
│ │ │ │ ├── index.ts
│ │ │ │ ├── app-player.selectors.ts
│ │ │ │ └── app-player.spec.ts
│ │ │ ├── now-playlist
│ │ │ │ ├── index.ts
│ │ │ │ └── now-playlist.selectors.ts
│ │ │ ├── player-search
│ │ │ │ ├── index.ts
│ │ │ │ ├── player-search.interfaces.ts
│ │ │ │ ├── player-search.selectors.ts
│ │ │ │ ├── player-search.reducer.spec.ts
│ │ │ │ └── player-search.reducer.ts
│ │ │ ├── user-profile
│ │ │ │ ├── index.ts
│ │ │ │ ├── user-profile.selectors.ts
│ │ │ │ └── user-profile.reducer.ts
│ │ │ ├── ngrx-worker.ts
│ │ │ ├── reducers.ts
│ │ │ └── index.ts
│ │ ├── api
│ │ │ ├── index.ts
│ │ │ ├── app.api.ts
│ │ │ └── app-player.api.ts
│ │ ├── resolvers
│ │ │ ├── index.ts
│ │ │ ├── playlist-videos.resolver.ts
│ │ │ └── playlist.resolver.ts
│ │ ├── module-imports.guards.ts
│ │ ├── effects
│ │ │ ├── index.ts
│ │ │ ├── router.effects.ts
│ │ │ ├── app-settings.effects.ts
│ │ │ ├── analytics.effects.ts
│ │ │ └── app-player.effects.ts
│ │ ├── index.ts
│ │ └── services
│ │ │ ├── version-checker.service.spec.ts
│ │ │ ├── youtube-videos-info.service.ts
│ │ │ ├── gapi-loader.service.ts
│ │ │ ├── analytics.service.ts
│ │ │ ├── index.ts
│ │ │ ├── youtube-player.service.ts
│ │ │ ├── version-checker.service.ts
│ │ │ ├── youtube-api.service.spec.ts
│ │ │ ├── media-parser.service.spec.ts
│ │ │ ├── youtube.search.spec.ts
│ │ │ ├── media-parser.service.ts
│ │ │ └── now-playlist.service.ts
│ ├── app.themes.ts
│ ├── app.component.html
│ ├── app.routes.ts
│ ├── app.component.scss
│ ├── app.component.ts
│ ├── app.module.ts
│ ├── app.component.spec.ts
│ └── app.e2e.ts
├── favicon.ico
├── fonts
│ ├── FontAwesome.otf
│ ├── fontawesome-webfont.eot
│ ├── fontawesome-webfont.ttf
│ ├── fontawesome-webfont.woff
│ ├── fontawesome-webfont.woff2
│ ├── glyphicons-halflings-regular.eot
│ ├── glyphicons-halflings-regular.ttf
│ ├── glyphicons-halflings-regular.woff
│ └── glyphicons-halflings-regular.woff2
├── css
│ ├── themes
│ │ ├── index.themes.scss
│ │ ├── arctic.theme.scss
│ │ ├── bumblebee.theme.scss
│ │ └── halloween.theme.scss
│ ├── core
│ │ └── global.scss
│ ├── layout
│ │ └── navbar.scss
│ └── style.scss
├── typings.d.ts
├── environments
│ ├── environment.prod.ts
│ └── environment.ts
├── main.ts
├── tsconfig.app.json
├── test.ts
├── tsconfig.spec.json
└── polyfills.ts
├── .npmrc
├── config
├── heroku
│ ├── .gitignore
│ ├── Procfile
│ ├── bs-config.js
│ └── package.json
├── deploy.sh
└── build-env.js
├── Procfile
├── .editorconfig
├── e2e
├── tsconfig.e2e.json
├── app.po.ts
└── app.e2e-spec.ts
├── .gitignore
├── .travis.yml
├── LICENSE
├── protractor.conf.js
├── tsconfig.json
├── tests
└── mocks
│ └── youtube.media.item.ts
├── karma.conf.js
└── .angular-cli.json
/CNAME:
--------------------------------------------------------------------------------
1 | echoesplayer.com
--------------------------------------------------------------------------------
/src/assets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
--------------------------------------------------------------------------------
/config/heroku/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
--------------------------------------------------------------------------------
/config/heroku/Procfile:
--------------------------------------------------------------------------------
1 | web: npm start
--------------------------------------------------------------------------------
/src/assets/robots.txt:
--------------------------------------------------------------------------------
1 | # robotstxt.org
2 |
3 | User-agent: *
4 |
--------------------------------------------------------------------------------
/src/app/shared/components/btn/index.ts:
--------------------------------------------------------------------------------
1 | export * from './btn.directive';
2 |
--------------------------------------------------------------------------------
/src/app/shared/directives/icon/index.ts:
--------------------------------------------------------------------------------
1 | export * from './icon.directive';
2 |
--------------------------------------------------------------------------------
/src/assets/service-worker.js:
--------------------------------------------------------------------------------
1 | // This file is intentionally without code.
2 |
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './youtube-list';
2 |
--------------------------------------------------------------------------------
/src/app/containers/playlist-view/playlist-view.component.scss:
--------------------------------------------------------------------------------
1 | .playlist-view {
2 |
3 | }
--------------------------------------------------------------------------------
/src/app/containers/user/playlists/index.ts:
--------------------------------------------------------------------------------
1 | export * from './playlists.component';
2 |
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-media/index.ts:
--------------------------------------------------------------------------------
1 | export * from './youtube-media';
2 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-group/index.ts:
--------------------------------------------------------------------------------
1 | export * from './button-group.component';
2 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-icon/index.ts:
--------------------------------------------------------------------------------
1 | export * from './button-icon.component';
2 |
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-playlist/index.ts:
--------------------------------------------------------------------------------
1 | export * from './youtube-playlist';
2 |
--------------------------------------------------------------------------------
/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/favicon.ico
--------------------------------------------------------------------------------
/src/app/core/components/app-player/image-blur/index.ts:
--------------------------------------------------------------------------------
1 | export * from './image-blur.component';
2 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/media-info/index.ts:
--------------------------------------------------------------------------------
1 | export * from './media-info.component';
2 |
--------------------------------------------------------------------------------
/src/app/shared/animations/index.ts:
--------------------------------------------------------------------------------
1 | export { fadeInAnimation, flyInOut } from './fade-in.animation';
2 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-icon/button-icon.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | .btn {
3 | padding-left: 0;
4 | }
5 | }
--------------------------------------------------------------------------------
/src/fonts/FontAwesome.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/FontAwesome.otf
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playlist-filter/index.ts:
--------------------------------------------------------------------------------
1 | export * from './now-playlist-filter.component';
2 |
--------------------------------------------------------------------------------
/src/assets/icon/120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/120x120.png
--------------------------------------------------------------------------------
/src/assets/icon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/favicon.ico
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon.png
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-menu/index.ts:
--------------------------------------------------------------------------------
1 | export { AppNavbarMenuComponent } from './app-navbar-menu.component';
2 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-user/index.ts:
--------------------------------------------------------------------------------
1 | export { AppNavbarUserComponent } from './app-navbar-user.component';
2 |
--------------------------------------------------------------------------------
/src/app/core/store/router-store/index.ts:
--------------------------------------------------------------------------------
1 | export * from './router-store.actions';
2 | export * from './router-store.reducer';
3 |
--------------------------------------------------------------------------------
/src/app/shared/components/loading-indicator/index.ts:
--------------------------------------------------------------------------------
1 | export { LoadingIndicatorComponent } from './loading-indicator.component';
--------------------------------------------------------------------------------
/src/app/shared/components/playlist-viewer/playlist-viewer.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | display: block;
3 | padding-bottom: 6rem;
4 | }
--------------------------------------------------------------------------------
/src/assets/icon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/favicon-16x16.png
--------------------------------------------------------------------------------
/src/assets/icon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/favicon-32x32.png
--------------------------------------------------------------------------------
/src/assets/icon/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/favicon-96x96.png
--------------------------------------------------------------------------------
/src/assets/icon/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/ms-icon-70x70.png
--------------------------------------------------------------------------------
/src/fonts/fontawesome-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/fontawesome-webfont.eot
--------------------------------------------------------------------------------
/src/fonts/fontawesome-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/fontawesome-webfont.ttf
--------------------------------------------------------------------------------
/src/app/containers/app-search/search-navigator/index.ts:
--------------------------------------------------------------------------------
1 | export { SearchNavigatorComponent } from './search-navigator.component';
2 |
--------------------------------------------------------------------------------
/src/assets/icon/android-icon-36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/android-icon-36.png
--------------------------------------------------------------------------------
/src/assets/icon/android-icon-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/android-icon-48.png
--------------------------------------------------------------------------------
/src/assets/icon/android-icon-72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/android-icon-72.png
--------------------------------------------------------------------------------
/src/assets/icon/android-icon-96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/android-icon-96.png
--------------------------------------------------------------------------------
/src/assets/icon/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/ms-icon-144x144.png
--------------------------------------------------------------------------------
/src/assets/icon/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/ms-icon-150x150.png
--------------------------------------------------------------------------------
/src/assets/icon/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/ms-icon-310x310.png
--------------------------------------------------------------------------------
/src/fonts/fontawesome-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/fontawesome-webfont.woff
--------------------------------------------------------------------------------
/src/fonts/fontawesome-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/fontawesome-webfont.woff2
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: npm install && npm run build:prod && npm run copy:heroku && node_modules/http-server/bin/http-server dist --cors -p $PORT
--------------------------------------------------------------------------------
/src/assets/icon/android-icon-144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/android-icon-144.png
--------------------------------------------------------------------------------
/src/assets/icon/android-icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/android-icon-192.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-114x114.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-120x120.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-144x144.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-152x152.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-180x180.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-57x57.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-60x60.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-72x72.png
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-76x76.png
--------------------------------------------------------------------------------
/src/css/themes/index.themes.scss:
--------------------------------------------------------------------------------
1 | @import
2 | './themes/arctic.theme',
3 | './themes/halloween.theme',
4 | './themes/bumblebee.theme';
--------------------------------------------------------------------------------
/src/typings.d.ts:
--------------------------------------------------------------------------------
1 | /* SystemJS module definition */
2 | declare var module: NodeModule;
3 | interface NodeModule {
4 | id: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/shared/directives/index.ts:
--------------------------------------------------------------------------------
1 | import { IconDirective } from './icon';
2 |
3 | export const CORE_DIRECTIVES = [
4 | IconDirective
5 | ];
6 |
--------------------------------------------------------------------------------
/src/assets/icon/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/assets/icon/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/src/fonts/glyphicons-halflings-regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/glyphicons-halflings-regular.eot
--------------------------------------------------------------------------------
/src/fonts/glyphicons-halflings-regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/glyphicons-halflings-regular.ttf
--------------------------------------------------------------------------------
/src/fonts/glyphicons-halflings-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/glyphicons-halflings-regular.woff
--------------------------------------------------------------------------------
/src/fonts/glyphicons-halflings-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pranesh239/echoes-player/master/src/fonts/glyphicons-halflings-regular.woff2
--------------------------------------------------------------------------------
/src/app/app.themes.ts:
--------------------------------------------------------------------------------
1 | export const Themes = [
2 | 'arctic',
3 | 'halloween',
4 | 'bumblebee'
5 | ];
6 |
7 | export const DEFAULT_THEME = Themes[0];
8 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playlist/index.ts:
--------------------------------------------------------------------------------
1 | export * from './now-playlist.component';
2 | export * from './now-playlist-track.component';
3 |
--------------------------------------------------------------------------------
/src/app/core/store/app-layout/index.ts:
--------------------------------------------------------------------------------
1 | export * from './app-layout.reducer';
2 | export * from './app-layout.actions';
3 | export * from './app-layout.selectors';
4 |
--------------------------------------------------------------------------------
/src/app/core/store/app-player/index.ts:
--------------------------------------------------------------------------------
1 | export * from './app-player.reducer';
2 | export * from './app-player.actions';
3 | export * from './app-player.selectors';
4 |
--------------------------------------------------------------------------------
/src/app/core/store/now-playlist/index.ts:
--------------------------------------------------------------------------------
1 | export * from './now-playlist.reducer';
2 | export * from './now-playlist.actions';
3 | export * from './now-playlist.selectors';
4 |
--------------------------------------------------------------------------------
/src/app/core/store/player-search/index.ts:
--------------------------------------------------------------------------------
1 | export * from './player-search.reducer';
2 | export * from './player-search.actions';
3 | export * from './player-search.selectors';
4 |
--------------------------------------------------------------------------------
/src/app/core/store/user-profile/index.ts:
--------------------------------------------------------------------------------
1 | export * from './user-profile.reducer';
2 | export * from './user-profile.actions';
3 | export * from './user-profile.selectors';
4 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-user/app-navbar-user.component.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | .navbar-link {
3 | line-height: 4rem;
4 | margin: 0;
5 | padding: 0;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/core/api/index.ts:
--------------------------------------------------------------------------------
1 | import { AppPlayerApi } from './app-player.api';
2 | import { AppApi } from './app.api';
3 |
4 | export const APP_APIS = [
5 | AppPlayerApi,
6 | AppApi
7 | ];
8 |
9 |
--------------------------------------------------------------------------------
/src/app/shared/components/playlist-viewer/index.ts:
--------------------------------------------------------------------------------
1 | export { PlaylistViewerComponent } from './playlist-viewer.component';
2 | export { PlaylistCoverComponent } from './playlist-cover.component';
3 |
--------------------------------------------------------------------------------
/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true,
3 | youtube: {
4 | API_KEY: '{YT_API_KEY}',
5 | CLIENT_ID: '{YT_CLIENT_ID}'
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/config/heroku/bs-config.js:
--------------------------------------------------------------------------------
1 | var port = process.env.PORT || 8080;
2 | module.exports = {
3 | port: port,
4 | ghostMode: {
5 | clicks: false,
6 | forms: false,
7 | scroll: false
8 | }
9 | };
--------------------------------------------------------------------------------
/src/app/shared/utils/data.utils.ts:
--------------------------------------------------------------------------------
1 | import { SimpleChange } from '@angular/core';
2 |
3 | export const isNewChange = (prop: SimpleChange) => {
4 | return prop.currentValue !== prop.previousValue;
5 | };
6 |
--------------------------------------------------------------------------------
/src/app/shared/pipes/index.ts:
--------------------------------------------------------------------------------
1 | import { SearchPipe } from './search.pipe';
2 | import { ToFriendlyDurationPipe } from './toFriendlyDuration.pipe';
3 |
4 | export const PIPES = [
5 | SearchPipe,
6 | ToFriendlyDurationPipe
7 | ];
8 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/app-search.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | :host {
5 | $gap-top: 10.5rem;
6 |
7 | > article {
8 | padding-top: $gap-top;
9 | }
10 | }
11 | }
--------------------------------------------------------------------------------
/src/app/app.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/app/app.routes.ts:
--------------------------------------------------------------------------------
1 | import { Routes, RouterModule } from '@angular/router';
2 |
3 | export const ROUTES: Routes = [
4 | { path: '', redirectTo: 'search', pathMatch: 'full' },
5 | { path: 'user', loadChildren: 'app/containers/user/index#UserModule' }
6 | ];
7 |
--------------------------------------------------------------------------------
/src/app/core/resolvers/index.ts:
--------------------------------------------------------------------------------
1 | import { PlaylistVideosResolver } from './playlist-videos.resolver';
2 | import { PlaylistResolver } from './playlist.resolver';
3 |
4 | export const APP_RESOLVERS = [
5 | PlaylistVideosResolver,
6 | PlaylistResolver,
7 | ];
8 |
--------------------------------------------------------------------------------
/src/app/core/module-imports.guards.ts:
--------------------------------------------------------------------------------
1 | export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) {
2 | if (parentModule) {
3 | throw new Error(`${moduleName} has already been loaded. Import Core modules in the AppModule only.`);
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/assets/icon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #ffffff
--------------------------------------------------------------------------------
/config/heroku/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "echoes-player",
3 | "version": "3.0.0",
4 | "description": "Echoes Player (Angular)",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "lite-server --config=./bs-config.js"
8 | },
9 | "dependencies": {
10 | "lite-server": "2.2.2"
11 | }
12 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | max_line_length = off
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/src/assets/humans.txt:
--------------------------------------------------------------------------------
1 | # humanstxt.org/
2 | # The humans responsible & technology colophon
3 |
4 | # TEAM
5 |
6 | -- --
7 |
8 | # THANKS
9 |
10 |
11 | PatrickJS -- @gdi2290
12 | AngularClass -- @AngularClass
13 |
14 | # TECHNOLOGY COLOPHON
15 |
16 | HTML5, CSS3
17 | Angular2, TypeScript, Webpack
18 |
--------------------------------------------------------------------------------
/src/app/containers/user/user.scss:
--------------------------------------------------------------------------------
1 | app-user {
2 | article {
3 | padding-bottom: 5rem;
4 | padding-top:7rem;
5 | }
6 | h2 {
7 | small {
8 | color: gray;
9 | }
10 | }
11 | .youtube-items-container {
12 | display: flex;
13 | flex-direction: row;
14 | flex-wrap: wrap;
15 | justify-content: center;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/containers/index.ts:
--------------------------------------------------------------------------------
1 | import { AppSearchModule } from './app-search';
2 | import { UserModule } from './user';
3 | import { AppNavbarModule } from './app-navbar';
4 | import { PlaylistViewModule } from './playlist-view';
5 |
6 | export const APP_CONTAINER_MODULES = [
7 | AppSearchModule,
8 | // UserModule,
9 | AppNavbarModule,
10 | PlaylistViewModule
11 | ];
12 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { enableProdMode } from '@angular/core';
2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
3 |
4 | import { AppModule } from './app/app.module';
5 | import { environment } from './environments/environment';
6 |
7 | if (environment.production) {
8 | enableProdMode();
9 | }
10 |
11 | platformBrowserDynamic().bootstrapModule(AppModule);
12 |
--------------------------------------------------------------------------------
/src/app/core/components/index.ts:
--------------------------------------------------------------------------------
1 | import { AppPlayerModule } from './app-player';
2 | import { AppNavigatorModule } from './app-navigator';
3 | import { NowPlayingModule } from './now-playing';
4 | import { AppBrandModule } from './app-brand';
5 | import { AppSidebarModule } from './app-sidebar';
6 |
7 | export const APP_CORE_MODULES = [
8 | AppPlayerModule,
9 | AppSidebarModule
10 | ];
11 |
--------------------------------------------------------------------------------
/src/app/core/components/app-brand/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 |
4 | import { AppBrandComponent } from './app-brand.component';
5 |
6 | @NgModule({
7 | imports: [SharedModule],
8 | exports: [AppBrandComponent],
9 | declarations: [AppBrandComponent],
10 | providers: [],
11 | })
12 | export class AppBrandModule { }
13 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/image-blur/image-blur.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | .media-bg {
3 | position: absolute;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | top: 0;
8 | background-size: 40%;
9 | background-position: top;
10 | filter: blur(20px);
11 | overflow: hidden;
12 | box-shadow: none;
13 | height: 290px;
14 | transform: rotate(-20deg) translateY(-120px);
15 | }
16 | }
--------------------------------------------------------------------------------
/src/css/core/global.scss:
--------------------------------------------------------------------------------
1 | @import '../echoes-variables.scss';
2 |
3 | @mixin transform($prop) {
4 | -webkit-transform: $prop;
5 | -moz-transform: $prop;
6 | transform: $prop;
7 | }
8 |
9 | @mixin active-link-style($color: #555, $bg: #e5e5e5, $shadow: rgba(0,0,0,0.125)){
10 | color: $color;
11 | text-decoration: none;
12 | background-color: $bg;
13 | box-shadow: inset 0 3px 8px $shadow;
14 | }
15 |
--------------------------------------------------------------------------------
/e2e/tsconfig.e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "sourceMap": true,
4 | "declaration": false,
5 | "moduleResolution": "node",
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "lib": [
9 | "es2016"
10 | ],
11 | "outDir": "../dist/out-tsc-e2e",
12 | "module": "commonjs",
13 | "target": "es6",
14 | "types":[
15 | "jasmine",
16 | "node"
17 | ]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/search-navigator/search-navigator.component.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/e2e/app.po.ts:
--------------------------------------------------------------------------------
1 | import { browser, element, by } from 'protractor';
2 |
3 | export class EchoesPlayerPage {
4 | navigateTo() {
5 | return browser.get('/');
6 | }
7 |
8 | getTitle() {
9 | return browser.getTitle();
10 | }
11 |
12 | getTitleInput() {
13 | return element(by.css('input[formcontrolname=title]'));
14 | }
15 |
16 | getVideoResults() {
17 | return element.all(by.css('youtube-videos youtube-list .youtube-list-item'));
18 | }
19 |
20 | // getTalkText(index: number) {
21 | // return this.getTalks().get(index).getText();
22 | // }
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/shared/pipes/search.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from '@angular/core';
2 |
3 | @Pipe({
4 | name: 'search',
5 | })
6 | export class SearchPipe implements PipeTransform {
7 |
8 | transform(values: any, args: any) {
9 | const term = args.length ? args.toLowerCase() : '';
10 | const matchString = (key) => {
11 | if (typeof key === 'string') {
12 | return key.toLowerCase().indexOf(term) > -1;
13 | }
14 | return Object.keys(key).some(prop => matchString(key[prop]));
15 | };
16 | return values.filter(matchString);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/environments/environment.ts:
--------------------------------------------------------------------------------
1 | // The file contents for the current environment will overwrite these during build.
2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do
3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead.
4 | // The list of which env maps to which file can be found in `.angular-cli.json`.
5 | export const environment = {
6 | production: false,
7 | youtube: {
8 | API_KEY: 'AIzaSyDCGg6FG6s_zxACqel09vQUKBc-x26pKFA',
9 | CLIENT_ID: '971861197531-hm7solf3slsdjc4omsfti4jbcbe625hs'
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-group/button-group.component.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | display: inline-block;
3 | .btn {
4 | border: 0;
5 | &.btn-default.active {
6 | background-color: var(--brand-primary);
7 | color: var(--brand-inverse-text);
8 | }
9 | }
10 |
11 | .btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {
12 | border-top-left-radius: 3px;
13 | border-bottom-left-radius: 3px;
14 | }
15 |
16 | .btn-group > .btn:last-child:not(:first-child) {
17 | border-top-right-radius: 3px;
18 | border-bottom-right-radius: 3px;
19 | }
20 | }
--------------------------------------------------------------------------------
/src/app/core/components/app-navigator/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { RouterModule } from '@angular/router';
3 |
4 | import { SharedModule } from '@shared/index';
5 |
6 | import { AppNavigatorComponent } from './app-navigator.component';
7 |
8 | @NgModule({
9 | imports: [
10 | SharedModule,
11 | RouterModule
12 | ],
13 | declarations: [
14 | AppNavigatorComponent
15 | ],
16 | exports: [
17 | AppNavigatorComponent
18 | ],
19 | providers: []
20 | })
21 | export class AppNavigatorModule { }
22 |
23 | // export * from './navigator.component';
24 |
--------------------------------------------------------------------------------
/src/assets/icon/icon.html:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-icon/button-icon.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, Input, ViewEncapsulation } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'button-icon',
5 | // styleUrls: ['./button-icon.scss'],
6 | template: `
7 |
10 | `,
11 | // encapsulation: ViewEncapsulation.None
12 | })
13 |
14 | export class ButtonIconComponent implements OnInit {
15 | @Input() icon: string;
16 | @Input() types: string;
17 |
18 | constructor() { }
19 |
20 | ngOnInit() { }
21 | }
22 |
--------------------------------------------------------------------------------
/config/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | if [ "$TRAVIS_BRANCH" == "master" ]; then
3 | git config --global user.email "farhioren+travis@gmail.com"
4 | git config --global user.name "travis-ci"
5 | npm run build:prod
6 | npm run copy:domain
7 | npm run copy:heroku
8 | if [ -d "./dist" ]; then
9 | echo "PRODUCTION BUILD CREATED";
10 | cd dist
11 | git init
12 | git add .
13 | git commit -m "deployed commit ${TRAVIS_COMMIT} from travis"
14 | git push --force --quiet "https://${GH_TOKEN}@${GH_REF}" master:gh-pages > /dev/null 2>&1
15 | else
16 | echo "!!! PRODUCTION BUILD FAILED !!!";
17 | fi
18 | fi
--------------------------------------------------------------------------------
/src/app/core/resolvers/playlist-videos.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs/Observable';
2 | import { UserProfile } from '@core/services';
3 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
4 | import { Injectable } from '@angular/core';
5 |
6 | @Injectable()
7 | export class PlaylistVideosResolver implements Resolve {
8 | constructor(
9 | private userProfile: UserProfile,
10 | ) { }
11 |
12 | resolve(route: ActivatedRouteSnapshot): Observable {
13 | const playlistId = route.params['id'];
14 | return this.userProfile.fetchAllPlaylistItems(playlistId);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/youtube-videos.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | :host {
4 | display: block;
5 |
6 | .nav-toolbar {
7 | margin: 0.9rem .5rem;
8 | display: inline-block;
9 | }
10 | }
11 |
12 | @media (min-width: 320px) {
13 | :host {
14 | position: relative;
15 |
16 | .nav-toolbar {
17 | .btn {
18 | padding: 1rem 2.7rem;
19 | }
20 | }
21 | }
22 | }
23 |
24 | @media (min-width: 768px) {
25 | :host {
26 | padding-left: 0;
27 |
28 | .nav-toolbar {
29 | .btn {
30 | padding: 0.7rem 1.5rem;
31 | }
32 | }
33 | .videos-list {
34 | padding-left: 1.6rem;
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/shared/components/loading-indicator/loading-indicator.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | :host {
4 | display: block;
5 | margin: 2rem;
6 | position: fixed;
7 | z-index: $zindex-navbar;
8 | min-width: 40%;
9 | transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55);
10 | transform: translatey(-20rem);
11 |
12 | &.show-loader {
13 | transform: translatey(0rem);
14 | }
15 |
16 | .alert {
17 | background-color: var(--sidebar-bg);
18 | box-shadow: 0 0 20px -3px #000;
19 | padding: 2rem;
20 |
21 | &.alert-info {
22 | color: var(--brand-primary);
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/core/store/user-profile/user-profile.selectors.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { IUserProfile } from './user-profile.reducer';
3 | import { EchoesState } from '@store/reducers';
4 | import { createSelector } from '@ngrx/store/src/selector';
5 |
6 | export const getUser = (state: EchoesState) => state.user;
7 | export const getUserPlaylists = createSelector(getUser, (user: IUserProfile) => user.playlists);
8 | export const getUserViewPlaylist = createSelector(getUser, (user: IUserProfile) => user.viewedPlaylist);
9 | export const getIsUserSignedIn = createSelector(getUser, (user: IUserProfile) => user.access_token !== '');
10 |
--------------------------------------------------------------------------------
/src/app/containers/playlist-view/playlist-view.routing.ts:
--------------------------------------------------------------------------------
1 | import { PlaylistVideosResolver } from '@core/resolvers/playlist-videos.resolver';
2 | import { PlaylistResolver } from '@core/resolvers/playlist.resolver';
3 | import { ModuleWithProviders } from '@angular/core';
4 | import { Routes, RouterModule } from '@angular/router';
5 |
6 | import { PlaylistViewComponent } from './playlist-view.component';
7 |
8 | export const routing: ModuleWithProviders = RouterModule.forChild([
9 | {
10 | path: 'playlist/:id', component: PlaylistViewComponent,
11 | resolve: {
12 | videos: PlaylistVideosResolver,
13 | playlist: PlaylistResolver
14 | }
15 | }
16 | ]);
17 |
--------------------------------------------------------------------------------
/src/app/core/resolvers/playlist.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs/Observable';
2 | import { UserProfile } from '@core/services';
3 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
4 | import { Injectable } from '@angular/core';
5 |
6 | @Injectable()
7 | export class PlaylistResolver implements Resolve {
8 | constructor(
9 | private userProfile: UserProfile,
10 | ) { }
11 |
12 | resolve(route: ActivatedRouteSnapshot): Observable {
13 | const playlistId = route.params['id'];
14 | return this.userProfile.fetchPlaylist(playlistId)
15 | .map(response => response.items[0]);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/e2e/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { EchoesPlayerPage } from './app.po';
2 |
3 | describe('echoes-player App', () => {
4 | let page: EchoesPlayerPage;
5 |
6 | beforeEach(() => {
7 | page = new EchoesPlayerPage();
8 | page.navigateTo();
9 | });
10 |
11 |
12 | it('should have a title', () => {
13 | const actual = page.getTitle();
14 | const expected = 'Echoes Player - Open Source Media Player for Youtube';
15 | expect(actual).toEqual(expected);
16 | });
17 |
18 | it('should show 50 video search results', () => {
19 | const actual = page.getVideoResults().count();
20 | const expected = 50;
21 | expect(actual).toEqual(expected);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/app/shared/components/loading-indicator/loading-indicator.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, HostBinding, Input, OnInit } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'loader',
5 | styleUrls: ['./loading-indicator.scss'],
6 | template: `
7 |
8 | {{ message }}
9 |
10 | `,
11 | changeDetection: ChangeDetectionStrategy.OnPush
12 | })
13 | export class LoadingIndicatorComponent {
14 | @Input() message = '';
15 | @Input() loading = false;
16 |
17 | @HostBinding('class.show-loader')
18 | get show() {
19 | return this.loading;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 | import { RouterModule } from '@angular/router';
4 |
5 | import { AppNavbarComponent } from './app-navbar.component';
6 | import { AppNavbarMenuComponent } from './app-navbar-menu';
7 | import { AppNavbarUserComponent } from './app-navbar-user';
8 |
9 | @NgModule({
10 | imports: [
11 | SharedModule,
12 | RouterModule
13 | ],
14 | declarations: [
15 | AppNavbarComponent,
16 | AppNavbarMenuComponent,
17 | AppNavbarUserComponent
18 | ],
19 | exports: [
20 | AppNavbarComponent
21 | ]
22 | })
23 | export class AppNavbarModule { }
24 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 |
4 | import { NowPlayingComponent } from './now-playing.component';
5 | import { NowPlaylistComponent, NowPlaylistTrackComponent } from './now-playlist';
6 | import { NowPlaylistFilterComponent } from './now-playlist-filter';
7 |
8 | @NgModule({
9 | imports: [
10 | SharedModule
11 | ],
12 | declarations: [
13 | NowPlayingComponent,
14 | NowPlaylistComponent,
15 | NowPlaylistTrackComponent,
16 | NowPlaylistFilterComponent
17 | ],
18 | exports: [
19 | NowPlayingComponent
20 | ]
21 | })
22 | export class NowPlayingModule { }
23 |
--------------------------------------------------------------------------------
/src/app/containers/playlist-view/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 |
4 | import { AppNavbarModule } from '../app-navbar';
5 | import { PlaylistViewComponent } from './playlist-view.component';
6 | import { PlaylistProxy } from './playlist-view.proxy';
7 |
8 | import { routing } from './playlist-view.routing';
9 |
10 | @NgModule({
11 | imports: [
12 | SharedModule,
13 | AppNavbarModule,
14 | routing
15 | ],
16 | declarations: [
17 | PlaylistViewComponent
18 | ],
19 | exports: [
20 | PlaylistViewComponent
21 | ],
22 | providers: [
23 | PlaylistProxy
24 | ]
25 | })
26 | export class PlaylistViewModule { }
27 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playing.scss:
--------------------------------------------------------------------------------
1 | // FLEX LAYOUT START
2 | :host {
3 | $padding-bottom: 31rem;
4 |
5 | flex-grow: 16;
6 | flex-shrink: 0;
7 | flex-basis: 0rem;
8 | display: flex;
9 | flex-direction: column;
10 |
11 | .sidebar-pane {
12 | flex: 1 1 0;
13 | display: flex;
14 | flex-direction: column;
15 | // hidden for hiding top shadow while exposing bottom shadow
16 | overflow-y: hidden;
17 | }
18 |
19 | now-playlist-filter {
20 | flex-grow: 0;
21 | flex-shrink: 1;
22 | flex-basis: 0rem;
23 | }
24 |
25 | now-playlist {
26 | flex: 1 1 0;
27 | overflow-y: auto;
28 | padding-bottom: $padding-bottom;
29 | }
30 | }
31 | // FLEX LAYOUT END
--------------------------------------------------------------------------------
/src/app/shared/utils/media.utils.ts:
--------------------------------------------------------------------------------
1 | export function getSnippet(media) {
2 | return media && media.hasOwnProperty('snippet') && media.snippet;
3 | }
4 |
5 | export function extractThumbnail(snippet) {
6 | let thumbUrl = '';
7 | if (snippet) {
8 | const thumbs = snippet.thumbnails;
9 | const sizes = ['high', 'standard', 'default'];
10 | const thumb = sizes.reduce((acc, size) => {
11 | acc.result = !acc.result.length && thumbs[size] ? thumbs[size].url : acc.result;
12 | return acc;
13 | }, { result: '' });
14 | thumbUrl = thumb.result;
15 | }
16 | return thumbUrl;
17 | }
18 |
19 | export function extractThumbUrl(media) {
20 | return extractThumbnail(getSnippet(media));
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/core/store/router-store/router-store.reducer.ts:
--------------------------------------------------------------------------------
1 | import { StoreModule, ActionReducerMap } from '@ngrx/store';
2 | import { Params, RouterStateSnapshot } from '@angular/router';
3 | import {
4 | RouterReducerState,
5 | RouterStateSerializer
6 | } from '@ngrx/router-store';
7 |
8 | export interface RouterStateUrl {
9 | url: string;
10 | queryParams: Params;
11 | }
12 |
13 | export class NavigationSerializer implements RouterStateSerializer {
14 | serialize(routerState: RouterStateSnapshot): RouterStateUrl {
15 | // console.log('ROUTE', routerState);
16 | const { url } = routerState;
17 | const queryParams = routerState.root.queryParams;
18 | return { url, queryParams };
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/core/store/router-store/router-store.actions.ts:
--------------------------------------------------------------------------------
1 | import { Action } from '@ngrx/store';
2 | import { NavigationExtras } from '@angular/router';
3 |
4 | export const GO = '[Router] Go';
5 | export const BACK = '[Router] Back';
6 | export const FORWARD = '[Router] Forward';
7 |
8 | export class Go implements Action {
9 | readonly type = GO;
10 |
11 | constructor(
12 | public payload: {
13 | path: any[];
14 | query?: object;
15 | extras?: NavigationExtras;
16 | }
17 | ) {}
18 | }
19 |
20 | export class Back implements Action {
21 | readonly type = BACK;
22 | }
23 |
24 | export class Forward implements Action {
25 | readonly type = FORWARD;
26 | }
27 |
28 | export type Actions = Go | Back | Forward;
29 |
--------------------------------------------------------------------------------
/src/app/core/components/app-sidebar/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 |
4 | import { AppSidebarComponent } from './app-sidebar.component';
5 | import { AppBrandModule } from '../app-brand';
6 | import { AppNavigatorModule } from '../app-navigator';
7 | import { NowPlayingModule } from '../now-playing';
8 |
9 | import { AppSidebarProxy } from './app-sidebar.proxy';
10 |
11 | @NgModule({
12 | imports: [
13 | SharedModule,
14 | AppBrandModule,
15 | AppNavigatorModule,
16 | NowPlayingModule
17 | ],
18 | exports: [AppSidebarComponent],
19 | declarations: [AppSidebarComponent],
20 | providers: [AppSidebarProxy],
21 | })
22 | export class AppSidebarModule { }
23 |
--------------------------------------------------------------------------------
/src/app/app.component.scss:
--------------------------------------------------------------------------------
1 | @import '~css/echoes-variables';
2 |
3 | :host {
4 | // DEFAULT THEME - 'arctic'
5 |
6 | background-color: var(--app-bg-color);
7 | display: block;
8 | height: 100%;
9 |
10 | .container-main {
11 | height: 100vh;
12 | display: block;
13 | transition: margin .3s ease-out;
14 | margin-left: 0;
15 | }
16 | }
17 | @media (min-width: 320px) {
18 | :host .container-fluid.container-main {
19 | padding-right: 0;
20 | padding-left: 0;
21 | }
22 | }
23 | @media (min-width: 768px) {
24 | :host .closed + .container-main {
25 | margin-left: $sidebar-closed-width;
26 | }
27 | }
28 | @media (min-width: 1024px) {
29 | :host .container-main {
30 | margin-left: $drawer-width;
31 | }
32 | }
--------------------------------------------------------------------------------
/src/app/core/store/app-player/app-player.selectors.ts:
--------------------------------------------------------------------------------
1 | import { Store, createSelector } from '@ngrx/store';
2 | import { Observable } from 'rxjs/Observable';
3 | import { IAppPlayer } from './app-player.reducer';
4 | import { EchoesState } from '@store/reducers';
5 |
6 | export const getPlayer = (state: EchoesState) => state.player;
7 | export const getCurrentMedia = createSelector(getPlayer, (player: IAppPlayer) => player.media);
8 | export const getIsPlayerPlaying = createSelector(getPlayer, (player: IAppPlayer) => player.playerState === 1);
9 | export const getShowPlayer = createSelector(getPlayer, (player: IAppPlayer) => player.showPlayer);
10 | export const getPlayerFullscreen = createSelector(getPlayer, (player: IAppPlayer) => player.fullscreen);
11 |
--------------------------------------------------------------------------------
/src/app/core/effects/index.ts:
--------------------------------------------------------------------------------
1 | import { EffectsModule } from '@ngrx/effects';
2 |
3 | import { AppPlayerEffects } from './app-player.effects';
4 | import { AnalyticsEffects } from './analytics.effects';
5 | import { NowPlaylistEffects } from './now-playlist.effects';
6 | import { UserProfileEffects } from './user-profile.effects';
7 | import { PlayerSearchEffects } from './player-search.effects';
8 | import { AppSettingsEffects } from './app-settings.effects';
9 | import { RouterEffects } from './router.effects';
10 |
11 | export const AppEffectsModules = EffectsModule.forRoot([
12 | AppPlayerEffects,
13 | NowPlaylistEffects,
14 | UserProfileEffects,
15 | PlayerSearchEffects,
16 | AppSettingsEffects,
17 | RouterEffects,
18 | AnalyticsEffects
19 | ]);
20 |
--------------------------------------------------------------------------------
/src/app/core/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule, Optional, SkipSelf } from '@angular/core';
2 | import { throwIfAlreadyLoaded } from './module-imports.guards';
3 |
4 | import { CoreStoreModule } from './store';
5 | import { AppEffectsModules } from './effects';
6 |
7 | import { APP_SERVICES } from './services';
8 | import { APP_RESOLVERS } from './resolvers';
9 | import { APP_APIS } from './api';
10 |
11 | @NgModule({
12 | imports: [CoreStoreModule, AppEffectsModules],
13 | declarations: [],
14 | exports: [CoreStoreModule],
15 | providers: [...APP_SERVICES, ...APP_RESOLVERS, ...APP_APIS]
16 | })
17 | export class CoreModule {
18 | // constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
19 | // throwIfAlreadyLoaded(parentModule, 'CoreModule');
20 | // }
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/core/components/app-sidebar/app-sidebar.proxy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { AppApi } from '@api/app.api';
4 | import { EchoesState } from '@store/reducers';
5 |
6 | import { getSidebarCollapsed } from '@store/app-layout';
7 | import * as PlayerSearch from '@store/player-search';
8 | import * as AppLayout from '@store/app-layout';
9 |
10 | @Injectable()
11 | export class AppSidebarProxy {
12 |
13 | sidebarCollapsed$ = this.store.select(getSidebarCollapsed);
14 | searchType$ = this.store.select(PlayerSearch.getSearchType);
15 |
16 | constructor(
17 | private store: Store,
18 | private appApi: AppApi) { }
19 |
20 | toggleSidebar() {
21 | this.appApi.toggleSidebar();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-group/button-group.component.spec.ts:
--------------------------------------------------------------------------------
1 | // import { TestBed, inject } from '@angular/core/testing';
2 |
3 | // import { ButtonGroupComponent } from './button-group.component';
4 |
5 | // describe('a button-group component', () => {
6 | // let component: ButtonGroupComponent;
7 |
8 | // // register all needed dependencies
9 | // beforeEach(() => {
10 | // TestBed.configureTestingModule({
11 | // providers: [
12 | // ButtonGroupComponent
13 | // ]
14 | // });
15 | // });
16 |
17 | // // instantiation through framework injection
18 | // beforeEach(inject([ButtonGroupComponent], (ButtonGroupComponent) => {
19 | // component = ButtonGroupComponent;
20 | // }));
21 |
22 | // it('should have an instance', () => {
23 | // expect(component).toBeDefined();
24 | // });
25 | // });
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-list/youtube-list.scss:
--------------------------------------------------------------------------------
1 | @media (min-width: 320px) {
2 | :host {
3 | ul {
4 | display: flex;
5 | flex-direction: row;
6 | flex-wrap: wrap;
7 | justify-content: center;
8 | }
9 | .youtube-list-item {
10 | width: 100%;
11 | max-width: 100%;
12 |
13 | &:hover {
14 | box-shadow: none;
15 | }
16 | }
17 | }
18 | }
19 | @media (min-width: 480px) {
20 | :host {
21 | .youtube-list-item {
22 | width: 33%;
23 | }
24 | }
25 | }
26 | @media (min-width: 767px) {
27 | :host {
28 | .youtube-list-item {
29 | max-width: 28rem;
30 | width: 24.5%;
31 | }
32 | }
33 | }
34 |
35 | @media (min-width: 1440px) {
36 | :host {
37 | .youtube-list-item {
38 | width: 28rem;
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/shared/components/index.ts:
--------------------------------------------------------------------------------
1 | import { YoutubeListComponent } from './youtube-list';
2 | import { YoutubeMediaComponent } from './youtube-media';
3 | import { YoutubePlaylistComponent } from './youtube-playlist';
4 | import { ButtonGroupComponent } from './button-group';
5 | import { PlaylistViewerComponent, PlaylistCoverComponent } from './playlist-viewer';
6 | import { LoadingIndicatorComponent } from './loading-indicator';
7 | import { ButtonDirective } from './btn';
8 | import { ButtonIconComponent } from './button-icon';
9 |
10 | export const CORE_COMPONENTS = [
11 | YoutubeListComponent,
12 | YoutubeMediaComponent,
13 | YoutubePlaylistComponent,
14 | ButtonGroupComponent,
15 | PlaylistViewerComponent, PlaylistCoverComponent,
16 | LoadingIndicatorComponent,
17 | ButtonDirective,
18 | ButtonIconComponent
19 | ];
20 |
--------------------------------------------------------------------------------
/config/build-env.js:
--------------------------------------------------------------------------------
1 | const replace = require("replace");
2 | const analyticsId = process.env.GA_PROJECTID;
3 | const youtubeApiKey = process.env.YT_API_KEY;
4 | const youtubeClientId = process.env.YT_CLIENT_ID;
5 | const googleVerification = process.env.GA_VERIFY_CODE;
6 |
7 | [{
8 | regex: '{GA_PROJECTID}',
9 | replacement: analyticsId,
10 | paths: ['./src'],
11 | recursive: true
12 | },
13 | {
14 | regex: '{YT_API_KEY}',
15 | replacement: youtubeApiKey,
16 | paths: ['./src/environments'],
17 | recursive: true
18 | },
19 | {
20 | regex: '{YT_CLIENT_ID}',
21 | replacement: youtubeClientId,
22 | paths: ['./src/environments'],
23 | recursive: true
24 | },
25 | {
26 | regex: '{GA_VERIFY_CODE}',
27 | replacement: googleVerification,
28 | paths: ['./src'],
29 | recursive: true
30 | }].forEach(options => replace(options));
--------------------------------------------------------------------------------
/src/app/containers/app-search/search-navigator/search-navigator.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed, inject } from '@angular/core/testing';
2 |
3 | import { SearchNavigatorComponent } from './search-navigator.component';
4 |
5 | describe('a search-navigator component', () => {
6 | let component: SearchNavigatorComponent;
7 |
8 | // register all needed dependencies
9 | beforeEach(() => {
10 | TestBed.configureTestingModule({
11 | providers: [
12 | SearchNavigatorComponent
13 | ]
14 | });
15 | });
16 |
17 | // instantiation through framework injection
18 | beforeEach(inject([SearchNavigatorComponent], (SearchNavigatorComponent) => {
19 | component = SearchNavigatorComponent;
20 | }));
21 |
22 | it('should have an instance', () => {
23 | expect(component).toBeDefined();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 |
4 | import { AppPlayerComponent } from './app-player.component';
5 | import { MediaInfoComponent } from './media-info';
6 | import { PlayerControlsComponent } from './player-controls/player-controls.component';
7 | import { PlayerResizerComponent } from './player-resizer/player-resizer.component';
8 | import { ImageBlurComponent } from './image-blur';
9 |
10 | @NgModule({
11 | imports: [
12 | SharedModule
13 | ],
14 | declarations: [
15 | AppPlayerComponent,
16 | MediaInfoComponent,
17 | PlayerControlsComponent,
18 | PlayerResizerComponent,
19 | ImageBlurComponent
20 | ],
21 | exports: [
22 | AppPlayerComponent
23 | ]
24 | })
25 | export class AppPlayerModule { }
26 |
--------------------------------------------------------------------------------
/src/app/core/components/app-navigator/app-navigator.scss:
--------------------------------------------------------------------------------
1 | @import '~css/echoes-variables.scss';
2 |
3 | :host {
4 | .list-group {
5 | margin: 0;
6 | }
7 | .list-group-item {
8 | $active-color: rgba(10,10,10, .5);
9 |
10 | color: $clouds;
11 | border: none;
12 | background: transparent;
13 |
14 | &:hover {
15 | background: rgba(10,10,10,.2);
16 | box-shadow: inset 0px 0px 6px $active-color;
17 | }
18 |
19 | .closed & {
20 | text-align: center;
21 | }
22 |
23 | .fa {
24 | color: var(--brand-primary);
25 | margin-right: 10px;
26 |
27 | .closed & {
28 | margin: 0;
29 | }
30 | }
31 | }
32 | }
33 |
34 | @media (min-width: 768px) {
35 | :host .closed {
36 | width: $sidebar-closed-width;
37 | a {
38 | text-indent: 0.6rem;
39 | }
40 | .text {
41 | display: none;
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | typings
3 | dist
4 |
5 | # Directory for instrumented libs generated by jscoverage/JSCover
6 | lib-cov
7 |
8 | # Coverage directory used by tools like istanbul
9 | coverage
10 |
11 | # Compiled binary addons (http://nodejs.org/api/addons.html)
12 | build/Release
13 |
14 | # Users Environment Variables
15 | .lock-wscript
16 |
17 | # OS generated files #
18 | .DS_Store
19 | ehthumbs.db
20 | # Icon?
21 | Thumbs.db
22 |
23 | # Node Files #
24 | /node_modules
25 | npm-debug.log
26 |
27 | # Coverage #
28 | /coverage/
29 |
30 | # Typing #
31 | /src/typings/tsd/
32 | /typings/
33 | /tsd_typings/
34 |
35 | # Dist #
36 | /dist
37 | /public/__build__/
38 | /src/*/__build__/
39 | /__build__/**
40 | /public/dist/
41 | /src/*/dist/
42 | /dist/**
43 | .webpack.json
44 |
45 | # Doc #
46 | /doc/
47 |
48 | # IDE #
49 | .idea/
50 | *.swp
51 | *.tag*
--------------------------------------------------------------------------------
/src/app/containers/app-search/search-navigator/search-navigator.component.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | :host {
5 | --border-active-color: var(--brand-primary);
6 | --link-active-color: var(--navbar-link-color);
7 |
8 | .search-selector {
9 | clear: both;
10 |
11 | &.nav > li > a {
12 | text-transform: uppercase;
13 | border: none;
14 | border-radius: none;
15 | background-color: transparent;
16 | color: var(--navbar-text-color);
17 | }
18 |
19 | &.nav-tabs {
20 | border-bottom: none;
21 |
22 | > li.active > a {
23 | border-bottom: 0.2rem solid var(--border-active-color);
24 | color: var(--link-active-color);
25 | box-shadow: none;
26 | }
27 | }
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/core/store/player-search/player-search.interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface IQueryParam {
2 | preset: string;
3 | duration: number;
4 | }
5 | export interface IPlayerSearch {
6 | query: string;
7 | filter: string;
8 | searchType: string;
9 | queryParams: IQueryParam;
10 | presets: IPresetParam[];
11 | pageToken: {
12 | next: string;
13 | prev: string;
14 | };
15 | isSearching: boolean;
16 | results: any[];
17 | }
18 |
19 | export interface ISearchQueryParam {
20 | [property: string]: any;
21 | }
22 |
23 | export interface IPresetParam {
24 | label: string;
25 | value: CPresetTypes | string;
26 | }
27 |
28 | export class CSearchTypes {
29 | static VIDEO = 'video';
30 | static PLAYLIST = 'playlist';
31 | }
32 |
33 | export class CPresetTypes {
34 | static FULL_ALBUMS = 'full albums';
35 | static LIVE = 'live';
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/app-search.routing.ts:
--------------------------------------------------------------------------------
1 | // import { PlaylistResolver, PlaylistVideosResolver } from '@shared/components/playlist-view';
2 | import { ModuleWithProviders } from '@angular/core';
3 | import { Routes, RouterModule } from '@angular/router';
4 |
5 | import { YoutubeVideosComponent } from './youtube-videos.component';
6 | import { AppSearchComponent } from './app-search.component';
7 | import { YoutubePlaylistsComponent } from './youtube-playlists.component';
8 |
9 | export const routing: ModuleWithProviders = RouterModule.forChild([
10 | {
11 | path: 'search', component: AppSearchComponent,
12 | children: [
13 | { path: '', redirectTo: 'videos', pathMatch: 'full' },
14 | { path: 'videos', component: YoutubeVideosComponent },
15 | { path: 'playlists', component: YoutubePlaylistsComponent }
16 | ]
17 | }
18 | ]);
19 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/player-resizer/player-resizer.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'player-resizer',
5 | styleUrls: ['./player-resizer.scss'],
6 | template: `
7 |
14 | `
15 | })
16 | export class PlayerResizerComponent implements OnInit {
17 | @Input() fullScreen: boolean;
18 | @Output() toggle = new EventEmitter();
19 | constructor() { }
20 |
21 | ngOnInit() { }
22 |
23 | togglePlayer() {
24 | this.toggle.next();
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/core/components/app-brand/app-brand.component.ts:
--------------------------------------------------------------------------------
1 | import { AppApi } from '@api/app.api';
2 | import { Component, OnInit } from '@angular/core';
3 |
4 | @Component({
5 | selector: 'app-brand',
6 | styleUrls: ['./app-brand.scss'],
7 | template: `
8 |
10 |
11 | Ech
12 |
13 | es
14 |
15 |
18 |
19 | `
20 | })
21 | export class AppBrandComponent implements OnInit {
22 | constructor(private appApi: AppApi) { }
23 | ngOnInit() { }
24 |
25 | toggleSidebar() {
26 | return this.appApi.toggleSidebar();
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/player-resizer/player-resizer.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | :host {
5 | .btn.show-player {
6 | background-color: var(--brand-primary);
7 | color: var(--brand-inverse-text);
8 | }
9 | .full-screen.show-player {
10 | transform: scale(5);
11 | background-color: transparent;
12 | color: var(--brand-primary);
13 | border: none;
14 | left: 14rem;
15 | position: absolute;
16 | font-size: 4rem;
17 | top: 12rem;
18 | padding: 1rem 2.5rem;
19 | }
20 | .icon-max {
21 | display: block;
22 | }
23 |
24 | .icon-minimize {
25 | display: none;
26 | }
27 |
28 | .show-youtube-player & {
29 | display: block;
30 |
31 | .icon-max {
32 | display: none;
33 | }
34 |
35 | .icon-minimize {
36 | display: block;
37 | }
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { VersionCheckerService } from './core/services/version-checker.service';
3 | import { Component, HostBinding, OnInit } from '@angular/core';
4 | import { EchoesState } from '@store/reducers';
5 | import { getSidebarCollapsed, getAppTheme } from '@store/app-layout';
6 |
7 |
8 | @Component({
9 | selector: 'body',
10 | templateUrl: './app.component.html',
11 | styleUrls: ['./app.component.scss']
12 | })
13 | export class AppComponent implements OnInit {
14 | sidebarCollapsed$ = this.store.select(getSidebarCollapsed);
15 | theme$ = this.store.select(getAppTheme);
16 |
17 | @HostBinding('class')
18 | style = 'arctic';
19 |
20 | constructor(private store: Store, private versionCheckerService: VersionCheckerService) {
21 | versionCheckerService.start();
22 | }
23 |
24 | ngOnInit() {
25 | this.theme$.subscribe(theme => this.style = theme);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/shared/pipes/toFriendlyDuration.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from '@angular/core';
2 |
3 | @Pipe({
4 | name: 'toFriendlyDuration'
5 | })
6 |
7 | export class ToFriendlyDurationPipe implements PipeTransform {
8 | transform(value: any, args?: any[]): any {
9 | const time = value;
10 | if (!time) {
11 | return '...';
12 | }
13 | return ['PT', 'H', 'M', 'S'].reduce((prev, cur, i, arr) => {
14 | const now = prev.rest.split(cur);
15 | if (cur !== 'PT' && cur !== 'H' && !prev.rest.match(cur)) {
16 | prev.new.push('00');
17 | }
18 | if (now.length === 1) {
19 | return prev;
20 | }
21 | prev.new.push(now[0]);
22 | return {
23 | rest: now[1].replace(cur, ''),
24 | new: prev.new
25 | };
26 | }, { rest: time, new: [] })
27 | .new.filter(_time => _time !== '')
28 | .map(_time => _time.length === 1 ? `0${_time}` : _time)
29 | .join(':');
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/assets/icon/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App",
3 | "icons": [
4 | {
5 | "src": "\/android-icon-36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/android-icon-48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/android-icon-72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/android-icon-96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/android-icon-144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/android-icon-192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/src/app/containers/app-search/search-navigator/search-navigator.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core';
2 | import { CSearchTypes } from '@core/store/player-search';
3 |
4 | @Component({
5 | selector: 'search-navigator',
6 | styleUrls: ['./search-navigator.component.scss'],
7 | template: `
8 |
15 | `,
16 | changeDetection: ChangeDetectionStrategy.OnPush
17 | })
18 | export class SearchNavigatorComponent implements OnInit {
19 | searchTypes = [
20 | { label: 'Videos', link: '/search/videos', type: CSearchTypes.VIDEO },
21 | { label: 'Playlists', link: '/search/playlists', type: CSearchTypes.PLAYLIST },
22 | ];
23 |
24 | ngOnInit() { }
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/core/store/app-layout/app-layout.selectors.ts:
--------------------------------------------------------------------------------
1 | import { Store, createSelector } from '@ngrx/store';
2 | import { Observable } from 'rxjs/Observable';
3 | import { IAppSettings } from './app-layout.reducer';
4 | import { EchoesState } from '@store/reducers';
5 |
6 | export const getAppSettings = (state: EchoesState) => state.appLayout;
7 | export const getAppTheme = createSelector(getAppSettings, (state: IAppSettings) => state.theme);
8 | export const getAllAppThemes = createSelector(getAppSettings, (state: IAppSettings) => state.themes);
9 | export const getAppThemes = createSelector(getAppSettings, getAppTheme, getAllAppThemes, (appLayout, theme: string, themes: string[]) => ({
10 | selected: theme,
11 | themes: themes.map(_theme => ({ label: _theme, value: _theme }))
12 | }));
13 | export const getAppVersion = createSelector(getAppSettings, (state: IAppSettings) => state.version);
14 | export const getSidebarCollapsed = createSelector(getAppSettings, (state: IAppSettings) => !state.sidebarExpanded);
15 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/image-blur/image-blur.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'image-blur',
5 | styleUrls: ['./image-blur.scss'],
6 | template: `
7 |
8 | `,
9 | changeDetection: ChangeDetectionStrategy.OnPush
10 | })
11 | export class ImageBlurComponent {
12 | @Input() media: GoogleApiYouTubeVideoResource;
13 | get style() {
14 | const hasMedia = this.media && this.media.snippet;
15 | return {
16 | backgroundImage: hasMedia
17 | ? `url(${this.extractBestImage(hasMedia.thumbnails as any)})`
18 | : ''
19 | };
20 | }
21 |
22 | extractBestImage(thumbnails: GoogleApiYouTubeThumbnailResource) {
23 | const quality =
24 | thumbnails && thumbnails.hasOwnProperty('high') ? 'high' : 'default';
25 | const hasContent = thumbnails && quality && thumbnails[quality];
26 | return hasContent ? thumbnails[quality].url : '';
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/media-info/media-info.scss:
--------------------------------------------------------------------------------
1 | @import '~css/echoes-variables.scss';
2 |
3 | :host {
4 | --media-title-text-color: var(--brand-primary);
5 | --media-title-shadow: var(--brand-dark-bg-color);
6 | --media-expand-icon-bg-color: var(--brand-dark-bg-color-transparent);
7 | $line-height: 5.5rem;
8 |
9 | line-height: 1.5rem;
10 |
11 | .yt-media-title {
12 | color: var(--media-title-text-color);
13 | margin: 0 1rem 0 0;
14 | display: inline-block;
15 | max-width: 40rem;
16 | font-size: 1.5rem;
17 | line-height: $line-height;
18 |
19 | .media-thumb-container {
20 | position: relative;
21 | margin-right: 1rem;
22 | cursor: pointer;
23 |
24 | .media-thumb {
25 | height: $line-height;
26 | }
27 | .fa {
28 | position: absolute;
29 | right: 0;
30 | bottom: 0;
31 | background: var(--media-expand-icon-bg-color);
32 | }
33 | }
34 |
35 | .title {
36 | text-shadow: 0px 0px .5rem var(--media-title-shadow);
37 | font-size: 2rem;
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: required
2 | dist: trusty
3 | addons:
4 | apt:
5 | sources:
6 | - google-chrome
7 | packages:
8 | - google-chrome-stable
9 | env:
10 | global:
11 | - GH_REF: github.com/orizens/echoes-player.git
12 | language: node_js
13 | node_js:
14 | - "8.7.0"
15 | install:
16 | - npm install karma-es6-shim
17 | - npm install
18 | script:
19 | - npm run test:ci
20 | before_script:
21 | - export DISPLAY=:99.0
22 | - sh -e /etc/init.d/xvfb start
23 | - sleep 3
24 | after_script:
25 | - process.exit()
26 | after_success:
27 | # - chmod +x ./config/deploy.sh
28 | # - ./config/deploy.sh
29 | - npm run build:env
30 | - npm run build:prod
31 | - npm run copy:domain
32 | - npm run copy:heroku
33 | - npm run copy:package
34 | deploy:
35 | provider: pages
36 | local_dir: ./dist
37 | skip_cleanup: true
38 | github_token: $GH_TOKEN
39 | name: deployed commit $TRAVIS_COMMIT from travis
40 | on:
41 | branch: master
42 | cache:
43 | directories:
44 | - $HOME/.nvm
45 | - node_modules
46 |
--------------------------------------------------------------------------------
/src/app/core/effects/router.effects.ts:
--------------------------------------------------------------------------------
1 | import 'rxjs/add/operator/do';
2 | import 'rxjs/add/operator/map';
3 | import { Injectable } from '@angular/core';
4 | import { Router } from '@angular/router';
5 | import { Location } from '@angular/common';
6 | import { Effect, Actions } from '@ngrx/effects';
7 | import * as RouterActions from '@store/router-store';
8 |
9 | @Injectable()
10 | export class RouterEffects {
11 | @Effect({ dispatch: false })
12 | navigate$ = this.actions$
13 | .ofType(RouterActions.GO)
14 | .map((action: RouterActions.Go) => action.payload)
15 | .do(({ path, query: queryParams, extras }) => this.router.navigate(path, { queryParams, ...extras }));
16 |
17 | @Effect({ dispatch: false })
18 | navigateBack$ = this.actions$.ofType(RouterActions.BACK).do(() => this.location.back());
19 |
20 | @Effect({ dispatch: false })
21 | navigateForward$ = this.actions$.ofType(RouterActions.FORWARD).do(() => this.location.forward());
22 |
23 | constructor(private actions$: Actions, private router: Router, private location: Location) { }
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/core/services/version-checker.service.spec.ts:
--------------------------------------------------------------------------------
1 | // import { TestBed, inject } from '@angular/core/testing';
2 | // import { HttpModule } from '@angular/http';
3 | // import { StoreModule } from '@ngrx/store';
4 |
5 | // import { VersionCheckerService } from './version-checker.service';
6 |
7 | // fdescribe('VersionCheckerService', () => {
8 | // // let service: VersionCheckerService;
9 |
10 | // beforeEach(() => {
11 | // TestBed.configureTestingModule({
12 | // imports: [ HttpModule, StoreModule ],
13 | // providers: [VersionCheckerService]
14 | // });
15 | // });
16 |
17 | // it('should create a service', inject([VersionCheckerService],
18 | // (service: VersionCheckerService) => {
19 | // expect(service).toBeTruthy();
20 | // }));
21 |
22 | // it('should have a "check" method', inject([VersionCheckerService],
23 | // (service: VersionCheckerService) => {
24 | // expect(service.check).toBeDefined();
25 | // }));
26 |
27 | // it('should return an observable when "check()"', () => {
28 |
29 | // });
30 | // });
31 |
--------------------------------------------------------------------------------
/src/app/core/store/now-playlist/now-playlist.selectors.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { Observable } from 'rxjs/Observable';
3 | import { INowPlaylist } from './now-playlist.reducer';
4 | import { EchoesState } from '@store/reducers';
5 | import { createSelector } from '@ngrx/store/src/selector';
6 |
7 | export const getNowPlaylist = (state: EchoesState) => state.nowPlaylist;
8 | export const isPlayerInRepeat = createSelector(getNowPlaylist, (nowPlaylist: INowPlaylist) => nowPlaylist.repeat);
9 | export const getPlaylistVideos = createSelector(getNowPlaylist, (nowPlaylist: INowPlaylist) => nowPlaylist.videos);
10 | export const getSelectedMediaId = createSelector(getNowPlaylist, (nowPlaylist: INowPlaylist) => nowPlaylist.selectedId);
11 | export const getSelectedMedia = createSelector(getNowPlaylist, getSelectedMediaId, (nowPlaylist: INowPlaylist, selectedId: string) => {
12 | const mediaIds = nowPlaylist.videos.map(video => video.id);
13 | const selectedMediaIndex = mediaIds.indexOf(selectedId);
14 | return nowPlaylist.videos[selectedMediaIndex];
15 | });
16 |
--------------------------------------------------------------------------------
/src/app/shared/components/btn/btn.directive.ts:
--------------------------------------------------------------------------------
1 | import { Component, Directive, ElementRef, Input, OnChanges, OnInit, Renderer2, SimpleChanges } from '@angular/core';
2 | import { isNewChange } from '@shared/utils/data.utils';
3 | @Directive({
4 | selector: '[btn]'
5 | })
6 | export class ButtonDirective implements OnInit, OnChanges {
7 |
8 | @Input() btn = '';
9 |
10 | private mainStyle = 'btn';
11 | private stylePrefix = 'btn-';
12 |
13 | constructor(private element: ElementRef, private renderer: Renderer2) { }
14 |
15 | ngOnInit() {
16 | this.addClass(this.mainStyle);
17 | }
18 |
19 | ngOnChanges({ btn }: SimpleChanges) {
20 | if (btn && isNewChange(btn)) {
21 | this.applyStyles();
22 | }
23 | }
24 |
25 | addClass(className: string) {
26 | this.renderer.addClass(this.element.nativeElement, className);
27 | }
28 |
29 | applyStyles() {
30 | const prefix = this.stylePrefix;
31 | const styles = this.btn.split(' ').map(style => `${prefix}${style}`)
32 | .forEach(className => this.addClass(className));
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-user/app-navbar-user.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-navbar-user',
5 | template: `
6 |
9 |
10 |
11 |
12 |
14 |
15 | Sign In
16 |
17 |
18 | `,
19 | styleUrls: ['./app-navbar-user.component.scss'],
20 | changeDetection: ChangeDetectionStrategy.OnPush
21 | })
22 | export class AppNavbarUserComponent {
23 | @Input() userImageUrl = '';
24 | @Input() signedIn = false;
25 |
26 | @Output() signIn = new EventEmitter();
27 |
28 | constructor() { }
29 |
30 | handleSignIn() {
31 | this.signIn.emit();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/containers/user/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { SharedModule } from '@shared/index';
3 | import { AppNavbarModule } from '../app-navbar';
4 |
5 | import { PlaylistViewModule } from '../playlist-view'
6 |
7 | import { UserComponent } from './user.component';
8 | import { PlaylistsComponent } from './playlists';
9 | // import { PlaylistViewComponent, PlaylistResolver, PlaylistVideosResolver } from '@shared/components/playlist-view';
10 |
11 | import { AuthGuard } from './user.guard';
12 | import { UserPlayerService } from './user-player.service';
13 | import { routing } from './user.routing';
14 |
15 | @NgModule({
16 | imports: [
17 | SharedModule,
18 | AppNavbarModule,
19 | PlaylistViewModule,
20 | routing
21 | ],
22 | declarations: [
23 | UserComponent,
24 | PlaylistsComponent
25 | ],
26 | exports: [
27 | UserComponent
28 | ],
29 | providers: [
30 | AuthGuard,
31 | UserPlayerService,
32 | // PlaylistResolver,
33 | // PlaylistVideosResolver
34 | ]
35 | })
36 | export class UserModule { }
37 |
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-playlist/youtube-playlist.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/index.ts:
--------------------------------------------------------------------------------
1 | import { ReactiveFormsModule } from '@angular/forms';
2 | import { NgModule } from '@angular/core';
3 | import { SharedModule } from '@shared/index';
4 |
5 | import { AppSearchComponent } from './app-search.component';
6 | import { AppNavbarModule } from '../app-navbar';
7 | import { YoutubeVideosComponent } from './youtube-videos.component';
8 | import { YoutubePlaylistsComponent } from './youtube-playlists.component';
9 | import { PlayerSearchComponent } from './player-search.component';
10 | import { SearchNavigatorComponent } from './search-navigator';
11 | import { routing } from './app-search.routing';
12 |
13 | @NgModule({
14 | imports: [
15 | SharedModule,
16 | AppNavbarModule,
17 | ReactiveFormsModule,
18 | routing
19 | ],
20 | declarations: [
21 | AppSearchComponent,
22 | YoutubeVideosComponent,
23 | YoutubePlaylistsComponent,
24 | PlayerSearchComponent,
25 | SearchNavigatorComponent
26 | ],
27 | exports: [
28 | AppSearchComponent
29 | ],
30 | providers: []
31 | })
32 | export class AppSearchModule { }
33 |
--------------------------------------------------------------------------------
/src/app/core/components/app-sidebar/app-sidebar.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component } from '@angular/core';
2 | import { AppSidebarProxy } from './app-sidebar.proxy';
3 |
4 | @Component({
5 | selector: 'app-sidebar',
6 | styleUrls: ['./app-sidebar.scss'],
7 | template: `
8 |
18 | `,
19 | changeDetection: ChangeDetectionStrategy.OnPush
20 | })
21 | export class AppSidebarComponent {
22 | sidebarCollapsed$ = this.appSidebarProxy.sidebarCollapsed$;
23 | searchType$ = this.appSidebarProxy.searchType$;
24 |
25 | constructor(private appSidebarProxy: AppSidebarProxy) { }
26 |
27 | toggleSidebar() {
28 | this.appSidebarProxy.toggleSidebar();
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/core/services/youtube-videos-info.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
2 | import { Injectable } from '@angular/core';
3 | import { YoutubeApiService } from './youtube-api.service';
4 | import { Authorization } from './authorization.service';
5 |
6 | interface IYoutubeVideosInfo {
7 | items: GoogleApiYouTubeVideoResource[];
8 | }
9 | @Injectable()
10 | export class YoutubeVideosInfo {
11 | public api: YoutubeApiService;
12 |
13 | constructor(private http: HttpClient, auth: Authorization) {
14 | this.api = new YoutubeApiService({
15 | url: 'https://www.googleapis.com/youtube/v3/videos',
16 | http: this.http,
17 | idKey: 'id',
18 | config: {
19 | part: 'snippet,contentDetails,statistics'
20 | }
21 | }, auth);
22 | }
23 |
24 | fetchVideoData(mediaId: string) {
25 | return this.api
26 | .list(mediaId)
27 | .map(response => response.items[0]);
28 | }
29 |
30 | fetchVideosData(mediaIds: string) {
31 | return this.api
32 | .list(mediaIds)
33 | .map(response => response.items);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/assets/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Echoes Player",
3 | "short_name": "Echoes Player",
4 | "description": "Echoes is an easy youtube player",
5 | "icons": [
6 | {
7 | "src": "icon/android-icon-36.png",
8 | "sizes": "36x36",
9 | "type": "image/png"
10 | },
11 | {
12 | "src": "icon/android-icon-48.png",
13 | "sizes": "48x48",
14 | "type": "image/png"
15 | },
16 | {
17 | "src": "icon/android-icon-72.png",
18 | "sizes": "72x72",
19 | "type": "image/png"
20 | },
21 | {
22 | "src": "icon/android-icon-96.png",
23 | "sizes": "96x96",
24 | "type": "image/png"
25 | },
26 | {
27 | "src": "icon/android-icon-144.png",
28 | "sizes": "144x144",
29 | "type": "image/png"
30 | },
31 | {
32 | "src": "icon/android-icon-192.png",
33 | "sizes": "192x192",
34 | "type": "image/png"
35 | }
36 | ],
37 | "start_url": "../index.html?utm_source=web_app_manifest",
38 | "display": "fullscreen",
39 | "background_color": "#03A9F4",
40 | "theme_color": "#03A9F4",
41 | "orientation": "portrait-primary"
42 | }
--------------------------------------------------------------------------------
/src/app/containers/user/user.routing.ts:
--------------------------------------------------------------------------------
1 | import { PlaylistVideosResolver } from '@core/resolvers/playlist-videos.resolver';
2 | import { PlaylistResolver } from '@core/resolvers/playlist.resolver';
3 |
4 | import { ModuleWithProviders } from '@angular/core';
5 | import { RouterModule } from '@angular/router';
6 |
7 | import { UserComponent } from './user.component';
8 | import { PlaylistsComponent } from './playlists';
9 | import { PlaylistViewComponent } from '../playlist-view/playlist-view.component';
10 | import { AuthGuard } from './user.guard';
11 |
12 | export const routing: ModuleWithProviders = RouterModule.forChild([
13 | {
14 | path: '', component: UserComponent,
15 | children: [
16 | { path: '', redirectTo: 'playlists', pathMatch: 'full' },
17 | { path: 'playlists', component: PlaylistsComponent },
18 | {
19 | path: 'playlist/:id', component: PlaylistViewComponent,
20 | canActivate: [AuthGuard], canActivateChild: [AuthGuard],
21 | resolve: {
22 | videos: PlaylistVideosResolver,
23 | playlist: PlaylistResolver
24 | }
25 | }
26 | ]
27 | },
28 | ]);
29 |
--------------------------------------------------------------------------------
/src/app/containers/user/user.guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router';
3 |
4 | import { Authorization } from '@core/services';
5 |
6 | @Injectable()
7 | export class AuthGuard implements CanActivate, CanActivateChild {
8 | constructor(private authorization: Authorization, private router: Router) { }
9 |
10 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
11 | // console.log('AuthGuard#canActivate called', { state });
12 | const url: string = state.url;
13 | return this.checkLogin(url);
14 | }
15 |
16 | canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
17 | return this.canActivate(route, state);
18 | }
19 |
20 | checkLogin(url: string): boolean {
21 | if (this.authorization.isSignIn()) { return true; }
22 |
23 | // Store the attempted URL for redirecting
24 | // this.authService.redirectUrl = url;
25 |
26 | // Navigate to the login page with extras
27 | this.router.navigate(['/user']);
28 | return false;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Oren Farhi Orizens LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/src/app/core/effects/app-settings.effects.ts:
--------------------------------------------------------------------------------
1 | import { NowPlaylistService } from '@core/services';
2 | import { Store } from '@ngrx/store';
3 | import { EchoesState } from '@store/reducers';
4 | import { Injectable } from '@angular/core';
5 | import { Effect, Actions, toPayload } from '@ngrx/effects';
6 |
7 | import 'rxjs/add/observable/of';
8 | import { Observable } from 'rxjs/Observable';
9 |
10 | import * as AppLayout from '@store/app-layout';
11 | import { VersionCheckerService } from '@core/services/version-checker.service';
12 |
13 | @Injectable()
14 | export class AppSettingsEffects {
15 | constructor(
16 | public actions$: Actions,
17 | public store: Store,
18 | public versionCheckerService: VersionCheckerService
19 | ) { }
20 |
21 | @Effect({ dispatch: false })
22 | updateAppVersion$ = this.actions$
23 | .ofType(AppLayout.ActionTypes.APP_UPDATE_VERSION)
24 | .map(() => this.versionCheckerService.updateVersion());
25 |
26 | @Effect({ dispatch: false })
27 | checkForNewAppVersion$ = this.actions$
28 | .ofType(AppLayout.ActionTypes.APP_CHECK_VERSION)
29 | .map(() => this.versionCheckerService.checkForVersion());
30 | }
31 |
--------------------------------------------------------------------------------
/protractor.conf.js:
--------------------------------------------------------------------------------
1 | // Protractor configuration file, see link for more information
2 | // https://github.com/angular/protractor/blob/master/lib/config.ts
3 |
4 | const { SpecReporter } = require('jasmine-spec-reporter');
5 |
6 | exports.config = {
7 | allScriptsTimeout: 15000,
8 | specs: [
9 | './e2e/**/*.e2e-spec.ts'
10 | ],
11 | capabilities: {
12 | 'browserName': 'chrome'
13 | },
14 | directConnect: true,
15 | baseUrl: 'http://localhost:3000/',
16 | framework: 'jasmine2',
17 | jasmineNodeOpts: {
18 | showColors: true,
19 | defaultTimeoutInterval: 40000,
20 | print: function() {}
21 | },
22 | beforeLaunch: function() {
23 | require('ts-node').register({
24 | project: 'e2e/tsconfig.e2e.json'
25 | });
26 | },
27 | onPrepare() {
28 | browser.ignoreSynchronization = true;
29 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
30 | },
31 | /**
32 | * Angular 2 configuration
33 | *
34 | * useAllAngular2AppRoots: tells Protractor to wait for any angular2 apps on the page instead of just the one matching
35 | * `rootEl`
36 | */
37 | useAllAngular2AppRoots: true
38 | };
39 |
--------------------------------------------------------------------------------
/src/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../out-tsc/app",
5 | "module": "es2015",
6 | "baseUrl": "",
7 | "paths": {
8 | "~/*": [
9 | "/*"
10 | ],
11 | "@utils/*": [
12 | "app/shared/utils/*"
13 | ],
14 | "@shared/*": [
15 | "app/shared/*"
16 | ],
17 | "@animations/*": [
18 | "app/shared/animations/*"
19 | ],
20 | "@core/*": [
21 | "app/core/*"
22 | ],
23 | "@api/*": [
24 | "app/core/api/*"
25 | ],
26 | "@resolvers/*": [
27 | "app/core/resolvers/*"
28 | ],
29 | "@store/*": [
30 | "app/core/store/*"
31 | ],
32 | "@mocks/*": [
33 | "../tests/mocks/*"
34 | ],
35 | "@env/*": [
36 | "environments/*"
37 | ]
38 | },
39 | "types": [
40 | "gapi",
41 | "gapi.youtube",
42 | "gapi.auth2",
43 | "youtube",
44 | "jasmine",
45 | "node"
46 | ]
47 | },
48 | "exclude": [
49 | "test.ts",
50 | "**/*.spec.ts"
51 | ],
52 | "angularCompilerOptions": {
53 | "preserveWhitespaces": false
54 | }
55 | }
--------------------------------------------------------------------------------
/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { BrowserModule } from '@angular/platform-browser';
2 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
3 | import { NgModule } from '@angular/core';
4 | import { FormsModule } from '@angular/forms';
5 | import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http';
6 | import { RouterModule } from '@angular/router';
7 |
8 | import { CoreModule } from './core';
9 | import { SharedModule } from '@shared/index';
10 |
11 | import { APP_CORE_MODULES } from './core/components';
12 | import { APP_CONTAINER_MODULES } from './containers';
13 | import { ROUTES } from './app.routes';
14 |
15 | import { AppComponent } from './app.component';
16 |
17 | @NgModule({
18 | declarations: [
19 | AppComponent,
20 | ],
21 | imports: [
22 | BrowserModule,
23 | FormsModule,
24 | HttpClientModule,
25 | HttpClientJsonpModule,
26 | RouterModule.forRoot(ROUTES, { useHash: true }),
27 | BrowserAnimationsModule,
28 |
29 | CoreModule,
30 | SharedModule,
31 | ...APP_CORE_MODULES,
32 | ...APP_CONTAINER_MODULES
33 | ],
34 | providers: [],
35 | bootstrap: [AppComponent]
36 | })
37 | export class AppModule { }
38 |
--------------------------------------------------------------------------------
/src/app/core/store/ngrx-worker.ts:
--------------------------------------------------------------------------------
1 | // import { WebWorkerService } from 'angular2-web-worker/web-worker';
2 |
3 | // export function reducerWorker (keys) {
4 | // // if (rehydrate === void 0) { rehydrate = false; }
5 | // // if (storage === void 0) { storage = localStorage; }
6 | // const reducerWorker = new WebWorkerService();
7 | // return function (reducer) {
8 | // return function (state, action) {
9 | // //if (state === void 0) { state = rehydratedState; }
10 | // /*
11 | // Handle case where state is rehydrated AND initial state is supplied.
12 | // Any additional state supplied will override rehydrated state for the given key.
13 | // */
14 | // // if (action.type === INIT_ACTION && rehydratedState) {
15 | // // state = Object.assign({}, state, rehydratedState);
16 | // // }
17 | // var nextState = {};
18 | // reducerWorker.run(reducer(state, action)).then(result => Object.assign(nextState, result));
19 | // // exports.syncStateUpdate(nextState, stateKeys, storage);
20 | // return nextState;
21 | // };
22 | // };
23 | // };
24 |
--------------------------------------------------------------------------------
/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import 'zone.js/dist/long-stack-trace-zone';
4 | import 'zone.js/dist/proxy.js';
5 | import 'zone.js/dist/sync-test';
6 | import 'zone.js/dist/jasmine-patch';
7 | import 'zone.js/dist/async-test';
8 | import 'zone.js/dist/fake-async-test';
9 | import { getTestBed } from '@angular/core/testing';
10 | import {
11 | BrowserDynamicTestingModule,
12 | platformBrowserDynamicTesting
13 | } from '@angular/platform-browser-dynamic/testing';
14 |
15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
16 | declare const __karma__: any;
17 | declare const require: any;
18 |
19 | // Prevent Karma from running prematurely.
20 | __karma__.loaded = function () {};
21 |
22 | // First, initialize the Angular testing environment.
23 | getTestBed().initTestEnvironment(
24 | BrowserDynamicTestingModule,
25 | platformBrowserDynamicTesting()
26 | );
27 | // Then we find all the tests.
28 | const context = require.context('./', true, /\.spec\.ts$/);
29 | // And load the modules.
30 | context.keys().map(context);
31 | // Finally, start Karma to run the tests.
32 | __karma__.start();
33 |
--------------------------------------------------------------------------------
/src/app/core/store/player-search/player-search.selectors.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { Observable } from 'rxjs/Observable';
3 | import { IPlayerSearch, IQueryParam } from './player-search.reducer';
4 | import { EchoesState } from '@store/reducers';
5 | import { createSelector } from '@ngrx/store/src/selector';
6 |
7 | export const getPlayerSearch = (state: EchoesState) => state.search;
8 | export const getPlayerSearchResults = createSelector(getPlayerSearch, (search: IPlayerSearch) => search.results);
9 | export const getQuery = createSelector(getPlayerSearch, (search: IPlayerSearch) => search.query);
10 | export const getQueryParams = createSelector(getPlayerSearch, (search: IPlayerSearch) => search.queryParams);
11 | export const getQueryParamPreset = createSelector(getPlayerSearch, getQueryParams,
12 | (search: IPlayerSearch, queryParams: IQueryParam) => queryParams.preset);
13 | export const getSearchType = createSelector(getPlayerSearch, (search: IPlayerSearch) => search.searchType);
14 | export const getIsSearching = createSelector(getPlayerSearch, (search: IPlayerSearch) => search.isSearching);
15 | export const getPresets = createSelector(getPlayerSearch, (search: IPlayerSearch) => search.presets);
16 |
--------------------------------------------------------------------------------
/src/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "sourceMap": true,
5 | "outDir": "../out-tsc/spec",
6 | "module": "commonjs",
7 | "target": "es5",
8 | "baseUrl": "",
9 | "paths": {
10 | "~/*": [
11 | "/*"
12 | ],
13 | "@utils/*": [
14 | "app/shared/utils/*"
15 | ],
16 | "@shared/*": [
17 | "app/shared/*"
18 | ],
19 | "@animations/*": [
20 | "app/shared/animations/*"
21 | ],
22 | "@core/*": [
23 | "app/core/*"
24 | ],
25 | "@api/*": [
26 | "app/core/api/*"
27 | ],
28 | "@resolvers/*": [
29 | "app/core/resolvers/*"
30 | ],
31 | "@store/*": [
32 | "app/core/store/*"
33 | ],
34 | "@mocks/*": [
35 | "../tests/mocks/*"
36 | ],
37 | "@env/*": [
38 | "environments/*"
39 | ]
40 | },
41 | "types": [
42 | "jasmine",
43 | "node",
44 | "gapi",
45 | "gapi.youtube",
46 | "gapi.auth2",
47 | "youtube"
48 | ]
49 | },
50 | "files": [
51 | "test.ts"
52 | ],
53 | "include": [
54 | "**/*.spec.ts",
55 | "**/*.d.ts"
56 | ]
57 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "outDir": "./dist/out-tsc",
5 | "sourceMap": true,
6 | "declaration": false,
7 | "moduleResolution": "node",
8 | "emitDecoratorMetadata": true,
9 | "experimentalDecorators": true,
10 | "target": "es5",
11 | "typeRoots": [
12 | "node_modules/@types"
13 | ],
14 | "lib": [
15 | "es2017",
16 | "dom"
17 | ],
18 | "baseUrl": "./",
19 | "paths": {
20 | "~/*": [
21 | "src/*"
22 | ],
23 | "@utils/*": [
24 | "src/app/shared/utils/*"
25 | ],
26 | "@shared/*": [
27 | "./src/app/shared/*"
28 | ],
29 | "@animations/*": [
30 | "./src/app/shared/animations/*"
31 | ],
32 | "@core/*": [
33 | "./src/app/core/*"
34 | ],
35 | "@api/*": [
36 | "./src/app/core/api/*"
37 | ],
38 | "@resolvers/*": [
39 | "./src/app/core/resolvers/*"
40 | ],
41 | "@store/*": [
42 | "./src/app/core/store/*"
43 | ],
44 | "@mocks/*": [
45 | "./tests/mocks/*"
46 | ],
47 | "@env/*": [
48 | "./src/environments/*"
49 | ]
50 | }
51 | }
52 | }
--------------------------------------------------------------------------------
/src/app/app.component.spec.ts:
--------------------------------------------------------------------------------
1 | // import { TestBed, async } from '@angular/core/testing';
2 |
3 | // import { AppComponent } from './app.component';
4 |
5 | // describe('AppComponent', () => {
6 | // beforeEach(async(() => {
7 | // TestBed.configureTestingModule({
8 | // declarations: [
9 | // AppComponent
10 | // ],
11 | // }).compileComponents();
12 | // }));
13 |
14 | // it('should create the app', async(() => {
15 | // const fixture = TestBed.createComponent(AppComponent);
16 | // const app = fixture.debugElement.componentInstance;
17 | // expect(app).toBeTruthy();
18 | // }));
19 |
20 | // it(`should have as title 'app works!'`, async(() => {
21 | // const fixture = TestBed.createComponent(AppComponent);
22 | // const app = fixture.debugElement.componentInstance;
23 | // expect(app.title).toEqual('app works!');
24 | // }));
25 |
26 | // it('should render title in a h1 tag', async(() => {
27 | // const fixture = TestBed.createComponent(AppComponent);
28 | // fixture.detectChanges();
29 | // const compiled = fixture.debugElement.nativeElement;
30 | // expect(compiled.querySelector('h1').textContent).toContain('app works!');
31 | // }));
32 | // });
33 |
--------------------------------------------------------------------------------
/src/app/core/services/gapi-loader.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { Observable } from 'rxjs/Observable';
3 | import { Subject } from 'rxjs/Subject';
4 |
5 | @Injectable()
6 | export class GapiLoader {
7 | private _api: Observable;
8 | private api: Subject;
9 |
10 | constructor() { }
11 |
12 | load(api: string) {
13 | return this.createApi(api);
14 | }
15 | _loadApi(api: string, observer) {
16 | const gapi = window['gapi'];
17 | const gapiAuthLoaded = gapi && gapi.auth2 && gapi.auth2.getAuthInstance();
18 | if (gapiAuthLoaded && gapiAuthLoaded.currentUser) {
19 | return observer.next(gapiAuthLoaded);
20 | }
21 | gapi.load(api, response => observer.next(response));
22 | }
23 |
24 | createApi(api: string) {
25 | const api$ = new Subject();
26 | const gapi = window['gapi'];
27 | // this._api = new Observable(observer => {
28 | const isGapiLoaded = gapi && gapi.load;
29 | const onApiLoaded = () => this._loadApi(api, api$);
30 | if (isGapiLoaded) {
31 | onApiLoaded();
32 | } else {
33 | window['apiLoaded'] = onApiLoaded;
34 | }
35 | // });
36 | // return this._api;
37 | return api$;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/core/services/analytics.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { Subject } from 'rxjs/Subject';
3 |
4 | declare const gtag;
5 |
6 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events#default-event-categories-and-labels
7 | // Google Analytics Events
8 | const Events = {
9 | Login: {
10 | NAME: 'login',
11 | LABEL: 'method'
12 | },
13 | Search: {
14 | NAME: 'search',
15 | LABEL: 'search_term'
16 | }
17 | };
18 |
19 | const CustomEvents = {
20 | VIDEO_PLAY: 'video_play'
21 | };
22 | @Injectable()
23 | export class AnalyticsService {
24 | private projectId = window['GA_PROJECT_ID'];
25 |
26 | constructor() { }
27 |
28 | trackPage(page) {
29 | gtag('config', this.projectId, {
30 | 'page_title': page,
31 | 'page_location': location.origin,
32 | 'page_path': location.hash
33 | });
34 | }
35 |
36 | trackSearch(searchType) {
37 | gtag('event', Events.Search.NAME, { [Events.Search.LABEL]: searchType });
38 | }
39 |
40 | trackSignin() {
41 | gtag('event', Events.Login.NAME, { [Events.Login.LABEL]: 'Google' });
42 | }
43 |
44 | trackVideoPlay() {
45 | gtag('event', CustomEvents.VIDEO_PLAY);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/app/shared/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { CommonModule } from '@angular/common';
3 | import { FormsModule } from '@angular/forms';
4 | import { RouterModule } from '@angular/router';
5 |
6 | import { InfiniteScrollModule } from 'ngx-infinite-scroll';
7 | import { NgxTypeaheadModule } from 'ngx-typeahead';
8 | import { YoutubePlayerModule } from 'ngx-youtube-player';
9 | import { CORE_COMPONENTS } from './components';
10 | import { CORE_DIRECTIVES } from './directives';
11 | import { PIPES } from './pipes';
12 | import { TooltipModule } from 'ngx-tooltip';
13 |
14 | @NgModule({
15 | imports: [
16 | CommonModule,
17 | FormsModule,
18 | RouterModule,
19 | YoutubePlayerModule,
20 | InfiniteScrollModule,
21 | NgxTypeaheadModule,
22 | TooltipModule
23 | ],
24 | declarations: [
25 | ...CORE_COMPONENTS,
26 | ...CORE_DIRECTIVES,
27 | ...PIPES
28 | ],
29 | exports: [
30 | CommonModule,
31 | FormsModule,
32 | ...CORE_COMPONENTS,
33 | ...CORE_DIRECTIVES,
34 | ...PIPES,
35 | InfiniteScrollModule,
36 | YoutubePlayerModule,
37 | NgxTypeaheadModule,
38 | TooltipModule
39 | ],
40 | providers: []
41 | })
42 | export class SharedModule { }
43 |
--------------------------------------------------------------------------------
/src/app/shared/components/button-group/button-group.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeDetectionStrategy,
3 | Component,
4 | EventEmitter,
5 | Input,
6 | OnInit,
7 | Output,
8 | ViewEncapsulation
9 | } from '@angular/core';
10 |
11 | // import './button-group.component.scss';
12 |
13 | export interface ButtonGroupButton {
14 | label: string;
15 | value: any;
16 | }
17 | @Component({
18 | selector: 'button-group',
19 | styleUrls: [ './button-group.component.scss' ],
20 | template: `
21 |
22 |
28 |
29 | `,
30 | changeDetection: ChangeDetectionStrategy.OnPush
31 | })
32 |
33 | export class ButtonGroupComponent implements OnInit {
34 | @Input() buttons: ButtonGroupButton[];
35 | @Input() selectedButton: string;
36 |
37 | @Output() buttonClick = new EventEmitter();
38 |
39 | ngOnInit() { }
40 |
41 | isSelectedButton(buttonValue: string) {
42 | return buttonValue === this.selectedButton;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/tests/mocks/youtube.media.item.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | export let YoutubeMediaMock = {
3 | "kind": "youtube#searchResult",
4 | "etag": "\"q5k97EMVGxODeKcDgp8gnMu79wM/ms1isEChRlNREE1F9UOkqY3Ihe8\"",
5 | "id": {
6 | "kind": "youtube#video",
7 | "videoId": "5PGvyjZtVKU"
8 | },
9 | "snippet": {
10 | "publishedAt": "2016-02-19T15:00:39.000Z",
11 | "channelId": "UCRxLFt-qJHbT-zzeZt4eQpw",
12 | "title": "TREMONTI + DUST = 04.29.16",
13 | "description": "\"Dust\" Presale Available NOW - https://fret12.com/store/tremonti-dust 'Dust' is Tremonti's follow up record to their 2015 FRET12 release 'Cauterize'. Get all the ...",
14 | "thumbnails": {
15 | "default": {
16 | "url": "https://i.ytimg.com/vi/5PGvyjZtVKU/default.jpg",
17 | "width": 120,
18 | "height": 90
19 | },
20 | "medium": {
21 | "url": "https://i.ytimg.com/vi/5PGvyjZtVKU/mqdefault.jpg",
22 | "width": 320,
23 | "height": 180
24 | },
25 | "high": {
26 | "url": "https://i.ytimg.com/vi/5PGvyjZtVKU/hqdefault.jpg",
27 | "width": 480,
28 | "height": 360
29 | }
30 | },
31 | "channelTitle": "TremontiOfficial",
32 | "liveBroadcastContent": "none"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration file, see link for more information
2 | // https://karma-runner.github.io/0.13/config/configuration-file.html
3 |
4 | module.exports = function (config) {
5 | let options = {
6 | basePath: '',
7 | frameworks: ['jasmine', '@angular/cli'],
8 | plugins: [
9 | require('karma-jasmine'),
10 | require('karma-chrome-launcher'),
11 | require('karma-jasmine-html-reporter'),
12 | require('karma-spec-reporter'),
13 | require('karma-coverage-istanbul-reporter'),
14 | require('@angular/cli/plugins/karma')
15 | ],
16 | client: {
17 | clearContext: false // leave Jasmine Spec Runner output visible in browser
18 | },
19 | coverageIstanbulReporter: {
20 | reports: ['html', 'lcovonly'],
21 | fixWebpackSourcePaths: true
22 | },
23 | angularCli: {
24 | environment: 'dev'
25 | },
26 | reporters: [/* 'progress', */'spec', 'kjhtml'],
27 | port: 9876,
28 | colors: true,
29 | logLevel: config.LOG_INFO,
30 | autoWatch: true,
31 | browsers: ['Chrome'],
32 | singleRun: false
33 | };
34 | if (process.env.TRAVIS) {
35 | options.singleRun = true;
36 | options.browsers = ['Chrome'];
37 | }
38 | config.set(options);
39 | };
40 |
--------------------------------------------------------------------------------
/src/css/themes/arctic.theme.scss:
--------------------------------------------------------------------------------
1 | body.arctic {
2 | --brand-primary: $brand-primary;
3 | --brand-secondary: $brand-secondary;
4 | --brand-success: $brand-success;
5 | --brand-warning: $brand-warning;
6 | --brand-danger: $brand-danger;
7 | --brand-info: $brand-info;
8 |
9 | --sidebar-bg: $blue-grey-900;
10 | --sidebar-bg-secondary: $blue-grey-darker;
11 | --sidebar-text: $inverse;
12 | --sidebar-text-lighter: $white-darker;
13 |
14 | --brand-inverse-text: $inverse;
15 | --brand-primary-lite-bg-color: $inverse;
16 | --brand-primary-text-color: $text-color;
17 | --brand-dark-bg-color: $dark;
18 | --brand-dark-bg-color-transparent: $dark-transparent;
19 | --brand-bg-lite-transparent: $inverse-transparent;
20 |
21 | --link-primary-color: var(--brand-primary);
22 | --link-primary-hover-color: var(--brand-primary);
23 |
24 | --navbar-bg-color: $white-dark-transparent;
25 | --navbar-text-color: $text-color;
26 | --navbar-link-color: var(--link-primary-color);
27 |
28 | --list-group-item-text: #555;
29 | --list-group-item-text-hover: #555;
30 | --list-group-item-bg: $inverse;
31 | --list-group-item-bg-hover: #f5f5f5;
32 | --list-group-item-border: #ddd;
33 |
34 | --player-controls-bar-bg: $gray-darker;
35 |
36 | --app-bg-color: $body-bg;
37 | }
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-playlist/youtube-playlist.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeDetectorRef, ChangeDetectionStrategy, Component,
3 | EventEmitter, Input, Output,
4 | OnChanges, SimpleChanges
5 | } from '@angular/core';
6 | import { extractThumbUrl } from '@utils/media.utils';
7 |
8 | @Component({
9 | selector: 'youtube-playlist',
10 | styleUrls: ['./youtube-playlist.scss'],
11 | templateUrl: './youtube-playlist.html',
12 | changeDetection: ChangeDetectionStrategy.OnPush
13 | })
14 | export class YoutubePlaylistComponent implements OnChanges {
15 | @Input() media: GoogleApiYouTubePlaylistResource;
16 | @Input() link = './';
17 | @Output() play = new EventEmitter();
18 | @Output() queue = new EventEmitter();
19 |
20 | isPlaying = false;
21 | thumb = '';
22 | loading = false;
23 |
24 | ngOnChanges({ media }: SimpleChanges) {
25 | if (media && !media.firstChange || media && media.firstChange) {
26 | this.thumb = extractThumbUrl(this.media);
27 | }
28 | }
29 |
30 | playPlaylist(media: GoogleApiYouTubePlaylistResource) {
31 | this.play.next(media);
32 | }
33 |
34 | queuePlaylist(media: GoogleApiYouTubePlaylistResource) {
35 | this.queue.next(media);
36 | }
37 |
38 | onNavigateToPlaylist() {
39 | this.loading = true;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/core/store/reducers.ts:
--------------------------------------------------------------------------------
1 | import { RouterStateSnapshot } from '@angular/router';
2 | import { Observable } from 'rxjs/Observable';
3 | import { ActionReducerMap, Store } from '@ngrx/store';
4 | // import { routerReducer, RouterReducerState } from '@ngrx/router-store';
5 |
6 | // reducers
7 | import { IAppPlayer, player, ActionTypes } from './app-player';
8 | import { INowPlaylist, nowPlaylist, NowPlaylistActions } from './now-playlist';
9 | import { IUserProfile, user, UserProfileActions } from './user-profile';
10 | import { IPlayerSearch, search, PlayerSearchActions } from './player-search';
11 | import { IAppSettings, appLayout } from './app-layout';
12 |
13 | // The top level Echoes Player application interface
14 | // each reducer is reponsible for manipulating a certain state
15 | export interface EchoesState {
16 | player: IAppPlayer;
17 | nowPlaylist: INowPlaylist;
18 | user: IUserProfile;
19 | search: IPlayerSearch;
20 | appLayout: IAppSettings;
21 | // routerReducer: RouterReducerState;
22 | }
23 |
24 | export let EchoesReducers: ActionReducerMap = {
25 | player,
26 | nowPlaylist,
27 | user,
28 | search,
29 | appLayout,
30 | // routerReducer
31 | };
32 |
33 | export let EchoesActions = [ActionTypes, NowPlaylistActions, UserProfileActions, PlayerSearchActions];
34 |
--------------------------------------------------------------------------------
/src/app/shared/directives/icon/icon.directive.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Directive,
3 | ElementRef,
4 | Input,
5 | OnChanges,
6 | OnInit,
7 | SimpleChanges,
8 | Renderer2
9 | } from '@angular/core';
10 | import { isNewChange } from '@utils/data.utils';
11 |
12 | const ICON_BASE_CLASSNAME = 'fa';
13 | const ICON_LIB_PREFFIX = 'fa';
14 | @Directive({
15 | selector: 'icon, [appIcon]'
16 | })
17 | export class IconDirective implements OnInit, OnChanges {
18 | @Input() name = '';
19 |
20 | icons = {
21 | 'fa': true
22 | };
23 |
24 | constructor(private el: ElementRef, private renderer: Renderer2) { }
25 |
26 | ngOnInit() {
27 | const { name } = this;
28 | let classes = [ICON_BASE_CLASSNAME];
29 | if (name) {
30 | classes = [...classes, ...this.createIconStyles(name)];
31 | }
32 | this.setClasses(classes);
33 | }
34 |
35 | ngOnChanges({ name }: SimpleChanges) {
36 | if (name && isNewChange(name)) {
37 | this.createIconStyles(name.currentValue);
38 | }
39 | }
40 |
41 | createIconStyles(names: string): string[] {
42 | return names.split(' ')
43 | .map(name => `${ICON_LIB_PREFFIX}-${name}`);
44 | }
45 |
46 | setClasses(names: string[]) {
47 | names.forEach(name =>
48 | this.renderer.addClass(this.el.nativeElement, name));
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/css/layout/navbar.scss:
--------------------------------------------------------------------------------
1 | @import '../core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | $navbar-new-height: 1rem;
5 |
6 | .navbar {
7 | background-image: none;
8 | background-color: var(--navbar-bg-color);
9 | transition: transform, margin-left .3s ease-out;
10 |
11 | &.navbar-fixed-top {
12 | padding: 0;
13 | z-index: 1020;
14 |
15 | &.fullscreen {
16 | transform: translatey(-60px);
17 | }
18 | }
19 |
20 | .navbar-brand {
21 | margin: 0;
22 | position: relative;
23 | }
24 | .sidebar-toggle {
25 | padding: .3rem 1.5rem;
26 | font-size: 2rem;
27 | line-height: 2;
28 | }
29 |
30 | .navbar-header {
31 | width: auto;
32 | margin-right: 7px;
33 | line-height: $navbar-new-height;
34 | }
35 | }
36 |
37 | &.navbar-transparent {
38 | background-color: transparent;
39 | color: $inverse;
40 | }
41 | }
42 |
43 | @media (min-width: 768px) {
44 | .navbar {
45 |
46 | .container {
47 | padding-right: 15px;
48 | padding-left: 15px;
49 | margin-right: auto;
50 | margin-left: auto;
51 | padding: 0;
52 | }
53 | }
54 | .navbar-fixed-top {
55 | box-shadow: 0 1px 7px rgba(0,0,0,0.5);
56 | border-width: 0;
57 | }
58 | }
59 | @media (min-width: 1280px) {
60 | .navbar .navbar-brand {
61 | padding-left: 5px;
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/css/themes/bumblebee.theme.scss:
--------------------------------------------------------------------------------
1 | body.bumblebee {
2 | --brand-primary: #faca0b;
3 | --brand-secondary: #9a5a42;
4 | --brand-success: #2ecc71;
5 | --brand-warning: #f18f0f;
6 | --brand-danger: #f13939;
7 | --brand-info: #00BCD4;
8 |
9 | --sidebar-bg: rgba(18, 22, 24, 0.95);
10 | --sidebar-bg-secondary: #080d0f;
11 | --sidebar-text: white;
12 | --sidebar-text-lighter: #c9c7c7;
13 |
14 | --brand-inverse-text: white;
15 | --brand-primary-lite-bg-color: white;
16 | --brand-primary-text-color: #fbfbfb;
17 | --brand-dark-bg-color: #000;
18 | --brand-dark-bg-color-transparent: rgba(10, 10, 10, 0.5);
19 | --brand-bg-lite-transparent: rgb(19, 40, 51);
20 |
21 | --link-primary-color: var(--brand-primary);
22 | --link-primary-hover-color: var(--brand-primary);
23 |
24 | --navbar-bg-color: rgba(18, 22, 24, 0.95);
25 | --navbar-text-color: #fbfbfb;
26 | --navbar-link-color: var(--link-primary-color);
27 |
28 | --list-group-item-text: var(--sidebar-text);
29 | --list-group-item-text-hover: var(--sidebar-text);
30 | --list-group-item-bg: var(--sidebar-bg-secondary);
31 | --list-group-item-bg-hover: var(--brand-bg-lite-transparent);
32 | --list-group-item-border: var(--brand-bg-lite-transparent);
33 |
34 | --player-controls-bar-bg: var(--navbar-bg-color);
35 |
36 | --app-bg-color: #0d0f0f;
37 | }
--------------------------------------------------------------------------------
/src/css/themes/halloween.theme.scss:
--------------------------------------------------------------------------------
1 | body.halloween {
2 | --brand-primary: #FF5722;
3 | --brand-secondary: #d77418;
4 | --brand-success: #2196F3;
5 | --brand-warning: #f1c40f;
6 | --brand-danger: #F44336;
7 | --brand-info: #4CAF50;
8 |
9 | --sidebar-bg: rgba(10, 21, 27, 0.95);
10 | --sidebar-bg-secondary: #0e191f;
11 | --sidebar-text: white;
12 | --sidebar-text-lighter: #c9c7c7;
13 |
14 | --brand-inverse-text: white;
15 | --brand-primary-lite-bg-color: white;
16 | --brand-primary-text-color: #fbfbfb;
17 | --brand-dark-bg-color: #000;
18 | --brand-dark-bg-color-transparent: rgba(10, 10, 10, 0.5);
19 | --brand-bg-lite-transparent: rgb(19, 40, 51);
20 |
21 | --link-primary-color: var(--brand-primary);
22 | --link-primary-hover-color: var(--brand-primary);
23 |
24 | --navbar-bg-color: rgba(10, 21, 27, 0.95);
25 | --navbar-text-color: #fbfbfb;
26 | --navbar-link-color: var(--link-primary-color);
27 |
28 | --list-group-item-text: var(--sidebar-text);
29 | --list-group-item-text-hover: var(--sidebar-text);
30 | --list-group-item-bg: var(--sidebar-bg-secondary);
31 | --list-group-item-bg-hover: var(--brand-bg-lite-transparent);
32 | --list-group-item-border: var(--brand-bg-lite-transparent);
33 |
34 | --player-controls-bar-bg: var(--navbar-bg-color);
35 |
36 | --app-bg-color: #0b161b;
37 | }
--------------------------------------------------------------------------------
/src/app/containers/user/playlists/playlists.component.ts:
--------------------------------------------------------------------------------
1 | import { UserPlayerService } from '../user-player.service';
2 | import { EchoesState } from '@core/store';
3 | import { Component, OnInit } from '@angular/core';
4 | import { Store } from '@ngrx/store';
5 |
6 | @Component({
7 | selector: 'playlists',
8 | template: `
9 |
20 | `
21 | })
22 | export class PlaylistsComponent implements OnInit {
23 | playlists$ = this.store.select(state => state.user.playlists);
24 |
25 | constructor(
26 | private store: Store,
27 | private userPlayerService: UserPlayerService
28 | ) { }
29 |
30 | ngOnInit() { }
31 |
32 | playSelectedPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
33 | this.userPlayerService.playSelectedPlaylist(playlist);
34 | }
35 |
36 | queueSelectedPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
37 | this.userPlayerService.queuePlaylist(playlist);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-menu/app-navbar-menu.component.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | :host {
5 |
6 | @keyframes pulse {
7 | 0% { transform: scale(0); opacity: 1 }
8 | 50% { transform: scale(1); opacity: .5; }
9 | 100% { transform: scale(1.5); opacity: 0;}
10 | }
11 |
12 | @keyframes slideInDown {
13 | 0% {
14 | transform: scale(0.4);
15 | opacity: 0;
16 | }
17 |
18 | 100% {
19 | transform: scale(1);
20 | opacity: 1;
21 | }
22 | }
23 | .pulse {
24 | animation: pulse 2s infinite;
25 | }
26 |
27 | .btn-toggle {
28 | padding: 1rem 1.5rem;
29 |
30 | .update-indicator {
31 | position: absolute;
32 | // transform: translateY(-8px);
33 | top: 13px; /* for pulse animation*/
34 | }
35 | }
36 |
37 | .menu-dropdown {
38 | position: absolute;
39 | right: 0;
40 | top: -35rem;
41 | min-width: 250px;
42 |
43 | &.slideInDown {
44 | animation: slideInDown .5s 1;
45 | top: 5rem;
46 | transform-origin: top right;
47 | }
48 | .menu-backdrop {
49 | position: fixed;
50 | top: 0;
51 | bottom: 0;
52 | right: 0;
53 | left: 0;
54 | }
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/src/app/core/services/index.ts:
--------------------------------------------------------------------------------
1 | import { UserProfile } from './user-profile.service';
2 | import { YoutubeSearch } from './youtube.search';
3 | import { YoutubePlayerService } from './youtube-player.service';
4 | import { NowPlaylistService } from './now-playlist.service';
5 | import { YoutubeVideosInfo } from './youtube-videos-info.service';
6 | import { GapiLoader } from './gapi-loader.service';
7 | import { Authorization } from './authorization.service';
8 | import { YoutubeDataApi } from './youtube-data-api';
9 | import { VersionCheckerService } from './version-checker.service';
10 | import { MediaParserService } from './media-parser.service';
11 | import { AnalyticsService } from './analytics.service';
12 |
13 | export * from './user-profile.service';
14 | export * from './youtube.search';
15 | export * from './youtube-player.service';
16 | export * from './now-playlist.service';
17 | export * from './youtube-videos-info.service';
18 | export * from './gapi-loader.service';
19 | export * from './authorization.service';
20 | export * from './version-checker.service';
21 | export * from './media-parser.service';
22 |
23 | export const APP_SERVICES = [
24 | UserProfile,
25 | YoutubeSearch,
26 | YoutubePlayerService,
27 | NowPlaylistService,
28 | YoutubeVideosInfo,
29 | GapiLoader,
30 | Authorization,
31 | YoutubeDataApi,
32 | VersionCheckerService,
33 | MediaParserService,
34 | AnalyticsService
35 | ];
36 |
--------------------------------------------------------------------------------
/src/css/style.scss:
--------------------------------------------------------------------------------
1 | /* ===== Primary Styles ========================================================
2 | Author: Oren Farhi, http://orizens.com
3 | ========================================================================== */
4 | /* Application */
5 | // $icon-font-path: '~bootstrap-sass/assets/fonts/bootstrap/';
6 | @import './echoes-variables';
7 | // @import '~bootstrap-sass/assets/stylesheets/bootstrap';
8 | @import './bootstrap-custom';
9 | @import './core/global';
10 | @import './core/utils';
11 |
12 | $fa-font-path: '../fonts' !default;
13 | @import '~font-awesome/scss/font-awesome';
14 |
15 | // Themes
16 | @import './themes/index.themes';
17 |
18 | .btn:focus,
19 | .btn:active:focus,
20 | .btn.active:focus,
21 | .btn.focus,
22 | .btn:active.focus,
23 | .btn.active.focus {
24 | outline: none;
25 | }
26 |
27 | iframe {
28 | border: none;
29 | }
30 | // ngx-tooltip Style
31 | tooltip-content .tooltip .tooltip-inner {
32 | box-shadow: 0px 0px 2px var(--brand-primary);
33 | color: var(--brand-primary);
34 | }
35 |
36 | // -list group item
37 | .list-group-item,
38 | button.list-group-item,
39 | a.list-group-item {
40 | background-color: var(--list-group-item-bg);
41 | border-color: var(--list-group-item-border);
42 | color: var(--list-group-item-text);
43 |
44 | &:hover {
45 | background-color: var(--list-group-item-bg-hover);
46 | color: var(--list-group-item-text-hover);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/app/shared/components/playlist-viewer/playlist-cover.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | $transparent-bg: rgba(255,255,255, .5);
3 | $cover-padding-size: 9rem 0;
4 | $cover-blur-size: 24rem;
5 | $cover-blur-value: 5px;
6 | $cover-blur-scale: 1.2;
7 | $thumbnail-top: 56px;
8 | display: block;
9 |
10 | .playlist-cover {
11 | position: relative;
12 | padding: $cover-padding-size;
13 | margin-bottom: 1rem;
14 | overflow: hidden;
15 |
16 | .cover-bg {
17 | background-size: cover;
18 | background-repeat: no-repeat;
19 | background-position: center;
20 | filter: blur($cover-blur-value);
21 | transform: scale(1.2$cover-blur-scale);
22 | height: $cover-blur-size;
23 | position: absolute;
24 | top: 0;
25 | left: 0;
26 | width: 100%;
27 | }
28 |
29 | .playlist-thumbnail {
30 | position: absolute;
31 | top: $thumbnail-top;
32 |
33 | img {
34 | border: 1px solid $transparent-bg;
35 | }
36 | }
37 |
38 | h4 {
39 | margin: 0;
40 | line-height: 5rem;
41 |
42 | span {
43 | font-weight: normal;
44 | position: relative;
45 | background-color: $transparent-bg;
46 | padding: 1rem;
47 | }
48 | }
49 |
50 | .actions {
51 | transform: translateX(11rem) translateY(9rem);
52 |
53 | .btn {
54 | color: white;
55 |
56 | &:hover {
57 | color: black;
58 | }
59 | }
60 | }
61 | }
62 | }
--------------------------------------------------------------------------------
/src/app/containers/user/user.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, ViewEncapsulation } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { EchoesState } from '@core/store';
4 |
5 | import * as UserProfile from '@core/store/user-profile/user-profile.selectors';
6 | import { AppApi } from '@api/app.api';
7 |
8 |
9 | @Component({
10 | selector: 'app-user',
11 | encapsulation: ViewEncapsulation.None,
12 | styleUrls: ['./user.scss'],
13 | template: `
14 |
15 |
19 |
20 | To view your playlists in youtube, you need to sign in.
21 |
25 |
26 |
27 |
28 | `
29 | })
30 | export class UserComponent implements OnInit {
31 | playlists$ = this.store.select(UserProfile.getUserPlaylists);
32 | currentPlaylist$ = this.store.select(UserProfile.getUserViewPlaylist);
33 | isSignedIn$ = this.store.select(UserProfile.getIsUserSignedIn);
34 |
35 | constructor(
36 | private appApi: AppApi,
37 | public store: Store
38 | ) {
39 | console.log('LAZY..');
40 | }
41 |
42 | ngOnInit() { }
43 |
44 | signInUser() {
45 | this.appApi.signinUser();
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/.angular-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "project": {
4 | "name": "echoes-player"
5 | },
6 | "apps": [
7 | {
8 | "root": "src",
9 | "outDir": "dist",
10 | "assets": [
11 | "assets",
12 | "favicon.ico"
13 | ],
14 | "index": "index.html",
15 | "main": "main.ts",
16 | "polyfills": "polyfills.ts",
17 | "test": "test.ts",
18 | "tsconfig": "tsconfig.app.json",
19 | "testTsconfig": "tsconfig.spec.json",
20 | "prefix": "app",
21 | "styles": [
22 | "css/style.scss"
23 | ],
24 | "scripts": [],
25 | "environmentSource": "environments/environment.ts",
26 | "environments": {
27 | "dev": "environments/environment.ts",
28 | "prod": "environments/environment.prod.ts"
29 | }
30 | }
31 | ],
32 | "e2e": {
33 | "protractor": {
34 | "config": "./protractor.conf.js"
35 | }
36 | },
37 | "lint": [
38 | {
39 | "project": "src/tsconfig.app.json",
40 | "exclude": "**/node_modules/**"
41 | },
42 | {
43 | "project": "src/tsconfig.spec.json",
44 | "exclude": "**/node_modules/**"
45 | },
46 | {
47 | "project": "e2e/tsconfig.e2e.json",
48 | "exclude": "**/node_modules/**"
49 | }
50 | ],
51 | "test": {
52 | "karma": {
53 | "config": "./karma.conf.js"
54 | }
55 | },
56 | "defaults": {
57 | "styleExt": "scss",
58 | "component": {}
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-user/app-navbar-user.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { NO_ERRORS_SCHEMA } from '@angular/core';
2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing';
3 | import { RouterTestingModule } from '@angular/router/testing';
4 |
5 | import { AppNavbarUserComponent } from './app-navbar-user.component';
6 |
7 | describe('AppNavbarUserComponent', () => {
8 | let component: AppNavbarUserComponent;
9 | let fixture: ComponentFixture;
10 |
11 | beforeEach(async(() => {
12 | TestBed.configureTestingModule({
13 | imports: [RouterTestingModule],
14 | schemas: [NO_ERRORS_SCHEMA],
15 | declarations: [AppNavbarUserComponent]
16 | })
17 | .compileComponents();
18 | }));
19 |
20 | beforeEach(() => {
21 | fixture = TestBed.createComponent(AppNavbarUserComponent);
22 | component = fixture.componentInstance;
23 | fixture.detectChanges();
24 | });
25 |
26 | it('should create a component', () => {
27 | expect(component).toBeTruthy();
28 | });
29 |
30 | it('should have an empty image url by default', () => {
31 | expect(component.userImageUrl).toBe('');
32 | });
33 |
34 | it('should NOT be signed in by default', () => {
35 | expect(component.signedIn).toBeFalsy();
36 | });
37 |
38 | it('should emit an event when signed in', () => {
39 | spyOn(component.signIn, 'emit');
40 | component.handleSignIn();
41 | expect(component.signIn.emit).toHaveBeenCalledTimes(1);
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/app/core/components/app-navigator/app-navigator.component.ts:
--------------------------------------------------------------------------------
1 | import { Router } from '@angular/router';
2 | import { EchoesState } from '@store/reducers';
3 | import { Store } from '@ngrx/store';
4 | import {
5 | ChangeDetectionStrategy,
6 | Component,
7 | EventEmitter,
8 | Input,
9 | OnInit,
10 | } from '@angular/core';
11 | import * as PlayerSearch from '@core/store/player-search';
12 |
13 | @Component({
14 | selector: 'app-navigator',
15 | styleUrls: ['./app-navigator.scss'],
16 | template: `
17 |
19 |
25 |
26 | `,
27 | changeDetection: ChangeDetectionStrategy.OnPush
28 | })
29 | export class AppNavigatorComponent implements OnInit {
30 | @Input() closed = false;
31 | @Input() searchType = PlayerSearch.CSearchTypes.VIDEO;
32 |
33 | public searchType$ = this.store.select(PlayerSearch.getSearchType);
34 | public routes = [
35 | { link: 'search', icon: 'music', label: 'Explore' }
36 | // { link: '/user', icon: 'heart', label: 'My Profile' }
37 | ];
38 |
39 | constructor(
40 | private store: Store,
41 | private router: Router
42 | ) { }
43 |
44 | ngOnInit() {
45 | }
46 |
47 | go(link) {
48 | this.router.navigate([`/${link}/${this.searchType}s`]);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/media-info/media-info.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeDetectionStrategy,
3 | Component,
4 | EventEmitter,
5 | Input,
6 | OnInit,
7 | Output,
8 | HostListener
9 | } from '@angular/core';
10 |
11 | @Component({
12 | selector: 'media-info',
13 | styleUrls: ['./media-info.scss'],
14 | template: `
15 |
16 |
25 |
26 | `,
27 | changeDetection: ChangeDetectionStrategy.OnPush
28 | })
29 | export class MediaInfoComponent implements OnInit {
30 | @Input() player: any = {};
31 | @Input() minimized: GoogleApiYouTubeVideoResource;
32 | @Output() thumbClick = new EventEmitter();
33 |
34 | constructor() { }
35 |
36 | ngOnInit() { }
37 |
38 | @HostListener('window:keyup.Escape', ['$event'])
39 | keyEvent(event: KeyboardEvent) {
40 | if (this.player.fullscreen.on) {
41 | this.handleThumbClick();
42 | }
43 | }
44 |
45 | handleThumbClick() {
46 | this.thumbClick.next();
47 | }
48 |
49 | get _minimized() {
50 | return !this.minimized.hasOwnProperty('id');
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/shared/animations/fade-in.animation.ts:
--------------------------------------------------------------------------------
1 | import {
2 | animate,
3 | state,
4 | style,
5 | transition,
6 | trigger,
7 | group
8 | } from '@angular/animations';
9 |
10 | export const fadeInAnimation = trigger('fadeIn', [
11 | state('void', style({ opacity: 0, transform: 'translateY(-2rem)' })),
12 | transition('void => *', [
13 | animate(
14 | '300ms ease-in',
15 | style({
16 | opacity: 1,
17 | transform: 'translateY(0rem)'
18 | })
19 | )
20 | ]),
21 | transition('* => void', [
22 | animate(
23 | '300ms ease-out',
24 | style({
25 | opacity: 0,
26 | transform: 'translateY(-2rem)'
27 | })
28 | )
29 | ])
30 | ]);
31 |
32 | export const flyOut = trigger('flyOut', [
33 | state('void', style({ opacity: 0, transform: 'translateY(-30%)' })),
34 | transition('void => *', [
35 | animate(
36 | '300ms ease-out',
37 | style({
38 | opacity: 1,
39 | transform: 'translateY(0%)'
40 | })
41 | )
42 | ]),
43 | transition('* => void', [
44 | animate(
45 | '300ms ease-out',
46 | style({
47 | opacity: 0,
48 | transform: 'translateX(-80%)'
49 | })
50 | )
51 | ])
52 | ]);
53 |
54 | export const flyInOut = trigger('flyInOut', [
55 | state('in', style({ transform: 'translateX(0)' })),
56 | transition('void => *', [
57 | style({ transform: 'translateX(-100%)' }),
58 | animate(100)
59 | ]),
60 | transition('* => void', [
61 | animate(100, style({ transform: 'translateX(100%)' }))
62 | ])
63 | ]);
64 |
--------------------------------------------------------------------------------
/src/app/core/store/index.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { Store, StoreModule, ActionReducer, MetaReducer } from '@ngrx/store';
3 | import { StoreDevtoolsModule } from '@ngrx/store-devtools';
4 | import { localStorageSync } from 'ngrx-store-localstorage';
5 | // import { StoreRouterConnectingModule, RouterStateSerializer } from '@ngrx/router-store';
6 |
7 | import { environment } from '@env/environment';
8 | import { EchoesState, EchoesReducers, EchoesActions } from './reducers';
9 | import { NavigationSerializer } from './router-store';
10 |
11 | // import { storeFreeze } from 'ngrx-store-freeze';
12 |
13 | export { EchoesState } from './reducers';
14 |
15 | export function localStorageSyncReducer(reducer: ActionReducer): ActionReducer {
16 | return localStorageSync({
17 | keys: Object.keys(EchoesReducers),
18 | rehydrate: true
19 | })(reducer);
20 | }
21 | const metaReducers: MetaReducer[] = [localStorageSyncReducer];
22 | const optionalImports = [];
23 | if (!environment.production) {
24 | // Note that you must instrument after importing StoreModule
25 | optionalImports.push(StoreDevtoolsModule.instrument({ maxAge: 25 }));
26 | }
27 |
28 | @NgModule({
29 | imports: [
30 | StoreModule.forRoot(EchoesReducers, { metaReducers }),
31 | // StoreRouterConnectingModule,
32 | ...optionalImports
33 | ],
34 | declarations: [],
35 | exports: [],
36 | providers: [
37 | ...EchoesActions,
38 | // { provide: RouterStateSerializer, useClass: NavigationSerializer }
39 | ]
40 | })
41 | export class CoreStoreModule { }
42 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/player-controls/player-controls.scss:
--------------------------------------------------------------------------------
1 | @import '~css/echoes-variables.scss';
2 |
3 | :host {
4 | --btn-color:var(--brand-inverse-text);
5 | --btn-control-primary-color: var(--brand-primary);
6 | $btn-control-font-size: 1.5rem;
7 | $btn-control-padding: 1.5rem 1.7rem;
8 | $btn-primary-font-size: 3rem;
9 |
10 | @mixin button-style () {
11 | border: none;
12 | background: transparent;
13 | color: var(--btn-color);
14 | border-radius: 0;
15 | outline: none;
16 | font-size: $btn-control-font-size;
17 | }
18 |
19 | .btn {
20 | &.next,
21 | &.previous,
22 | &.repeat {
23 | padding: $btn-control-padding;
24 | }
25 | }
26 | .btn {
27 | @include button-style();
28 | vertical-align: middle;
29 | line-height: 0;
30 |
31 | &:hover {
32 | @include button-style();
33 | }
34 |
35 | &.pause {
36 | display: none;
37 | }
38 |
39 | &.play,
40 | &.pause {
41 | border: 0;
42 | color: var(--btn-control-primary-color);
43 | padding: 0.9rem 1.4rem;
44 | margin: 0;
45 | font-size: $btn-primary-font-size;
46 | top: 3px;
47 | }
48 | }
49 |
50 | .show-player {
51 | transform: translatey(0);
52 | }
53 |
54 | .player-controls {
55 | padding: .5rem;
56 | }
57 | .play,
58 | .pause {
59 | mix-blend-mode: screen;
60 | }
61 |
62 | &.yt-playing .player-controls {
63 | .play {
64 | display: none;
65 | }
66 |
67 | .pause {
68 | display: inline-block;
69 | }
70 | }
71 |
72 | &.yt-repeat-on .player-controls {
73 | .repeat {
74 | color: var(--btn-control-primary-color);
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/core/effects/analytics.effects.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import 'rxjs/add/operator/switchMapTo';
3 | import 'rxjs/add/operator/switchMap';
4 |
5 | import { Injectable } from '@angular/core';
6 | import { Effect, Actions, toPayload } from '@ngrx/effects';
7 | import { UserProfileActions } from '@store/user-profile';
8 | import * as PlayerSearch from '@store/player-search';
9 | import { ActionTypes } from '@store/app-player';
10 | import { AnalyticsService } from '@core/services/analytics.service';
11 | import { EchoesState } from '@store/reducers';
12 |
13 | @Injectable()
14 | export class AnalyticsEffects {
15 | constructor(
16 | private actions$: Actions,
17 | private store: Store,
18 | private userProfileActions: UserProfileActions,
19 | private analytics: AnalyticsService
20 | ) { }
21 |
22 | @Effect({ dispatch: false })
23 | trackToken$ = this.actions$
24 | .ofType(UserProfileActions.USER_PROFILE_RECIEVED)
25 | .map(toPayload)
26 | .do(() => this.analytics.trackSignin());
27 |
28 | @Effect({ dispatch: false })
29 | trackSearch$ = this.actions$
30 | .ofType(PlayerSearch.PlayerSearchActions.SEARCH_NEW_QUERY, PlayerSearch.PlayerSearchActions.SEARCH_MORE_FOR_QUERY)
31 | .map(toPayload)
32 | .withLatestFrom(this.store.select(PlayerSearch.getSearchType))
33 | .do((states: any[]) => this.analytics.trackSearch(states[1].presets));
34 |
35 | @Effect({ dispatch: false })
36 | trackPlay$ = this.actions$
37 | .ofType(ActionTypes.PLAY_STARTED)
38 | .map(toPayload)
39 | .do(() => this.analytics.trackVideoPlay());
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/core/api/app.api.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { Injectable } from '@angular/core';
3 | import { EchoesState } from '@store/reducers';
4 |
5 | // Actions
6 | import * as AppLayout from '@store/app-layout';
7 | import * as RouterActions from '@store/router-store';
8 | import * as UserActions from '@store/user-profile';
9 |
10 | @Injectable()
11 | export class AppApi {
12 | themes$ = this.store.select(AppLayout.getAppThemes);
13 | appVersion$ = this.store.select(AppLayout.getAppVersion);
14 | user$ = this.store.select(UserActions.getUser);
15 |
16 | constructor(
17 | private store: Store
18 | ) { }
19 |
20 | toggleSidebar() {
21 | this.store.dispatch(new AppLayout.ToggleSidebar());
22 | }
23 |
24 | navigateBack() {
25 | this.store.dispatch(new RouterActions.Back());
26 | }
27 |
28 | updateVersion() {
29 | this.store.dispatch(new AppLayout.UpdateAppVersion());
30 | }
31 |
32 | checkVersion() {
33 | this.store.dispatch(new AppLayout.CheckVersion());
34 | }
35 |
36 | changeTheme(theme: string) {
37 | this.store.dispatch(new AppLayout.ThemeChange(theme));
38 | }
39 |
40 | notifyNewVersion(response) {
41 | this.store.dispatch(new AppLayout.RecievedAppVersion(response));
42 | }
43 |
44 | recievedNewVersion(response) {
45 | this.store.dispatch(new AppLayout.RecievedAppVersion(response));
46 | }
47 |
48 | // AUTHORIZATION
49 | signinUser() {
50 | this.store.dispatch(new UserActions.UserSignin());
51 | }
52 |
53 | signoutUser() {
54 | this.store.dispatch(new UserActions.UserSignout());
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playlist/now-playlist.scss:
--------------------------------------------------------------------------------
1 | @import '~css/echoes-variables.scss';
2 |
3 | @media (min-width: 320px) {
4 | $track-badge-index: 10;
5 | $track-button: 15;
6 | $track-border-color: #262829;
7 | --track-active-border-color: var(--brand-primary);
8 | --track-bg-active-color: var(--brand-dark-bg-color-transparent);
9 |
10 | .now-playlist {
11 | position: relative;
12 | // opacity: 0;
13 |
14 | .nav-list {
15 | overflow-x: hidden;
16 |
17 | > :not(.active) a:hover {
18 | background: rgba(10,10,10,.2);
19 | }
20 | }
21 |
22 | .now-playlist-track {
23 | cursor: pointer;
24 | opacity: 1;
25 |
26 | a .playlist-track__content {
27 | height: 6rem;
28 | }
29 | }
30 | // .as-sortable-placeholder {
31 | // background-color: $gray-darker;
32 | // border: 1px dashed white;
33 | // }
34 |
35 | }
36 |
37 | .ng-enter + .now-playlist.slide-down {
38 | transform: translatey(10px);
39 | }
40 | }
41 | @media (min-width: 768px) {
42 | .now-playlist {
43 | .now-playlist-track {
44 | a .playlist-track__content {
45 | overflow: hidden;
46 | }
47 | }
48 | }
49 | .closed now-playlist {
50 | .now-playlist-track {
51 | a,
52 | a.active {
53 | padding: 0;
54 | min-height: 6rem;
55 | margin: 0.7rem 0;
56 | }
57 | .now-playlist-track__trigger {
58 | padding: 0;
59 | }
60 | .playlist-track__content,
61 | .video-title {
62 | height: 0;
63 | }
64 | .track-number {
65 | left: 0;
66 | }
67 | .video-thumb {
68 | height: 5rem;
69 | width: 7rem;
70 | }
71 | .track-tracks {
72 | display: none;
73 | }
74 | .playlist-track {
75 | transform: translateX(5rem);
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/app/app.e2e.ts:
--------------------------------------------------------------------------------
1 | // import { browser, by, element } from 'protractor';
2 |
3 | // class EchoesAppPage {
4 | // navigateTo() {
5 | // return browser.get('/');
6 | // }
7 |
8 | // getTitle() {
9 | // return browser.getTitle();
10 | // }
11 |
12 | // getTitleInput() {
13 | // return element(by.css('input[formcontrolname=title]'));
14 | // }
15 |
16 | // getVideoResults() {
17 | // return element.all(by.css('youtube-videos youtube-list .youtube-list-item'));
18 | // }
19 |
20 | // // getTalkText(index: number) {
21 | // // return this.getTalks().get(index).getText();
22 | // // }
23 | // }
24 |
25 | // describe('Echoes App E2E tests', () => {
26 | // let page: EchoesAppPage;
27 |
28 | // beforeEach(() => {
29 | // page = new EchoesAppPage();
30 | // page.navigateTo();
31 | // });
32 |
33 |
34 | // // it('should have a title', () => {
35 | // // const actual = page.getTitle();
36 | // // const expected = 'Echoes Player - Open Source Media Player for Youtube';
37 | // // expect(actual).toEqual(expected);
38 | // // });
39 |
40 | // // it('should show 50 video search results', () => {
41 | // // let actual = page.getVideoResults().count();
42 | // // let expected = 50;
43 | // // expect(actual).toEqual(expected);
44 | // // });
45 |
46 | // // it('should have ', () => {
47 | // // let subject = element(by.css('app home')).isPresent();
48 | // // let result = true;
49 | // // expect(subject).toEqual(result);
50 | // // });
51 |
52 | // // it('should have buttons', () => {
53 | // // let subject = element(by.css('button')).getText();
54 | // // let result = 'Submit Value';
55 | // // expect(subject).toEqual(result);
56 | // // });
57 |
58 | // });
59 |
--------------------------------------------------------------------------------
/src/app/core/store/app-layout/app-layout.actions.ts:
--------------------------------------------------------------------------------
1 | import { Action } from '@ngrx/store';
2 |
3 | export class ActionTypes {
4 | static SIDEBAR_EXPAND = '[APP LAYOUT] SIDEBAR_EXPAND';
5 | static SIDEBAR_COLLAPSE = '[APP LAYOUT] SIDEBAR_COLLAPSE';
6 | static SIDEBAR_TOGGLE = '[APP LAYOUT] SIDEBAR_TOGGLE';
7 |
8 | static APP_VERSION_RECIEVED = '[APP] APP_VERSION_RECIEVED';
9 | static APP_UPDATE_VERSION = '[APP] APP_UPDATE_VERSION';
10 | static APP_CHECK_VERSION = '[APP] APP_CHECK_VERSION';
11 |
12 | static APP_THEME_CHANGE = '[App Theme] APP_THEME_CHANGE';
13 | }
14 | export class RecievedAppVersion implements Action {
15 | public type = ActionTypes.APP_VERSION_RECIEVED;
16 | constructor(public payload: any) { }
17 | }
18 | export class UpdateAppVersion implements Action {
19 | public type = ActionTypes.APP_UPDATE_VERSION;
20 | public payload = '';
21 | }
22 | export class CheckVersion implements Action {
23 | public type = ActionTypes.APP_CHECK_VERSION;
24 | public payload = '';
25 | }
26 | export class ExpandSidebar implements Action {
27 | public type = ActionTypes.SIDEBAR_EXPAND;
28 | public payload = true;
29 | }
30 |
31 | export class CollapseSidebar implements Action {
32 | public type = ActionTypes.SIDEBAR_COLLAPSE;
33 | public payload = false;
34 | }
35 |
36 | export class ToggleSidebar implements Action {
37 | public type = ActionTypes.SIDEBAR_TOGGLE;
38 | public payload = '';
39 | }
40 |
41 | export class ThemeChange implements Action {
42 | public type = ActionTypes.APP_THEME_CHANGE;
43 | constructor(public payload: string) { }
44 | }
45 |
46 | export type Action =
47 | | RecievedAppVersion
48 | | UpdateAppVersion
49 | | CheckVersion
50 | | ExpandSidebar
51 | | CollapseSidebar
52 | | ToggleSidebar
53 | | ThemeChange;
54 |
--------------------------------------------------------------------------------
/src/app/containers/user/user-player.service.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { Injectable } from '@angular/core';
3 | import { YoutubePlayerService, NowPlaylistService, UserProfile } from '@core/services';
4 | import { EchoesState } from '@core/store';
5 | import * as NowPlaylist from '@core/store/now-playlist';
6 | import * as AppPlayer from '@core/store/app-player';
7 |
8 | @Injectable()
9 | export class UserPlayerService {
10 | constructor(
11 | private nowPlaylistService: NowPlaylistService,
12 | private userProfile: UserProfile,
13 | private store: Store
14 | ) { }
15 |
16 | playSelectedPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
17 | this.userProfile
18 | .fetchPlaylistItems(playlist.id, '')
19 | .subscribe((items: GoogleApiYouTubeVideoResource[]) => {
20 | this.store.dispatch(new NowPlaylist.QueueVideos(items));
21 | this.nowPlaylistService.updateIndexByMedia(items[0].id);
22 | this.store.dispatch(new AppPlayer.LoadAndPlay(items[0]));
23 | });
24 | }
25 |
26 | queuePlaylist(playlist: GoogleApiYouTubePlaylistResource) {
27 | this.userProfile
28 | .fetchPlaylistItems(playlist.id, '')
29 | .subscribe((items: GoogleApiYouTubeVideoResource[]) => {
30 | this.store.dispatch(new NowPlaylist.QueueVideos(items));
31 | return items;
32 | });
33 | }
34 |
35 | queueVideo(media: GoogleApiYouTubeVideoResource) {
36 | this.store.dispatch(new NowPlaylist.QueueVideo(media));
37 | }
38 |
39 | playVideo(media: GoogleApiYouTubeVideoResource) {
40 | this.store.dispatch(new AppPlayer.LoadAndPlay(media));
41 | this.store.dispatch(new NowPlaylist.QueueVideo(media));
42 | this.store.dispatch(new NowPlaylist.SelectVideo(media));
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/shared/pipes/toFriendlyDuration.pipe.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | inject,
3 | async,
4 | } from '@angular/core/testing';
5 |
6 | import { ToFriendlyDurationPipe } from './toFriendlyDuration.pipe';
7 |
8 | describe('The toFriendlyDuration Pipe', () => {
9 | const pipe = new ToFriendlyDurationPipe();
10 |
11 | it('should render 3 dots when no valid value is provided', () => {
12 | const duration = undefined;
13 | const actual = pipe.transform(duration, []);
14 | const expected = '...';
15 | expect(actual).toBe(expected);
16 | });
17 |
18 | it('should render hour, no minutes and seconds', () => {
19 | const duration = 'PT2H33S';
20 | const actual = pipe.transform(duration, []);
21 | const expected = '02:00:33';
22 | expect(actual).toBe(expected);
23 | });
24 |
25 | it('should render hour, 2 digit minutes (less than 10) and seconds', () => {
26 | const duration = 'PT2H09M33S';
27 | const actual = pipe.transform(duration, []);
28 | const expected = '02:09:33';
29 | expect(actual).toBe(expected);
30 | });
31 |
32 | it('should render hour, 2 digit minutes (above 10) and seconds', () => {
33 | const duration = 'PT2H45M33S';
34 | const actual = pipe.transform(duration, []);
35 | const expected = '02:45:33';
36 | expect(actual).toBe(expected);
37 | });
38 |
39 | it('should render minutes and seconds', () => {
40 | const duration = 'PT4M1S';
41 | const actual = pipe.transform(duration, []);
42 | const expected = '04:01';
43 | expect(actual).toBe(expected);
44 | });
45 |
46 | it('should render just seconds', () => {
47 | const duration = 'PT35S';
48 | const actual = pipe.transform(duration, []);
49 | const expected = '00:35';
50 | expect(actual).toBe(expected);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/app/core/store/player-search/player-search.reducer.spec.ts:
--------------------------------------------------------------------------------
1 | import { inject, async } from '@angular/core/testing';
2 |
3 | import { search, IPlayerSearch } from './player-search.reducer';
4 | import * as SearchActions from './player-search.actions';
5 | import { YoutubeMediaItemsMock } from '@mocks/youtube.media.items';
6 |
7 | describe('The Player Search reducer', () => {
8 | const mockedState = (results = []): IPlayerSearch => ({
9 | query: '',
10 | filter: '',
11 | searchType: '',
12 | queryParams: {
13 | preset: '',
14 | duration: -1
15 | },
16 | presets: [],
17 | pageToken: {
18 | next: '',
19 | prev: ''
20 | },
21 | results: results ? results : [],
22 | isSearching: false
23 | });
24 |
25 | const playerSearchActions = new SearchActions.PlayerSearchActions();
26 |
27 | // it('should return current state when no valid actions have been made', () => {
28 | // const state = mockedState();
29 | // const actual = search(state, { type: 'INVALID_ACTION' });
30 | // const expected = state;
31 | // expect(actual).toEqual(expected);
32 | // });
33 |
34 | it('should ADD videos', () => {
35 | const state = mockedState();
36 | const youtubeMediaItems = YoutubeMediaItemsMock as any[];
37 | const actual = search(state, SearchActions.AddResultsAction.creator(youtubeMediaItems));
38 | const expected = [...state.results, ...YoutubeMediaItemsMock];
39 | expect(actual.results.length).toBe(expected.length);
40 | });
41 |
42 | it('should empty the state when RESET', () => {
43 | const state = mockedState([...YoutubeMediaItemsMock]);
44 | const actual = search(state, playerSearchActions.resetResults());
45 | const expected = 0;
46 | expect(actual.results.length).toEqual(expected);
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-media/youtube-media.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
2 | import { extractThumbUrl } from '@utils/media.utils';
3 |
4 | interface MediaStatus {
5 | queued: boolean;
6 | isPlaying: boolean;
7 | }
8 |
9 | @Component({
10 | selector: 'youtube-media',
11 | styleUrls: ['./youtube-media.scss'],
12 | templateUrl: './youtube-media.html',
13 | changeDetection: ChangeDetectionStrategy.OnPush
14 | })
15 | export class YoutubeMediaComponent implements OnChanges {
16 | @Input() media: GoogleApiYouTubeVideoResource;
17 | @Input() status: MediaStatus = {
18 | queued: false,
19 | isPlaying: false
20 | };
21 | @Output() play = new EventEmitter();
22 | @Output() queue = new EventEmitter();
23 | @Output() add = new EventEmitter();
24 | @Output() unqueue = new EventEmitter();
25 |
26 | showDesc = false;
27 | isPlaying = false;
28 | thumb = '';
29 |
30 | constructor() { }
31 |
32 | ngOnChanges({ media }: SimpleChanges) {
33 | if (media && !media.firstChange || media && media.firstChange) {
34 | this.thumb = extractThumbUrl(this.media);
35 | }
36 | }
37 |
38 | playVideo(media: GoogleApiYouTubeVideoResource) {
39 | this.play.emit(media);
40 | }
41 |
42 | queueVideo(media: GoogleApiYouTubeVideoResource) {
43 | this.queue.emit(media);
44 | }
45 |
46 | addVideo(media: GoogleApiYouTubeVideoResource) {
47 | this.add.emit(media);
48 | }
49 |
50 | toggle(showDesc: Boolean) {
51 | this.showDesc = !showDesc;
52 | }
53 |
54 | removeVideoFromQueue(media: GoogleApiYouTubeVideoResource) {
55 | this.unqueue.emit(media);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/core/store/user-profile/user-profile.reducer.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs/Observable';
2 | import { EchoesState } from '../';
3 | import { Action } from '@ngrx/store';
4 | import { UserProfileActions } from './user-profile.actions';
5 |
6 | export * from './user-profile.actions';
7 |
8 | export interface IUserProfile {
9 | access_token: string;
10 | playlists: GoogleApiYouTubePlaylistResource[];
11 | data?: {};
12 | nextPageToken?: string;
13 | profile: GoogleBasicProfile;
14 | viewedPlaylist?: string;
15 | }
16 |
17 | export interface GoogleBasicProfile {
18 | name?: string;
19 | imageUrl?: string;
20 | }
21 |
22 | const initialUserState: IUserProfile = {
23 | access_token: '',
24 | playlists: [],
25 | data: {},
26 | nextPageToken: '',
27 | profile: {},
28 | viewedPlaylist: ''
29 | };
30 | interface UnsafeAction extends Action {
31 | payload: any;
32 | }
33 | export function user(state = initialUserState, action: UnsafeAction): IUserProfile {
34 | switch (action.type) {
35 | case UserProfileActions.ADD_PLAYLISTS:
36 | return { ...state, playlists: [...state.playlists, ...action.payload] };
37 |
38 | case UserProfileActions.UPDATE_TOKEN:
39 | return { ...state, access_token: action.payload, playlists: [] };
40 |
41 | case UserProfileActions.USER_SIGNOUT_SUCCESS:
42 | return { ...initialUserState };
43 |
44 | case UserProfileActions.UPDATE:
45 | return { ...state, data: action.payload };
46 |
47 | case UserProfileActions.UPDATE_NEXT_PAGE_TOKEN:
48 | return { ...state, nextPageToken: action.payload };
49 |
50 | case UserProfileActions.UPDATE_USER_PROFILE:
51 | return { ...state, profile: action.payload };
52 |
53 | case UserProfileActions.VIEWED_PLAYLIST:
54 | return { ...state, viewedPlaylist: action.payload };
55 |
56 | default:
57 | return state;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/app/core/store/app-player/app-player.spec.ts:
--------------------------------------------------------------------------------
1 | import { inject, async } from '@angular/core/testing';
2 |
3 | import { player, IAppPlayer } from './app-player.reducer';
4 | import { ActionTypes } from './app-player.actions';
5 | import { YoutubeMediaMock } from '@mocks/youtube.media.item';
6 |
7 | describe('The App Player reducer', () => {
8 | const mockedState: IAppPlayer = {
9 | mediaId: { videoId: 'NONE' },
10 | index: 0,
11 | media: {},
12 | showPlayer: true,
13 | playerState: 0,
14 | fullscreen: {
15 | on: false,
16 | height: 0,
17 | width: 0
18 | },
19 | isFullscreen: false
20 | };
21 | it('should return current state when no valid actions have been made', () => {
22 | const state = { ...mockedState };
23 | const actual = player(state, { type: 'INVALID_ACTION', payload: {} });
24 | const expected = state;
25 | expect(actual).toEqual(expected);
26 | });
27 |
28 | it('should set the new media id by the new PLAYED youtube media item', () => {
29 | const state = { ...mockedState };
30 | const actual = player(state, { type: ActionTypes.PLAY, payload: YoutubeMediaMock });
31 | const expected = state;
32 | expect(actual.mediaId.videoId).toBe(YoutubeMediaMock.id.videoId);
33 | });
34 |
35 | it('should toggle visibility of the player', () => {
36 | const state = {
37 | ...mockedState,
38 | showPlayer: false
39 | };
40 | const actual = player(state, { type: ActionTypes.TOGGLE_PLAYER, payload: true });
41 | const expected = state;
42 | expect(actual.showPlayer).toBe(!expected.showPlayer);
43 | });
44 |
45 | it('should change the state of the player', () => {
46 | const state = {
47 | ...mockedState,
48 | playerState: 0
49 | };
50 | const actual = player(state, { type: ActionTypes.UPDATE_STATE, payload: 1 });
51 | const expected = state;
52 | expect(actual.playerState).toBe(1);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/player-search.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | $input-search-box-height: $input-height-base;
5 | $input-search-box-width: 25rem;
6 | $form-bg-color: $input-bg;
7 |
8 | player-search {
9 | display: inline-block;
10 |
11 | .form-search {
12 | float: none;
13 | position: relative;
14 | border: 0;
15 |
16 | .btn-submit {
17 | position: absolute;
18 | right: 0rem;
19 | top: 0;
20 | padding: 0.4rem 1rem 0.7rem 1rem;
21 | color: $navbar-default-link-active-color;
22 | }
23 |
24 | .dropdown-menu {
25 | box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 8px 0px;
26 | transform: translatey(0rem);
27 | max-height: none;
28 | background-color: white;
29 | opacity: 1;
30 | > li.active a {
31 | color: $inverse;
32 | }
33 | > li a {
34 | color: #444;
35 | line-height: 3;
36 | }
37 | }
38 |
39 | .form-group {
40 | background-color: $form-bg-color;
41 | border-radius: 3px;
42 | position: relative;
43 | }
44 |
45 | input.form-control {
46 | width: $input-search-box-width;
47 | height: $input-search-box-height;
48 | border: none;
49 | box-shadow: none;
50 | }
51 | }
52 |
53 | .results {
54 | position: absolute;
55 | top: $input-search-box-height;
56 | z-index: 10;
57 | box-shadow: 0rem 0.3rem 3rem -0.5rem rgba(0,0,0,.5);
58 |
59 | .list-group-item {
60 | border-radius: 0;
61 |
62 | &.active {
63 | background-color: var(--brand-primary);
64 | border-color: var(--brand-primary);
65 | }
66 | }
67 | }
68 | }
69 | }
70 |
71 | @media (min-width: 460px) {
72 | player-search {
73 | .form-search {
74 | input.form-control {
75 | display: inline-block;
76 | }
77 | .btn-submit {
78 | position: relative;
79 | top: 0;
80 | right: auto;
81 | }
82 | }
83 | }
84 | }
85 |
86 | @media (min-width: 768px) {
87 | player-search {
88 |
89 | }
90 | }
--------------------------------------------------------------------------------
/src/app/core/services/youtube-player.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NgZone } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { EchoesState } from '@store/reducers';
4 | import * as AppPlayer from '@store/app-player';
5 |
6 | @Injectable()
7 | export class YoutubePlayerService {
8 | public player: YT.Player;
9 |
10 | constructor(
11 | private store: Store,
12 | private zone: NgZone,
13 | private playerActions: AppPlayer.ActionTypes
14 | ) { }
15 |
16 | setupPlayer(player) {
17 | this.player = player;
18 | }
19 |
20 | play() {
21 | this.zone.runOutsideAngular(() => this.player.playVideo());
22 | }
23 |
24 | pause() {
25 | this.zone.runOutsideAngular(() => this.player.pauseVideo());
26 | }
27 |
28 | playVideo(media: GoogleApiYouTubeVideoResource, seconds?: number) {
29 | const id = media.id;
30 | const isLoaded = this.player.getVideoUrl().includes(id);
31 | if (!isLoaded) {
32 | this.zone.runOutsideAngular(() => this.player.loadVideoById(id, seconds || undefined));
33 | }
34 | this.play();
35 | }
36 |
37 | seekTo(seconds: number) {
38 | this.zone.runOutsideAngular(() => this.player.seekTo(seconds, true));
39 | }
40 |
41 | // Not in use
42 | onPlayerStateChange(event) {
43 | const state = event.data;
44 | // let autoNext = false;
45 | // play the next song if its not the end of the playlist
46 | // should add a "repeat" feature
47 | if (state === YT.PlayerState.ENDED) {
48 | // this.listeners.ended.forEach(callback => callback(state));
49 | }
50 |
51 | if (state === YT.PlayerState.PAUSED) {
52 | // service.playerState = YT.PlayerState.PAUSED;
53 | }
54 | if (state === YT.PlayerState.PLAYING) {
55 | // service.playerState = YT.PlayerState.PLAYING;
56 | }
57 | }
58 |
59 | setSize(height, width) {
60 | this.zone.runOutsideAngular(() => {
61 | this.player.setSize(width, height);
62 | });
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playlist/now-playlist-track.spec.ts:
--------------------------------------------------------------------------------
1 | import { By } from '@angular/platform-browser';
2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing';
3 | import { NO_ERRORS_SCHEMA } from '@angular/core';
4 | import { TooltipModule } from 'ngx-tooltip';
5 | import { PIPES } from '@shared/pipes';
6 | import { NowPlaylistTrackComponent } from './now-playlist-track.component';
7 | import { VideoMock, VideoMockWithSpecialChars } from '@mocks/now-playlist-track.mocks';
8 | import { MediaParserService } from '@core/services';
9 |
10 | describe('NowPlaylistTrackComponent', () => {
11 | let component: NowPlaylistTrackComponent;
12 | let fixture: ComponentFixture;
13 |
14 | function createComponent(video = VideoMock) {
15 | fixture = TestBed.createComponent(NowPlaylistTrackComponent);
16 | component = fixture.componentInstance;
17 | component.video = video;
18 | fixture.detectChanges();
19 | }
20 |
21 | beforeEach(async(() => {
22 | const mediaParserSpy = jasmine.createSpyObj('mediaParserSpy', [
23 | 'extractTracks', 'verifyTracksCue', 'extractTime', 'parseTracks'
24 | ]);
25 | TestBed.configureTestingModule({
26 | imports: [TooltipModule],
27 | declarations: [NowPlaylistTrackComponent, ...PIPES],
28 | schemas: [NO_ERRORS_SCHEMA],
29 | providers: [
30 | { provide: MediaParserService, useValue: mediaParserSpy },
31 | ]
32 | })
33 | .compileComponents();
34 | }));
35 |
36 | it('should create a component', () => {
37 | createComponent();
38 | expect(component).toBeDefined();
39 | });
40 |
41 | it('should select the track when title is clicked', () => {
42 | const trigger = fixture.debugElement.query(By.css('.video-title'));
43 | spyOn(component.select, 'emit');
44 | const actual = component.select.emit;
45 | trigger.triggerEventHandler('click', {});
46 | expect(actual).toHaveBeenCalled();
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/app/shared/components/playlist-viewer/playlist-cover.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeDetectionStrategy,
3 | Component,
4 | EventEmitter,
5 | Input,
6 | OnInit,
7 | Output,
8 | ViewEncapsulation
9 | } from '@angular/core';
10 |
11 | @Component({
12 | selector: 'playlist-cover',
13 | styleUrls: ['./playlist-cover.scss'],
14 | template: `
15 |
16 |
17 |
18 |
![]()
19 |
20 |
21 |
25 |
29 |
30 |
31 | `,
32 | changeDetection: ChangeDetectionStrategy.OnPush
33 | })
34 | export class PlaylistCoverComponent implements OnInit {
35 | @Input() playlist: GoogleApiYouTubePlaylistResource;
36 | @Output() play = new EventEmitter();
37 | @Output() queue = new EventEmitter();
38 |
39 | constructor() { }
40 |
41 | ngOnInit() { }
42 |
43 | get title() {
44 | return this.playlist && this.playlist.snippet
45 | ? this.playlist.snippet.title
46 | : '...';
47 | }
48 |
49 | get total() {
50 | return this.playlist && this.playlist.contentDetails
51 | ? this.playlist.contentDetails.itemCount
52 | : '...';
53 | }
54 |
55 | get thumbUrl() {
56 | const thumbnails = this.playlist && this.playlist.snippet.thumbnails;
57 | const sizes = ['default', 'medium'];
58 | return sizes.reduce((acc, size) => thumbnails.hasOwnProperty(size) && thumbnails[size].url, '');
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/shared/components/playlist-viewer/playlist-viewer.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, EventEmitter, Input, OnInit, Output, ChangeDetectionStrategy } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'playlist-viewer',
5 | styleUrls: ['./playlist-viewer.scss'],
6 | changeDetection: ChangeDetectionStrategy.OnPush,
7 | template: `
8 |
12 |
13 |
22 | `
23 | })
24 | export class PlaylistViewerComponent implements OnInit {
25 | @Input() videos: GoogleApiYouTubeVideoResource[] = [];
26 | @Input() playlist: GoogleApiYouTubePlaylistResource;
27 | @Input() queuedPlaylist = [];
28 |
29 | @Output() queuePlaylist = new EventEmitter();
30 | @Output() playPlaylist = new EventEmitter();
31 | @Output() queueVideo = new EventEmitter();
32 | @Output() playVideo = new EventEmitter();
33 | @Output() unqueueVideo = new EventEmitter();
34 |
35 | constructor() { }
36 |
37 | ngOnInit() {
38 | }
39 |
40 | onPlayPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
41 | this.playPlaylist.emit(playlist);
42 | }
43 |
44 | onQueueVideo(media: GoogleApiYouTubeVideoResource) {
45 | this.queueVideo.emit(media);
46 | }
47 |
48 | onPlayVideo(media: GoogleApiYouTubeVideoResource) {
49 | this.playVideo.emit(media);
50 | }
51 |
52 | onQueuePlaylist(playlist: GoogleApiYouTubePlaylistResource) {
53 | this.queuePlaylist.emit(playlist);
54 | }
55 |
56 | onRemove(media: GoogleApiYouTubeVideoResource) {
57 | this.unqueueVideo.emit(media);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/app/core/services/version-checker.service.ts:
--------------------------------------------------------------------------------
1 | import { Subscription } from 'rxjs/Subscription';
2 | import { HttpClient } from '@angular/common/http';
3 | import { Injectable, NgZone } from '@angular/core';
4 | import { Observable } from 'rxjs/Observable';
5 |
6 | import { AppApi } from '@api/app.api';
7 |
8 | import 'rxjs/add/operator/retry';
9 | import 'rxjs/add/observable/timer';
10 | import 'rxjs/add/observable/of';
11 |
12 | interface INpmPackageJson {
13 | version: number;
14 | [param: string]: any;
15 | }
16 |
17 | function verifyPackage(packageJson: INpmPackageJson) {
18 | return packageJson.hasOwnProperty('version');
19 | }
20 |
21 | @Injectable()
22 | export class VersionCheckerService {
23 | private interval = 1000 * 60 * 60;
24 | private protocol = 'https';
25 | private prefix = 'raw.githubusercontent.com';
26 | private repo = 'orizens/echoes-player';
27 | private repoBranch = 'gh-pages';
28 | private pathToFile = 'assets/package.json';
29 | public url = `${this.protocol}://${this.prefix}/${this.repo}/${this.repoBranch}/${this.pathToFile}`;
30 |
31 | constructor(private http: HttpClient,
32 | private zone: NgZone, private appApi: AppApi) { }
33 |
34 | check() {
35 | return this.http.get(this.url);
36 | }
37 |
38 | start() {
39 | let checkTimer: Subscription;
40 | this.zone.runOutsideAngular(() => {
41 | checkTimer = Observable.timer(0, this.interval)
42 | .switchMap(() => this.check())
43 | // .catch((err) => {
44 | // console.log(err);
45 | // return Observable.of(err);
46 | // })
47 | .retry()
48 | .filter(verifyPackage)
49 | .subscribe(response => this.appApi.recievedNewVersion(response));
50 | });
51 | return checkTimer;
52 | }
53 |
54 | updateVersion() {
55 | if (window) {
56 | window.location.reload(true);
57 | }
58 | }
59 |
60 | checkForVersion() {
61 | return this.check()
62 | .retry()
63 | .filter(verifyPackage)
64 | .take(1)
65 | .subscribe(response => this.appApi.notifyNewVersion(response));
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/youtube-videos.component.ts:
--------------------------------------------------------------------------------
1 | import * as PlayerSearch from '@core/store/player-search';
2 | import { Component, OnInit } from '@angular/core';
3 | import { Store } from '@ngrx/store';
4 | import { EchoesState } from '@core/store';
5 |
6 | // actions
7 | import { NowPlaylistActions } from '@core/store/now-playlist';
8 | import { ActionTypes } from '@core/store/app-player';
9 | import { AppPlayerApi } from '@core/api/app-player.api';
10 |
11 | // selectors
12 | import * as NowPlaylist from '@core/store/now-playlist';
13 |
14 | @Component({
15 | selector: 'youtube-videos',
16 | styleUrls: ['./youtube-videos.scss'],
17 | template: `
18 |
19 |
26 | `
27 | })
28 | export class YoutubeVideosComponent implements OnInit {
29 | videos$ = this.store.select(PlayerSearch.getPlayerSearchResults);
30 | playlistVideos$ = this.store.select(NowPlaylist.getPlaylistVideos);
31 | loading$ = this.store.select(PlayerSearch.getIsSearching);
32 |
33 | constructor(
34 | private store: Store,
35 | private appPlayerApi: AppPlayerApi,
36 | private playerSearchActions: PlayerSearch.PlayerSearchActions
37 | ) { }
38 |
39 | ngOnInit() {
40 | this.store.dispatch(this.playerSearchActions.updateSearchType(PlayerSearch.CSearchTypes.VIDEO));
41 | this.store.dispatch(this.playerSearchActions.searchCurrentQuery());
42 | }
43 |
44 | playSelectedVideo(media: GoogleApiYouTubeVideoResource) {
45 | this.appPlayerApi.playVideo(media);
46 | }
47 |
48 | queueSelectedVideo(media: GoogleApiYouTubeVideoResource) {
49 | this.appPlayerApi.queueVideo(media);
50 | }
51 |
52 | removeVideoFromPlaylist(media: GoogleApiYouTubeVideoResource) {
53 | this.appPlayerApi.removeVideoFromPlaylist(media);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playlist-filter/now-playlist-filter.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | :host {
5 | .user-playlists-filter {
6 | --filter-bg: var(--sidebar-bg);
7 | --filter-darker-bg: var(--sidebar-bg-secondary);
8 |
9 | position: relative;
10 | background-color: var(--filter-bg);
11 | padding-top: 0.5rem;
12 | padding-bottom: 0.5rem;
13 | padding-left: 1.5rem;
14 | font-size: 10px;
15 |
16 | .playlist-count,
17 | .playlist-filter {
18 | color: var(--sidebar-text-color);
19 | }
20 |
21 | &.nav-header {
22 | box-shadow: -1rem -0.2rem 2rem #000 !important;
23 | line-height: 26px;
24 | color: var(--sidebar-text-color);
25 | text-transform: uppercase;
26 | font-size: 10px;
27 | }
28 |
29 | .btn-clear {
30 | color: $alizarin;
31 |
32 | &:disabled {
33 | color: grey;
34 | }
35 | }
36 | .playlist-header {
37 | cursor: pointer;
38 | }
39 | .playlist-filter {
40 | position: absolute;
41 | right: 0;
42 | top: 0.6rem;
43 | background: var(--filter-darker-bg);
44 | padding-left: 5px;
45 |
46 | input {
47 | background: var(--filter-darker-bg);
48 | border: none;
49 | padding: 0 3px;
50 | margin-bottom: 0;
51 | }
52 | }
53 | }
54 | }
55 | }
56 | @media (min-width: 768px) {
57 | :host {
58 | .text.btn-transparent {
59 | cursor: pointer;
60 | }
61 | .user-playlists-filter {
62 | .closed & {
63 | .playlist-filter {
64 | transform: translateX(-100rem);
65 | }
66 | }
67 | }
68 | .closed & {
69 | .text,
70 | .btn-save,
71 | .playlist-count,
72 | .btn-clear {
73 | display: none;
74 | }
75 |
76 | .playlist-header {
77 | font-size: 2rem;
78 | }
79 |
80 | .nav-header.user-playlists-filter {
81 | padding: 0 1rem;
82 | text-align: center;
83 | }
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/app/core/components/app-brand/app-brand.scss:
--------------------------------------------------------------------------------
1 | @import '~css/core/global.scss';
2 |
3 | @media (min-width: 320px) {
4 | :host {
5 | .brand-container {
6 | $brand-icon-font-size: 30px;
7 | $brand-icon-line-height: 2;
8 | $brand-icon-margin: 0 0.3rem;
9 | $brand-padding: 0.5rem 1.5rem;
10 |
11 | padding: $brand-padding;
12 | margin: 0;
13 | text-transform: uppercase;
14 | position: relative;
15 | display: flex;
16 |
17 | .brand-text {
18 | flex: 1;
19 | display: flex;
20 | flex-direction: row;
21 |
22 | .brand-text-item {
23 | margin: 0;
24 | transition: flex, opacity 0.3s ease-in;
25 | line-height: $brand-icon-line-height;
26 | }
27 | }
28 |
29 | .brand-icon {
30 | font-size: $brand-icon-font-size;
31 | margin: $brand-icon-margin;
32 | color: $clouds;
33 | padding-top: 9px;
34 | flex: 0 0 0%;
35 |
36 | &.brand-text-item {
37 | line-height: 1 !important;
38 | }
39 |
40 | .closed & {
41 | flex: 0;
42 | }
43 | }
44 |
45 | .text {
46 | flex: 0 1 0%;
47 | }
48 |
49 | .sidebar-toggle {
50 | transition: transform;
51 | transform: translateX(37.5rem);
52 | color: var(--brand-primary);
53 |
54 | .closed & {
55 | position: absolute;
56 | transform: translateY(-4rem);
57 | }
58 | }
59 | }
60 | }
61 | }
62 | @media (min-width: 768px) {
63 | :host {
64 | .brand-container {
65 | .sidebar-toggle {
66 | transform: translateX(0);
67 | color: var(--brand-inverse-text);
68 | }
69 |
70 | .brand-icon {
71 | .closed & {
72 | flex: 1 1 100%;
73 | }
74 | }
75 | }
76 | }
77 | }
78 |
79 | @media (min-width: 1024px) {
80 | :host {
81 | .brand-container {
82 | .closed & {
83 | text-align: center;
84 | }
85 | .text {
86 | .closed & {
87 | flex: 0 1 00%;
88 | width: 0;
89 | overflow: hidden;
90 | opacity: 0;
91 | }
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/app/containers/app-navbar/app-navbar-menu/app-navbar-menu.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { NO_ERRORS_SCHEMA } from '@angular/core';
2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing';
3 |
4 | import { AppNavbarMenuComponent } from './app-navbar-menu.component';
5 |
6 | describe('AppNavbarMenuComponent', () => {
7 | let component: AppNavbarMenuComponent;
8 | let fixture: ComponentFixture;
9 |
10 | beforeEach(async(() => {
11 | TestBed.configureTestingModule({
12 | declarations: [AppNavbarMenuComponent],
13 | schemas: [NO_ERRORS_SCHEMA]
14 | })
15 | .compileComponents();
16 | }));
17 |
18 | beforeEach(() => {
19 | fixture = TestBed.createComponent(AppNavbarMenuComponent);
20 | component = fixture.componentInstance;
21 | fixture.detectChanges();
22 | });
23 |
24 | it('should create a navbar-menu component', () => {
25 | expect(component).toBeTruthy();
26 | });
27 |
28 | it('should hide menu by default', () => {
29 | expect(component.hide).toBeTruthy();
30 | });
31 |
32 | it('should not be signed-in by default', () => {
33 | expect(component.signedIn).toBeFalsy();
34 | });
35 |
36 | it('should hide the menu', () => {
37 | component.hideMenu();
38 | const actual = component.hide;
39 | const expected = true;
40 | expect(actual).toBe(expected);
41 | });
42 |
43 | it('should toggle the menu visibility', () => {
44 | const initialState = component.hide;
45 | component.toggleMenu();
46 | const actual = component.hide;
47 | const expected = !initialState;
48 | expect(actual).toBe(expected);
49 | });
50 |
51 | it('should hide the menu on ESC key pressed', () => {
52 | const mockedKeyboardEvent = {
53 | keyCode: 27 // ESC key code
54 | };
55 | component.handleKeyPress(mockedKeyboardEvent);
56 | const actual = component.hide;
57 | const expected = true;
58 | expect(actual).toBe(expected);
59 | });
60 |
61 | it('should emit a sign out event when user signs out', () => {
62 | spyOn(component.signOut, 'emit');
63 | component.handleSignOut();
64 | expect(component.signOut.emit).toHaveBeenCalledTimes(1);
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/src/app/shared/components/youtube-list/youtube-list.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeDetectionStrategy,
3 | Component,
4 | EventEmitter,
5 | Input,
6 | OnChanges,
7 | Output,
8 | SimpleChanges
9 | } from '@angular/core';
10 | import { fadeInAnimation } from '@animations/fade-in.animation';
11 |
12 | function createIdMap(list: GoogleApiYouTubeVideoResource[]) {
13 | return list.reduce((acc, cur) => {
14 | acc[cur.id] = true;
15 | return acc;
16 | }, {});
17 | }
18 |
19 | @Component({
20 | selector: 'youtube-list',
21 | styleUrls: ['./youtube-list.scss'],
22 | animations: [fadeInAnimation],
23 | template: `
24 |
36 | `,
37 | changeDetection: ChangeDetectionStrategy.OnPush
38 | })
39 | export class YoutubeListComponent implements OnChanges {
40 | @Input() list: GoogleApiYouTubeVideoResource[] = [];
41 | @Input() queued: GoogleApiYouTubeVideoResource[] = [];
42 | @Output() play = new EventEmitter();
43 | @Output() queue = new EventEmitter();
44 | @Output() add = new EventEmitter();
45 | @Output() unqueue = new EventEmitter();
46 |
47 | queuedMediaIdMap = {};
48 |
49 | constructor() { }
50 |
51 | ngOnChanges({ queued }: SimpleChanges) {
52 | if (queued && queued.currentValue) {
53 | this.queuedMediaIdMap = createIdMap(queued.currentValue);
54 | }
55 | }
56 |
57 | playSelectedVideo(media) {
58 | this.play.emit(media);
59 | }
60 |
61 | queueSelectedVideo(media) {
62 | this.queue.emit(media);
63 | }
64 |
65 | addVideo(media) {
66 | this.add.emit(media);
67 | }
68 |
69 | unqueueSelectedVideo(media) {
70 | this.unqueue.emit(media);
71 | }
72 |
73 | getMediaStatus(media: GoogleApiYouTubeVideoResource) {
74 | return {
75 | queued: this.queuedMediaIdMap[media.id]
76 | };
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/app/core/services/youtube-api.service.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Testing a Service
3 | * More info: https://angular.io/docs/ts/latest/guide/testing.html
4 | */
5 | import { TestBed, async, inject } from '@angular/core/testing';
6 | import { YoutubeApiService } from './youtube-api.service';
7 |
8 | describe('YoutubeApiService', () => {
9 | let authSpy;
10 |
11 | beforeEach(() => {
12 | authSpy = jasmine.createSpyObj('authSpy', ['signIn']);
13 | authSpy.accessToken = 'testing';
14 | });
15 |
16 | it('should reset config when instansiated', () => {
17 | spyOn(YoutubeApiService.prototype, 'resetConfig').and.callThrough();
18 | const service = new YoutubeApiService({}, authSpy);
19 | const actual = service.resetConfig;
20 | const expected = 1;
21 | expect(actual).toHaveBeenCalledTimes(expected);
22 | });
23 |
24 |
25 | it('should create authorization header when accessToken exists', () => {
26 | const token = 'mocked-token-for-test';
27 | const service = new YoutubeApiService({}, authSpy);
28 | authSpy.accessToken = token;
29 | const actual = service.createHeaders()['Authorization'];
30 | const expected = token;
31 | expect(actual).toContain(expected);
32 | });
33 |
34 |
35 | it('should set url, http and idKey by the options', () => {
36 | const options = {
37 | url: 'mocked-url',
38 | http: {},
39 | idKey: 'mocked-idkey'
40 | };
41 | const service = new YoutubeApiService(options, authSpy);
42 | expect(service.url).toMatch(options.url);
43 | expect(service.idKey).toMatch(options.idKey);
44 | });
45 |
46 | it('should set configuration when instansiated', () => {
47 | spyOn(YoutubeApiService.prototype, 'setConfig').and.callThrough();
48 | const options = {
49 | url: 'mocked-url',
50 | http: {},
51 | idKey: 'mocked-idkey',
52 | config: {
53 | mine: 'true'
54 | }
55 | };
56 | const service = new YoutubeApiService(options, authSpy);
57 | const actual = service.setConfig;
58 | const expected = 1;
59 | expect(actual).toHaveBeenCalledTimes(expected);
60 | });
61 |
62 | it('should check that the token exists', () => {
63 | const token = 'fake token';
64 | const service = new YoutubeApiService({}, authSpy);
65 | const actual = service.hasToken();
66 | expect(actual).toBeTruthy();
67 | });
68 |
69 | });
70 |
--------------------------------------------------------------------------------
/src/app/core/components/app-player/player-controls/player-controls.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | EventEmitter,
4 | Input,
5 | OnInit,
6 | Output,
7 | HostBinding
8 | } from '@angular/core';
9 |
10 | @Component({
11 | selector: 'player-controls',
12 | styleUrls: ['./player-controls.scss'],
13 | template: `
14 |
15 |
22 |
23 | `
24 | })
25 | export class PlayerControlsComponent {
26 | @Input() media: GoogleApiYouTubeVideoResource;
27 | @HostBinding('class.yt-repeat-on')
28 | @Input()
29 | isRepeat = false;
30 | @HostBinding('class.yt-playing')
31 | @Input()
32 | playing = false;
33 | @Output() play = new EventEmitter();
34 | @Output() pause = new EventEmitter();
35 | @Output() previous = new EventEmitter();
36 | @Output() next = new EventEmitter();
37 | @Output() repeat = new EventEmitter();
38 |
39 | controls = [
40 | {
41 | title: 'previous',
42 | icon: 'step-backward',
43 | handler: this.handlePrevious,
44 | feature: 'previous'
45 | },
46 | {
47 | title: 'pause',
48 | icon: 'pause',
49 | handler: this.handlePause,
50 | feature: 'pause'
51 | },
52 | {
53 | title: 'play',
54 | icon: 'play',
55 | handler: this.handlePlay,
56 | feature: 'play'
57 | },
58 | {
59 | title: 'play next track',
60 | icon: 'step-forward',
61 | handler: this.handleNext,
62 | feature: 'next'
63 | },
64 | {
65 | title: 'repeate playlist',
66 | icon: 'refresh',
67 | handler: this.handleRepeat,
68 | feature: 'repeat'
69 | }
70 | ];
71 |
72 | handlePlay() {
73 | this.play.emit(this.media);
74 | }
75 |
76 | handlePrevious() {
77 | this.previous.emit();
78 | }
79 |
80 | handlePause() {
81 | this.pause.emit();
82 | }
83 |
84 | handleNext() {
85 | this.next.emit();
86 | }
87 |
88 | handleRepeat() {
89 | this.repeat.emit();
90 | }
91 |
92 | handleControl(control) {
93 | control.handler.call(this);
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/app/containers/playlist-view/playlist-view.proxy.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { Injectable } from '@angular/core';
3 | import 'rxjs/add/operator/let';
4 |
5 | import { EchoesState } from '@core/store';
6 | import * as NowPlaylist from '@core/store/now-playlist';
7 |
8 | import { ActivatedRoute, Data } from '@angular/router';
9 | import { UserProfileActions } from '@core/store/user-profile';
10 | import { AppPlayerApi } from '@api/app-player.api';
11 | import { AppApi } from '@api/app.api';
12 |
13 | import * as RouterActions from '@core/store/router-store';
14 |
15 | export interface PlaylistData {
16 | videos: GoogleApiYouTubeVideoResource[];
17 | playlist: GoogleApiYouTubePlaylistResource;
18 | }
19 |
20 | @Injectable()
21 | export class PlaylistProxy {
22 | nowPlaylist$ = this.store.select(NowPlaylist.getPlaylistVideos);
23 |
24 | constructor(
25 | public store: Store,
26 | private userProfileActions: UserProfileActions,
27 | private appPlayerApi: AppPlayerApi,
28 | private appApi: AppApi
29 | ) { }
30 |
31 | goBack() {
32 | this.appApi.navigateBack();
33 | }
34 |
35 | toRouteData(route: ActivatedRoute) {
36 | return route.data;
37 | }
38 |
39 | fetchPlaylist(route: ActivatedRoute) {
40 | return this.toRouteData(route).map((data: PlaylistData) => data.playlist);
41 | }
42 |
43 | fetchPlaylistVideos(route: ActivatedRoute) {
44 | return this.toRouteData(route).map((data: PlaylistData) => data.videos);
45 | }
46 |
47 | fetchPlaylistHeader(route: ActivatedRoute) {
48 | return this.fetchPlaylist(route).map((playlist: GoogleApiYouTubePlaylistResource) => {
49 | const { snippet, contentDetails } = playlist;
50 | return `${snippet.title} (${contentDetails.itemCount} videos)`;
51 | });
52 | }
53 |
54 | playPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
55 | this.appPlayerApi.playPlaylist(playlist);
56 | }
57 |
58 | queuePlaylist(playlist: GoogleApiYouTubePlaylistResource) {
59 | this.appPlayerApi.queuePlaylist(playlist);
60 | }
61 |
62 | queueVideo(media: GoogleApiYouTubeVideoResource) {
63 | this.appPlayerApi.queueVideo(media);
64 | }
65 |
66 | playVideo(media: GoogleApiYouTubeVideoResource) {
67 | this.appPlayerApi.playVideo(media);
68 | }
69 |
70 | unqueueVideo(media: GoogleApiYouTubeVideoResource) {
71 | this.appPlayerApi.removeVideoFromPlaylist(media);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/app-search.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { EchoesState } from '@core/store';
4 |
5 | import { NowPlaylistActions } from '@core/store/now-playlist';
6 | // selectors
7 | import * as UserProfile from '@core/store/user-profile';
8 | import * as PlayerSearch from '@core/store/player-search';
9 |
10 | @Component({
11 | selector: 'app-search',
12 | styleUrls: ['./app-search.scss'],
13 | template: `
14 |
20 |
21 |
28 |
33 |
34 |
35 |
36 |
37 | `
38 | })
39 | export class AppSearchComponent implements OnInit {
40 | query$ = this.store.select(PlayerSearch.getQuery);
41 | currentPlaylist$ = this.store.select(UserProfile.getUserViewPlaylist);
42 | queryParamPreset$ = this.store.select(PlayerSearch.getQueryParamPreset);
43 | presets$ = this.store.select(PlayerSearch.getPresets);
44 |
45 | constructor(
46 | private store: Store,
47 | private playerSearchActions: PlayerSearch.PlayerSearchActions
48 | ) { }
49 |
50 | ngOnInit() { }
51 |
52 | search(query: string) {
53 | this.store.dispatch(this.playerSearchActions.searchNewQuery(query));
54 | }
55 |
56 | resetPageToken(query: string) {
57 | this.store.dispatch(this.playerSearchActions.resetPageToken());
58 | this.store.dispatch(new PlayerSearch.UpdateQueryAction(query));
59 | }
60 |
61 | searchMore() {
62 | this.store.dispatch(this.playerSearchActions.searchMoreForQuery());
63 | }
64 |
65 | updatePreset(preset: PlayerSearch.IPresetParam) {
66 | this.store.dispatch(this.playerSearchActions.updateQueryParam({ preset: preset.value }));
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/core/api/app-player.api.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@ngrx/store';
2 | import { Actions, Effect, toPayload } from '@ngrx/effects';
3 |
4 | import { Injectable } from '@angular/core';
5 | import { EchoesState } from '@store/reducers';
6 | import * as AppPlayer from '@store/app-player';
7 | import * as NowPlaylist from '@store/now-playlist';
8 | import { NowPlaylistEffects } from '@core/effects/now-playlist.effects';
9 |
10 | import 'rxjs/add/operator/take';
11 |
12 | @Injectable()
13 | export class AppPlayerApi {
14 | constructor(
15 | private store: Store,
16 | private nowPlaylistEffects: NowPlaylistEffects,
17 | private nowPlaylistActions: NowPlaylist.NowPlaylistActions
18 | ) { }
19 |
20 | playPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
21 | this.nowPlaylistEffects.playPlaylistFirstTrack$
22 | .map(toPayload)
23 | .take(1)
24 | .subscribe((media: GoogleApiYouTubeVideoResource) => this.playVideo(media));
25 | this.queuePlaylist(playlist);
26 | }
27 |
28 | queuePlaylist(playlist: GoogleApiYouTubePlaylistResource) {
29 | this.store.dispatch(new NowPlaylist.LoadPlaylistAction(playlist.id));
30 | }
31 |
32 | playVideo(media: GoogleApiYouTubeVideoResource) {
33 | this.store.dispatch(new AppPlayer.LoadAndPlay(media));
34 | this.store.dispatch(new NowPlaylist.SelectVideo(media));
35 | }
36 |
37 | queueVideo(media: GoogleApiYouTubeVideoResource) {
38 | this.store.dispatch(new NowPlaylist.QueueVideo(media));
39 | }
40 |
41 | removeVideoFromPlaylist(media: GoogleApiYouTubeVideoResource) {
42 | this.store.dispatch(new NowPlaylist.RemoveVideo(media));
43 | }
44 |
45 | pauseVideo() {
46 | this.store.dispatch(new AppPlayer.PauseVideo());
47 | }
48 |
49 | togglePlayer() {
50 | this.store.dispatch(new AppPlayer.TogglePlayer(true));
51 | }
52 |
53 | toggleFullScreen() {
54 | this.store.dispatch(new AppPlayer.FullScreen());
55 | }
56 |
57 | toggleRepeat() {
58 | this.store.dispatch(new NowPlaylist.ToggleRepeat());
59 | }
60 |
61 | resetPlayer() {
62 | this.store.dispatch(new AppPlayer.Reset());
63 | }
64 |
65 | setupPlayer(player) {
66 | this.store.dispatch(new AppPlayer.SetupPlayer(player));
67 | }
68 |
69 | changePlayerState(event: YT.OnStateChangeEvent) {
70 | this.store.dispatch(new AppPlayer.PlayerStateChange(event));
71 | this.store.dispatch(new NowPlaylist.PlayerStateChange(event));
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/polyfills.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file includes polyfills needed by Angular and is loaded before the app.
3 | * You can add your own extra polyfills to this file.
4 | *
5 | * This file is divided into 2 sections:
6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
8 | * file.
9 | *
10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
13 | *
14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
15 | */
16 |
17 | /***************************************************************************************************
18 | * BROWSER POLYFILLS
19 | */
20 |
21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/
22 | // import 'core-js/es6/symbol';
23 | // import 'core-js/es6/object';
24 | // import 'core-js/es6/function';
25 | // import 'core-js/es6/parse-int';
26 | // import 'core-js/es6/parse-float';
27 | // import 'core-js/es6/number';
28 | // import 'core-js/es6/math';
29 | // import 'core-js/es6/string';
30 | // import 'core-js/es6/date';
31 | // import 'core-js/es6/array';
32 | // import 'core-js/es6/regexp';
33 | // import 'core-js/es6/map';
34 | // import 'core-js/es6/set';
35 |
36 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */
37 | // import 'classlist.js'; // Run `npm install --save classlist.js`.
38 |
39 | /** IE10 and IE11 requires the following to support `@angular/animation`. */
40 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`.
41 |
42 |
43 | /** Evergreen browsers require these. **/
44 | import 'core-js/es7/reflect';
45 |
46 |
47 | /** ALL Firefox browsers require the following to support `@angular/animation`. **/
48 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`.
49 |
50 |
51 |
52 | /***************************************************************************************************
53 | * Zone JS is required by Angular itself.
54 | */
55 | import 'zone.js/dist/zone'; // Included with Angular CLI.
56 |
57 |
58 |
59 | /***************************************************************************************************
60 | * APPLICATION IMPORTS
61 | */
62 |
--------------------------------------------------------------------------------
/src/app/containers/playlist-view/playlist-view.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from '@angular/core';
2 | import { ActivatedRoute } from '@angular/router';
3 | import { EchoesState } from '@core/store';
4 | import { Store } from '@ngrx/store';
5 | import { Observable } from 'rxjs/Observable';
6 | import { UserProfileActions } from '@core/store/user-profile';
7 | import { NowPlaylistActions, LoadPlaylistAction, PlayPlaylistAction } from '@core/store/now-playlist';
8 |
9 | import { PlaylistProxy } from './playlist-view.proxy';
10 |
11 | @Component({
12 | selector: 'playlist-view',
13 | styleUrls: ['./playlist-view.component.scss'],
14 | template: `
15 |
16 |
19 |
20 |
32 |
33 | `
34 | })
35 | export class PlaylistViewComponent implements OnInit {
36 | playlist$ = this.playlistProxy.fetchPlaylist(this.route);
37 | videos$ = this.playlistProxy.fetchPlaylistVideos(this.route);
38 | header$ = this.playlistProxy.fetchPlaylistHeader(this.route);
39 | nowPlaylist$ = this.playlistProxy.nowPlaylist$;
40 |
41 | constructor(private playlistProxy: PlaylistProxy, private route: ActivatedRoute) { }
42 |
43 | ngOnInit() { }
44 |
45 | playPlaylist(playlist: GoogleApiYouTubePlaylistResource) {
46 | this.playlistProxy.playPlaylist(playlist);
47 | }
48 |
49 | queuePlaylist(playlist: GoogleApiYouTubePlaylistResource) {
50 | this.playlistProxy.queuePlaylist(playlist);
51 | }
52 |
53 | queueVideo(media: GoogleApiYouTubeVideoResource) {
54 | this.playlistProxy.queueVideo(media);
55 | }
56 |
57 | playVideo(media: GoogleApiYouTubeVideoResource) {
58 | this.playlistProxy.playVideo(media);
59 | }
60 |
61 | unqueueVideo(media: GoogleApiYouTubeVideoResource) {
62 | this.playlistProxy.unqueueVideo(media);
63 | }
64 |
65 | handleBack() {
66 | this.playlistProxy.goBack();
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/core/services/media-parser.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestBed, inject } from '@angular/core/testing';
2 |
3 | import { MediaParserService } from './media-parser.service';
4 | import * as Mocks from '@mocks/now-playlist-track.mocks';
5 |
6 | describe('MediaParserService', () => {
7 | let service: MediaParserService;
8 |
9 | beforeEach(() => {
10 | TestBed.configureTestingModule({
11 | providers: [MediaParserService]
12 | });
13 | });
14 |
15 | beforeEach(inject([MediaParserService], (s: MediaParserService) => {
16 | service = s;
17 | }));
18 |
19 | it('should extract tracks from a description with tracks', () => {
20 | const tracks = service.extractTracks(Mocks.VideoMock);
21 | const actual = tracks.length;
22 | const expected = 0;
23 | expect(actual).toBeGreaterThan(expected);
24 | });
25 |
26 | it('extract tracks from a description with chars: [].?', () => {
27 | const tracks = service.extractTracks(Mocks.VideoMockWithSpecialChars);
28 | const actual = tracks.length;
29 | const expected = 0;
30 | expect(actual).toBeGreaterThan(expected);
31 | });
32 |
33 | describe('Tracks Cue Validation', () => {
34 | it('should verify tracks cues are valid', () => {
35 | const tracks = service.extractTracks(Mocks.VideoMock);
36 | const isValid = service.verifyTracksCue(tracks);
37 | expect(isValid).toBeTruthy();
38 | });
39 |
40 | it('should verify tracks are NOY cued correctly', () => {
41 | const tracks = service.extractTracks(Mocks.VideoMockWithTracksLengthOnly);
42 | const isValid = service.verifyTracksCue(tracks);
43 | expect(isValid).toBeFalsy();
44 | });
45 | });
46 |
47 | describe('Time Conversion', () => {
48 | it('should convert "06:30" to 420 seconds', () => {
49 | const time = '06:30';
50 | const actual = service.toNumber(time);
51 | const expected = (6 * 60) + 30;
52 | expect(actual).toBe(expected);
53 | });
54 |
55 | it('should convert "6:30" to 420 seconds', () => {
56 | const time = '06:30';
57 | const actual = service.toNumber(time);
58 | const expected = (6 * 60) + 30;
59 | expect(actual).toBe(expected);
60 | });
61 |
62 | it('should convert "1:30:30" to 420 seconds', () => {
63 | const time = '1:30:30';
64 | const actual = service.toNumber(time);
65 | const expected = (1 * 60 * 60) + (30 * 60) + 30;
66 | expect(actual).toBe(expected);
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/app/core/services/youtube.search.spec.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Testing a Service
3 | * More info: https://angular.io/docs/ts/latest/guide/testing.html
4 | */
5 | import { TestBed, inject, } from '@angular/core/testing';
6 | import { HttpClientModule } from '@angular/common/http';
7 | import { PlayerSearchActions } from '@store/player-search';
8 | import { YoutubeDataApi } from './youtube-data-api';
9 | import { YoutubeSearch } from './youtube.search';
10 |
11 | describe('Youtube Search Service', () => {
12 | let service: YoutubeSearch;
13 | let youtubeDataApiSpy: YoutubeDataApi;
14 |
15 | beforeEach(() => {
16 | youtubeDataApiSpy = jasmine.createSpyObj('youtubeDataApiSpy',
17 | ['list', 'delete', 'insert', 'update']
18 | );
19 |
20 | TestBed.configureTestingModule({
21 | imports: [HttpClientModule],
22 | providers: [
23 | YoutubeSearch,
24 | PlayerSearchActions,
25 | { provide: YoutubeDataApi, useValue: youtubeDataApiSpy },
26 | ]
27 | });
28 | });
29 |
30 | // instantiation through framework injection
31 | beforeEach(inject([YoutubeSearch], (youtubeSearch) => {
32 | service = youtubeSearch;
33 | }));
34 |
35 | it('should have a search method', () => {
36 | const actual = service.search;
37 | expect(actual).toBeDefined();
38 | });
39 |
40 | it('should perform search with the api', () => {
41 | const actual = youtubeDataApiSpy.list;
42 | service.search('ozrics');
43 | expect(actual).toHaveBeenCalled();
44 | });
45 |
46 | it('should search with same value when searching more', () => {
47 | const query = 'ozrics';
48 | const nextPageToken = 'fdsaf#42441';
49 | service.search(query);
50 | service.searchMore(nextPageToken);
51 | service.search(query);
52 | const actual = youtubeDataApiSpy.list;
53 | const expected = {
54 | part: 'snippet,id',
55 | q: query,
56 | type: 'video',
57 | pageToken: nextPageToken
58 | };
59 | expect(actual).toHaveBeenCalledWith('search', expected);
60 | });
61 |
62 | it('should reset the page token', () => {
63 | const query = 'ozrics';
64 | service.searchMore('fakePageToken$#@$$!');
65 | service.resetPageToken();
66 | service.search(query);
67 | const actual = youtubeDataApiSpy.list;
68 | const expected = {
69 | part: 'snippet,id',
70 | q: query,
71 | type: 'video',
72 | pageToken: ''
73 | };
74 | expect(actual).toHaveBeenCalledWith('search', expected);
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/src/app/core/services/media-parser.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 |
3 | @Injectable()
4 | export class MediaParserService {
5 |
6 | private HH_MM_SSre = /(\d{1,2}):\d{2}:?\d{0,2}/;
7 | private LINE_WITH_TRACKre = /([a-zA-Z \S\d]){0,}(\d{1,2}:\d{2}:{0,1}\d{0,2})+([a-zA-Z \S]){0,}/;
8 |
9 | constructor() { }
10 |
11 | extractTracks(media: GoogleApiYouTubeVideoResource) {
12 | // const re = /(([0-9]{0,1}[0-9]):([0-9][0-9]){0,1}:{0,1}([0-9][0-9]){0,1}\s*)([\w\s/]*[^ 0-9:/\n\b])/;
13 | const LINE_WITH_TRACKre = /([a-zA-Z \S\d]){0,}(\d{1,2}:\d{2}:{0,1}\d{0,2})+([a-zA-Z \S]){0,}/;
14 | const hasTracksRegexp = new RegExp(LINE_WITH_TRACKre, 'gmi');
15 | const tracks = media.snippet.description.match(hasTracksRegexp);
16 | // make sure there's a first track
17 | if (tracks && tracks.length && !tracks[0].includes('00:0')) {
18 | tracks.unshift('00:00');
19 | }
20 | return tracks;
21 | }
22 |
23 | extractTime(track: string) {
24 | const HH_MM_SSre = this.HH_MM_SSre;
25 | const title = track.match(HH_MM_SSre);
26 | return title;
27 | }
28 |
29 | verifyTracksCue(tracks: string[]) {
30 | const HH_MM_SSre = this.HH_MM_SSre;
31 | const isCueValid = tracks
32 | .map((track: string) => this.extractTime(track))
33 | .every((track, index, arr) => {
34 | const prev = index > 0 ? this.toNumber(arr[index - 1][0]) : false;
35 | const current = this.toNumber(track[0]);
36 | return prev ? current > prev : true;
37 | });
38 | return isCueValid;
39 | }
40 |
41 | parseTracks(tracks: string[] = []) {
42 | let _tracks = [];
43 | const isFormatValid = this.verifyTracksCue(tracks);
44 | if (isFormatValid && tracks) {
45 | const re = this.HH_MM_SSre;
46 | _tracks = tracks
47 | .filter((track: string) => {
48 | const isTrack = re.test(track);
49 | return isTrack;
50 | });
51 | }
52 | return _tracks;
53 | }
54 |
55 | /**
56 | * converts time format of HH:MM:SS to seconds
57 | * @param time string
58 | */
59 | toNumber (time: string): number {
60 | const timeUnitRatio = {
61 | '3': 60 * 60, // HH
62 | '2': 60, // MM
63 | '1': 1
64 | };
65 | return time.split(':').reverse()
66 | .map((num: string) => parseInt(num, 10))
67 | .reduce((acc: number, current: number, index: number, arr: number[]) => {
68 | return acc + (current * +timeUnitRatio[index + 1]);
69 | }, 0);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/app/containers/app-search/youtube-playlists.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { EchoesState } from '@core/store';
4 |
5 | // actions
6 | import { NowPlaylistActions, LoadPlaylistAction, PlayPlaylistAction } from '@core/store/now-playlist';
7 | import { ActionTypes } from '@core/store/app-player';
8 | // selectors
9 | import * as PlayerSearch from '@core/store/player-search';
10 | import { AppPlayerApi } from '@core/api/app-player.api';
11 |
12 | import { fadeInAnimation } from '@shared/animations/fade-in.animation';
13 |
14 | @Component({
15 | selector: 'youtube-playlists',
16 | styles: [
17 | `
18 | :host .youtube-items-container {
19 | display: flex;
20 | flex-direction: row;
21 | flex-wrap: wrap;
22 | justify-content: center;
23 | }
24 | `
25 | ],
26 | animations: [fadeInAnimation],
27 | template: `
28 |
29 |
41 | `
42 | })
43 | export class YoutubePlaylistsComponent implements OnInit {
44 | results$ = this.store.select(PlayerSearch.getPlayerSearchResults);
45 | isSearching$ = this.store.select(PlayerSearch.getIsSearching);
46 |
47 | constructor(
48 | private store: Store,
49 | private nowPlaylistActions: NowPlaylistActions,
50 | private appPlayerActions: ActionTypes,
51 | private playerSearchActions: PlayerSearch.PlayerSearchActions,
52 | private appPlayerApi: AppPlayerApi
53 | ) { }
54 |
55 | ngOnInit() {
56 | this.store.dispatch(this.playerSearchActions.updateSearchType(PlayerSearch.CSearchTypes.PLAYLIST));
57 | this.store.dispatch(PlayerSearch.PlayerSearchActions.PLAYLISTS_SEARCH_START.creator());
58 | }
59 |
60 | playPlaylist(media: GoogleApiYouTubePlaylistResource) {
61 | // this.store.dispatch(new PlayPlaylistAction(media.id));
62 | this.appPlayerApi.playPlaylist(media);
63 | }
64 |
65 | queueSelectedPlaylist(media: GoogleApiYouTubePlaylistResource) {
66 | // this.store.dispatch(new LoadPlaylistAction(media.id));
67 | this.appPlayerApi.queuePlaylist(media);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/core/store/player-search/player-search.reducer.ts:
--------------------------------------------------------------------------------
1 | import { Action } from '@ngrx/store';
2 | import { PlayerSearchActions, AddResultsAction } from './player-search.actions';
3 | import { IPlayerSearch, CSearchTypes, CPresetTypes } from './player-search.interfaces';
4 |
5 | export * from './player-search.interfaces';
6 |
7 | const initialState: IPlayerSearch = {
8 | query: '',
9 | filter: '',
10 | searchType: CSearchTypes.VIDEO,
11 | queryParams: {
12 | preset: '',
13 | duration: -1
14 | },
15 | presets: [
16 | { label: 'Any', value: '' },
17 | { label: 'Albums', value: CPresetTypes.FULL_ALBUMS },
18 | { label: 'Live', value: CPresetTypes.LIVE }
19 | ],
20 | pageToken: {
21 | next: '',
22 | prev: ''
23 | },
24 | isSearching: false,
25 | results: []
26 | };
27 | interface UnsafeAction extends Action {
28 | payload: any;
29 | }
30 |
31 | export function search(state: IPlayerSearch = initialState, action: UnsafeAction): IPlayerSearch {
32 | switch (action.type) {
33 | case PlayerSearchActions.UPDATE_QUERY: {
34 | return { ...state, query: action.payload };
35 | }
36 |
37 | case PlayerSearchActions.SEARCH_NEW_QUERY:
38 | return {
39 | ...state,
40 | query: action.payload,
41 | isSearching: true
42 | };
43 |
44 | case PlayerSearchActions.UPDATE_QUERY_PARAM:
45 | const queryParams = { ...state.queryParams, ...action.payload };
46 | return { ...state, queryParams };
47 |
48 | case PlayerSearchActions.SEARCH_RESULTS_RETURNED:
49 | const { nextPageToken, prevPageToken } = action.payload;
50 | const statePageToken = state.pageToken;
51 | const pageToken = {
52 | next: nextPageToken || statePageToken.next,
53 | prev: prevPageToken || statePageToken.prev
54 | };
55 | return { ...state, pageToken };
56 |
57 | case PlayerSearchActions.SEARCH_STARTED:
58 | return { ...state, isSearching: true };
59 |
60 | case AddResultsAction.type:
61 | return AddResultsAction.handler(state, action.payload);
62 |
63 | case PlayerSearchActions.RESET_RESULTS:
64 | return { ...state, results: [] };
65 |
66 | case PlayerSearchActions.SEARCH_TYPE_UPDATE: {
67 | return {
68 | ...state,
69 | searchType: action.payload
70 | };
71 | }
72 | case PlayerSearchActions.PLAYLISTS_SEARCH_START.action: {
73 | return { ...state, isSearching: true };
74 | }
75 |
76 | default:
77 | // upgrade policy - for when the initialState has changed
78 | return { ...initialState, ...state };
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/app/core/components/now-playing/now-playing.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core';
2 | import { Observable } from 'rxjs/Observable';
3 | import { Store } from '@ngrx/store';
4 |
5 | import { EchoesState } from '@store/reducers';
6 | import { NowPlaylistService } from '@core/services/now-playlist.service';
7 | import { INowPlaylist } from '@store/now-playlist';
8 | import * as AppPlayer from '@store/app-player/app-player.actions';
9 | import { NowPlaylistComponent } from './now-playlist';
10 |
11 | @Component({
12 | selector: 'now-playing',
13 | styleUrls: ['./now-playing.scss'],
14 | template: `
15 |
30 | `,
31 | // (sort)="sortVideo($event)"
32 | changeDetection: ChangeDetectionStrategy.OnPush
33 | })
34 | export class NowPlayingComponent implements OnInit {
35 | public nowPlaylist$: Observable;
36 | @ViewChild(NowPlaylistComponent) nowPlaylistComponent: NowPlaylistComponent;
37 |
38 | constructor(public store: Store, public nowPlaylistService: NowPlaylistService) { }
39 |
40 | ngOnInit() {
41 | this.nowPlaylist$ = this.nowPlaylistService.playlist$;
42 | }
43 |
44 | selectVideo(media: GoogleApiYouTubeVideoResource) {
45 | this.store.dispatch(new AppPlayer.PlayVideo(media));
46 | this.nowPlaylistService.updateIndexByMedia(media.id);
47 | }
48 |
49 | sortVideo() { }
50 |
51 | updateFilter(searchFilter: string) {
52 | this.nowPlaylistService.updateFilter(searchFilter);
53 | }
54 |
55 | resetFilter() {
56 | this.nowPlaylistService.updateFilter('');
57 | }
58 |
59 | clearPlaylist() {
60 | this.nowPlaylistService.clearPlaylist();
61 | }
62 |
63 | removeVideo(media) {
64 | this.nowPlaylistService.removeVideo(media);
65 | }
66 |
67 | onHeaderClick() {
68 | this.nowPlaylistComponent.scrollToActiveTrack();
69 | }
70 |
71 | selectTrackInVideo(trackEvent: { time: string; media: GoogleApiYouTubeVideoResource }) {
72 | this.store.dispatch(new AppPlayer.PlayVideo(trackEvent.media));
73 | this.nowPlaylistService.seekToTrack(trackEvent);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/app/core/services/now-playlist.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { Observable } from 'rxjs/Observable';
4 | import { EchoesState } from '@store/reducers';
5 | import * as NowPlaylist from '@store/now-playlist';
6 | import { YoutubeVideosInfo } from './youtube-videos-info.service';
7 |
8 | @Injectable()
9 | export class NowPlaylistService {
10 | public playlist$: Observable;
11 |
12 | constructor(
13 | public store: Store,
14 | private youtubeVideosInfo: YoutubeVideosInfo,
15 | private nowPlaylistActions: NowPlaylist.NowPlaylistActions
16 | ) {
17 | this.playlist$ = this.store.select(NowPlaylist.getNowPlaylist);
18 | }
19 |
20 | queueVideo(mediaId: string) {
21 | return this.youtubeVideosInfo.api.list(mediaId).map(items => items[0]);
22 | }
23 |
24 | queueVideos(medias: GoogleApiYouTubeVideoResource[]) {
25 | this.store.dispatch(new NowPlaylist.QueueVideos(medias));
26 | }
27 |
28 | removeVideo(media) {
29 | this.store.dispatch(new NowPlaylist.RemoveVideo(media));
30 | }
31 |
32 | selectVideo(media) {
33 | this.store.dispatch(new NowPlaylist.SelectVideo(media));
34 | }
35 |
36 | updateFilter(filter: string) {
37 | this.store.dispatch(new NowPlaylist.FilterChange(filter));
38 | }
39 |
40 | clearPlaylist() {
41 | this.store.dispatch(new NowPlaylist.RemoveAll());
42 | }
43 |
44 | selectNextIndex() {
45 | this.store.dispatch(new NowPlaylist.SelectNext());
46 | }
47 |
48 | selectPreviousIndex() {
49 | this.store.dispatch(new NowPlaylist.SelectPrevious());
50 | }
51 |
52 | trackEnded() {
53 | this.store.dispatch(new NowPlaylist.MediaEnded());
54 | }
55 |
56 | getCurrent() {
57 | let media;
58 | this.playlist$.take(1).subscribe(playlist => {
59 | media = playlist.videos.find(video => video.id === playlist.selectedId);
60 | });
61 | return media;
62 | }
63 |
64 | updateIndexByMedia(mediaId: string) {
65 | this.store.dispatch(new NowPlaylist.UpdateIndexByMedia(mediaId));
66 | }
67 |
68 | isInLastTrack(): boolean {
69 | let nowPlaylist: NowPlaylist.INowPlaylist;
70 | this.playlist$.take(1).subscribe(_nowPlaylist => (nowPlaylist = _nowPlaylist));
71 | const currentVideoId = nowPlaylist.selectedId;
72 | const indexOfCurrentVideo = nowPlaylist.videos.findIndex(video => video.id === currentVideoId);
73 | const isCurrentLast = indexOfCurrentVideo + 1 === nowPlaylist.videos.length;
74 | return isCurrentLast;
75 | }
76 |
77 | seekToTrack(trackEvent) {
78 | this.store.dispatch(this.nowPlaylistActions.seekTo(trackEvent));
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/app/core/effects/app-player.effects.ts:
--------------------------------------------------------------------------------
1 | import { NowPlaylistService } from '@core/services';
2 | import { Store } from '@ngrx/store';
3 | import { EchoesState } from '@store/reducers';
4 | import { Injectable } from '@angular/core';
5 | import { Effect, Actions, toPayload } from '@ngrx/effects';
6 |
7 | import { of } from 'rxjs/observable/of';
8 | import { Observable } from 'rxjs/Observable';
9 | import { defer } from 'rxjs/observable/defer';
10 | import 'rxjs/add/operator/catch';
11 |
12 | import * as AppPlayer from '@store/app-player';
13 | import { YoutubePlayerService } from '@core/services/youtube-player.service';
14 | import { YoutubeVideosInfo } from '@core/services/youtube-videos-info.service';
15 |
16 | @Injectable()
17 | export class AppPlayerEffects {
18 | constructor(
19 | public actions$: Actions,
20 | public store: Store,
21 | public youtubePlayerService: YoutubePlayerService,
22 | public youtubeVideosInfo: YoutubeVideosInfo
23 | ) { }
24 |
25 | @Effect() init$ = defer(() => of(new AppPlayer.ResetFullScreen()));
26 |
27 | @Effect()
28 | playVideo$ = this.actions$
29 | .ofType(AppPlayer.ActionTypes.PLAY)
30 | .map(toPayload)
31 | .switchMap(media =>
32 | of(this.youtubePlayerService.playVideo(media)).map((video: any) => new AppPlayer.PlayStarted(video))
33 | );
34 |
35 | @Effect({ dispatch: false })
36 | pauseVideo$ = this.actions$
37 | .ofType(AppPlayer.ActionTypes.PAUSE)
38 | .do(() => this.youtubePlayerService.pause());
39 |
40 | @Effect()
41 | loadAndPlay$ = this.actions$
42 | .ofType(AppPlayer.ActionTypes.LOAD_AND_PLAY)
43 | .map(toPayload)
44 | .switchMap((media: any) =>
45 | this.youtubeVideosInfo
46 | .fetchVideoData(media.id || media.id.videoId)
47 | .map((video: any) => new AppPlayer.PlayVideo(video))
48 | )
49 | .catch(() => of({ type: 'LOAD_AND_PLAY_ERROR' }));
50 |
51 | @Effect({ dispatch: false })
52 | toggleFullscreen$ = this.actions$
53 | .ofType(AppPlayer.ActionTypes.FULLSCREEN)
54 | .withLatestFrom(this.store.select(AppPlayer.getPlayerFullscreen))
55 | .do((states: [any, { on; height; width }]) =>
56 | this.youtubePlayerService.setSize(states[1].height, states[1].width)
57 | );
58 |
59 | @Effect({ dispatch: false })
60 | setupPlayer$ = this.actions$
61 | .ofType(AppPlayer.ActionTypes.SETUP_PLAYER)
62 | .map(toPayload)
63 | .do((player) => this.youtubePlayerService.setupPlayer(player));
64 |
65 | @Effect()
66 | playerStateChange$ = this.actions$
67 | .ofType(AppPlayer.ActionTypes.PLAYER_STATE_CHANGE)
68 | .map(toPayload)
69 | .map((event: YT.OnStateChangeEvent) => new AppPlayer.UpdateState(event.data));
70 | }
71 |
--------------------------------------------------------------------------------