├── 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 |
20 | 21 |
22 |
23 |
24 |
-------------------------------------------------------------------------------- /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 | 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 |
10 |
11 | 17 | 18 |
19 |
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 | 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 |
14 | 21 |
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 |
    25 |
  • 26 | 33 | 34 |
  • 35 |
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 |
21 | 31 |
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 |
30 |
31 | 38 | 39 |
40 |
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 | --------------------------------------------------------------------------------