...
 
Commits (39)
......@@ -229,7 +229,7 @@ review:start:
image: minds/helm-eks:latest
script:
- aws eks update-kubeconfig --name=sandbox
- git clone --branch=sandbox-wip https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/minds/helm-charts.git
- git clone --branch=master https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/minds/helm-charts.git
- "helm upgrade \
--install \
--reuse-values \
......
......@@ -19,6 +19,7 @@
"assets": ["src/assets", "src/favicon.ico"],
"styles": [
"node_modules/material-design-lite/dist/material.blue_grey-amber.min.css",
"node_modules/plyr/dist/plyr.css",
"node_modules/material-design-icons/iconfont/material-icons.css",
"src/main.css"
],
......
......@@ -116,18 +116,47 @@ context('Discovery', () => {
cy.get("m-topbar--navigation--options ul > m-nsfw-selector ul > li:contains('Other')").click();
});
it('should allow the user to filter by a single hashtag', () => {
it('should allow the user to turn off single hashtag filter and view all posts', () => {
cy.visit('/newsfeed/global/top');
cy.get('m-hashtagssidebarselector__item')
.first()
.click();
});
it('should allow the user to turn off single hashtag filter and view all posts', () => {
it.skip('should allow the user to toggle a single hashtag and then toggle back to the initial feed', () => {
cy.visit('/newsfeed/global/top');
cy.get('m-hashtagssidebarselector__item')
.first()
.find('.m-hashtagsSidebarSelectorList__visibility > i')
.click();
})
// get first label value
cy.get('.m-hashtagsSidebarSelectorList__label').first().invoke('text').then((text) => {
// repeat twice to capture full cycle.
Cypress._.times(2, (i) => {
// split hashtag off of label text
let label = text.split('#')[1];
// click switch
toggleFirstVisibilitySwitch();
// check location name has updated
cy.location('pathname')
.should('eq', `/newsfeed/global/top;period=12h;hashtag=${label}`);
// click switch
toggleFirstVisibilitySwitch();
// check location name has updated
cy.location('pathname')
.should('eq', `/newsfeed/global/top;period=12h`);
});
});
});
// click first visibility switch
const toggleFirstVisibilitySwitch = () => {
cy.get('m-hashtagssidebarselector__item')
.first()
.find('.m-hashtagsSidebarSelectorList__visibility > i')
.click();
}
})
......@@ -661,4 +661,56 @@ context('Newsfeed', () => {
});
});
// enable once failing tests are fixed
it.skip('should post an nsfw activity when value is held by the selector (is blue) but it has not been clicked yet', () => {
// click on nsfw dropdown
cy.get(
'minds-newsfeed-poster m-nsfw-selector .m-dropdown--label-container'
).click();
// select Nudity
cy.get('minds-newsfeed-poster m-nsfw-selector .m-dropdownList__item')
.contains('Nudity')
.click();
// click away
cy.get('minds-newsfeed-poster m-nsfw-selector .minds-bg-overlay').click();
// navigate away from newsfeed and back.
cy.get('[data-cy=data-minds-nav-wallet-button]').first().click(); // bottom bar exists, so take first child
cy.get('[data-cy=data-minds-nav-newsfeed-button]').first().click();
newActivityContent('This is a nsfw post');
postActivityAndAwaitResponse(200);
// should have the mature text toggle
cy.get(
'.minds-list > minds-activity:first-child .message .m-mature-text-toggle'
).should('not.have.class', 'mdl-color-text--red-500');
cy.get(
'.minds-list > minds-activity:first-child .message .m-mature-message-content'
).should('have.class', 'm-mature-text');
// click the toggle
cy.get(
'.minds-list > minds-activity:first-child .message .m-mature-text-toggle'
).click();
// text should be visible now
cy.get(
'.minds-list > minds-activity:first-child .message .m-mature-text-toggle'
).should('have.class', 'mdl-color-text--red-500');
cy.get(
'.minds-list > minds-activity:first-child .message .m-mature-message-content'
).should('not.have.class', 'm-mature-text');
cy.get(
'.minds-list > minds-activity:first-child .message .m-mature-message-content'
).contains('This is a nsfw post');
deleteActivityFromNewsfeed();
});
});
This diff is collapsed.
This diff is collapsed.
......@@ -32,25 +32,31 @@
{{ hoverInfo.date | utcDate | date: datePipe }}
</div>
<div
[ngSwitch]="rawData?.unit"
*ngFor="let value of hoverInfo.values"
class="m-chartV2__hoverInfo__row--primary"
>
<ng-template ngSwitchCase="number">
{{ hoverInfo.value | number: '1.0-0' | abbr }}
{{ rawData.label | lowercase }}
</ng-template>
<ng-template ngSwitchCase="usd">
{{ hoverInfo.value | currency }} USD
</ng-template>
<ng-template ngSwitchCase="eth">
{{ hoverInfo.value | number: '1.3-3' }} ETH
</ng-template>
<ng-template ngSwitchCase="tokens">
{{ hoverInfo.value | number: '1.1-3' }} tokens
</ng-template>
<ng-template ngSwitchDefault>
{{ hoverInfo.value | number: '1.0-3' }} {{ rawData?.unit }}
</ng-template>
<span
class="m-chartV2__hoverInfoRow__hex"
[style.background-color]="value.color"
></span>
<ng-container [ngSwitch]="rawData?.unit">
<ng-template ngSwitchCase="number">
{{ value.value | number: '1.0-0' | abbr }}
{{ value.label | lowercase }}
</ng-template>
<ng-template ngSwitchCase="usd">
{{ value.value | currency }} USD
</ng-template>
<ng-template ngSwitchCase="eth">
{{ value.value | number: '1.3-3' }} ETH
</ng-template>
<ng-template ngSwitchCase="tokens">
{{ value.value | number: '1.1-3' }} Tokens
</ng-template>
<ng-template ngSwitchDefault>
{{ value.value | number: '1.0-3' }} {{ rawData?.unit }}
</ng-template>
</ng-container>
</div>
<div class="m-chartV2__hoverInfo__row" *ngIf="isComparison">
vs
......
......@@ -58,6 +58,14 @@ m-chartV2 {
}
}
.m-chartV2__hoverInfoRow__hex {
width: 6px;
height: 6px;
display: inline-block;
margin-right: 2px;
border-radius: 50%;
}
.m-chartV2__hoverInfo__closeBtn {
display: none;
font-size: 15px;
......
......@@ -76,11 +76,12 @@ export class ChartV2Component implements OnInit, OnDestroy {
? this.rawData.visualisation.segments.slice(0, 1)
: this.rawData.visualisation.segments;
if (this.segments.length === 2) {
this.isComparison = true;
// this.isComparison = true;
// Reverse the segments so comparison line is layered behind current line
this.segments.reverse();
// this.segments.reverse();
// Current line should be blue, not grey
this.swapSegmentColors();
// this.swapSegmentColors();
this.detectChanges();
}
this.themeSubscription = this.themeService.isDark$.subscribe(isDark => {
this.isDark = isDark;
......@@ -131,7 +132,9 @@ export class ChartV2Component implements OnInit, OnDestroy {
this.segments.forEach((segment, index) => {
const segmentMarkerFills = [];
for (let i = 0; i < this.pointsPerSegment; i++) {
segmentMarkerFills[i] = this.getColor('m-white');
segmentMarkerFills[i] = this.getColor(
chartPalette.segmentColorIds[index]
);
}
this.markerFills.push(segmentMarkerFills);
});
......@@ -169,12 +172,12 @@ export class ChartV2Component implements OnInit, OnDestroy {
y: this.unpack(this.segments[i].buckets, 'value'),
};
if (this.segments[i].comparison) {
segment.line.dash = 'dot';
}
this.data[i] = segment;
});
if (this.isComparison) {
this.data[0].line.dash = 'dot';
}
}
setLayout() {
......@@ -252,7 +255,7 @@ export class ChartV2Component implements OnInit, OnDestroy {
onHover($event) {
this.hoverPoint = $event.points[0].pointIndex;
this.addMarkerFill();
this.emptyMarkerFill();
if (!this.isMini) {
this.showShape($event);
}
......@@ -265,7 +268,7 @@ export class ChartV2Component implements OnInit, OnDestroy {
}
onUnhover($event) {
this.emptyMarkerFill();
this.addMarkerFill();
this.hideShape();
this.hoverInfoDiv.style.opacity = 0;
this.detectChanges();
......@@ -306,24 +309,37 @@ export class ChartV2Component implements OnInit, OnDestroy {
}
populateHoverInfo() {
const pt = this.isComparison ? 1 : 0;
// TODO: format value strings here and remove ngSwitch from template?
this.hoverInfo['date'] = this.segments[pt].buckets[this.hoverPoint].date;
this.hoverInfo['date'] = this.segments[0].buckets[this.hoverPoint].date;
this.hoverInfo['value'] =
this.rawData.unit !== 'usd'
? this.segments[pt].buckets[this.hoverPoint].value
: this.segments[pt].buckets[this.hoverPoint].value / 100;
if (this.isComparison && this.segments[1]) {
this.hoverInfo['comparisonValue'] =
this.rawData.unit !== 'usd'
? this.segments[0].buckets[this.hoverPoint].value
: this.segments[0].buckets[this.hoverPoint].value / 100;
this.hoverInfo['comparisonDate'] = this.segments[0].buckets[
this.hoverPoint
].date;
? this.segments[0].buckets[this.hoverPoint].value
: this.segments[0].buckets[this.hoverPoint].value / 100;
this.hoverInfo['values'] = [];
for (const pt in this.segments) {
const segment = this.segments[pt];
this.hoverInfo['values'][pt] = {
value:
this.rawData.unit !== 'usd'
? segment.buckets[this.hoverPoint].value
: segment.buckets[this.hoverPoint].value / 100,
label: segment.label || this.rawData.label,
color: this.getColor(chartPalette.segmentColorIds[pt]),
};
}
// if (this.isComparison && this.segments[1]) {
// this.hoverInfo['comparisonValue'] =
// this.rawData.unit !== 'usd'
// ? this.segments[0].buckets[this.hoverPoint].value
// : this.segments[0].buckets[this.hoverPoint].value / 100;
//
// this.hoverInfo['comparisonDate'] = this.segments[0].buckets[
// this.hoverPoint
// ].date;
// }
}
positionHoverInfo($event) {
......
......@@ -14,7 +14,7 @@ m-shadowboxHeader.isScrollable {
position: relative;
transition: all 0.3s ease;
@include m-theme() {
border-top: 1px solid rgba(themed($m-grey-50), 0.5);
border-top: 1px solid rgba(themed($m-grey-200), 0.4);
background-color: themed($m-white);
}
}
......
......@@ -37,7 +37,11 @@
<h3>{{ menu.header.label }}</h3>
</div>
<nav class="m-sidebarMenu__linksContainer" *ngIf="menu.links">
<nav
class="m-sidebarMenu__linksContainer"
*ngIf="menu.links"
data-minds="sidebarMenuLinks"
>
<div class="m-sidebarMenu__link" *ngFor="let link of menu.links">
<ng-container *ngIf="link.permissionGranted">
<ng-container *ngIf="!link.newWindow">
......
......@@ -56,10 +56,10 @@ export class SortSelectorComponent implements OnInit, OnDestroy, AfterViewInit {
id: '30d',
label: '30d',
},
/*{
{
id: '1y',
label: '1y'
},*/
label: '1y',
},
];
customTypes: Array<{ id; label; icon? }> = [
......
......@@ -43,7 +43,6 @@
<li
class="m-dropdownList__item m-user-menuDropdown__Item"
(click)="closeMenu()"
*ngIf="getCurrentUser()?.pro"
>
<a routerLink="/analytics/dashboard/traffic">
<i class="material-icons">timeline</i>
......
......@@ -5,6 +5,7 @@
routerLinkActive="m-v2-topbarNav__Item--active"
title="Newsfeed"
i18n-title
data-cy="data-minds-nav-newsfeed-button"
>
<i class="material-icons">home</i>
<span class="m-v2-topbarNavItem__Text" i18n>Newsfeed</span>
......@@ -16,6 +17,7 @@
routerLinkActive="m-v2-topbarNav__Item--active"
title="Discovery"
i18n-title
data-cy="data-minds-nav-discovery-button"
>
<i class="material-icons">search</i>
<span class="m-v2-topbarNavItem__Text" i18n>Discovery</span>
......@@ -27,6 +29,7 @@
routerLinkActive="m-v2-topbarNav__Item--active"
title="Wallet"
i18n-title
data-cy="data-minds-nav-wallet-button"
>
<i class="material-icons">account_balance</i>
<span class="m-v2-topbarNavItem__Text" i18n>Wallet</span>
......
......@@ -16,6 +16,8 @@ import { switchMap, map, tap, first } from 'rxjs/operators';
export class FeedsService {
limit: BehaviorSubject<number> = new BehaviorSubject(12);
offset: BehaviorSubject<number> = new BehaviorSubject(0);
fallbackAt: number | null = null;
fallbackAtIndex: BehaviorSubject<number | null> = new BehaviorSubject(null);
pageSize: Observable<number>;
pagingToken: string = '';
canFetchMore: boolean = true;
......@@ -50,6 +52,22 @@ export class FeedsService {
.setCastToActivities(this.castToActivities)
.getFromFeed(feed)
),
tap(feed => {
if (feed.length && this.fallbackAt) {
for (let i = 0; i < feed.length; i++) {
const entity: any = feed[i].getValue();
if (
entity &&
entity.time_created &&
entity.time_created < this.fallbackAt
) {
this.fallbackAtIndex.next(i);
break;
}
}
}
}),
tap(feed => {
if (feed.length)
// We should have skipped but..
......@@ -143,6 +161,8 @@ export class FeedsService {
response.entities = response.activity;
}
if (response.entities.length) {
this.fallbackAt = response['fallback_at'];
this.fallbackAtIndex.next(null);
this.rawFeed.next(this.rawFeed.getValue().concat(response.entities));
this.pagingToken = response['load-next'];
} else {
......@@ -168,6 +188,8 @@ export class FeedsService {
* To clear data.
*/
clear(): FeedsService {
this.fallbackAt = null;
this.fallbackAtIndex.next(null);
this.offset.next(0);
this.pagingToken = '';
this.rawFeed.next([]);
......
<div class="m-embed-video" *ngIf="object.subtype == 'video'">
<m-video
[autoplay]="false"
[muted]="false"
[src]="[{ 'uri': object.src['720.mp4'] }]"
<m-videoPlayer
[guid]="object.guid"
[playCount]="object['play:count']"
[poster]="object['thumbnail_src']"
></m-video>
[autoplay]="false"
[shouldPlayInModal]="false"
>
</m-videoPlayer>
</div>
......@@ -29,17 +29,13 @@ export class AnalyticsFiltersComponent implements OnInit, OnDestroy {
// TODO: remove all of this once channel search is ready
// Temporarily remove channel search from channel filter options
this.subscription = this.analyticsService.filters$.subscribe(filters => {
this.filters = filters;
const channelFilter = filters.find(filter => filter.id === 'channel');
channelFilter.options = channelFilter.options.filter(option => {
return option.id === 'all' || option.id === 'self';
});
this.filters.find(filter => filter.id === 'channel').options =
channelFilter.options;
if (channelFilter) {
channelFilter.options = channelFilter.options.filter(option => {
return option.id === 'all' || option.id === 'self';
});
}
this.filters = filters;
this.detectChanges();
});
}
......
<ng-container *ngIf="metrics$ | async as metrics">
<ng-container *ngFor="let metric of metrics">
<div
class="m-analytics__metric m-shadowboxLayout__headerItem"
(click)="updateMetric(metric)"
[ngClass]="{ active: metric.visualisation }"
*ngIf="metric.permissionGranted"
>
<div class="m-analytics__metricLabel">
<span>{{ metric.label }}</span>
<m-tooltip [anchor]="top" icon="help">
{{ metric.description }}
</m-tooltip>
</div>
<div class="m-analytics__metricSummary" *ngIf="metric.summary">
<ng-container *ngIf="metric.unit === 'number'">
{{ metric.summary.current_value | number }}
</ng-container>
<ng-container *ngIf="metric.unit === 'usd'">
<span>$</span
>{{ metric.summary.current_value / 100 | number: '1.2-2' }}
</ng-container>
</div>
<div
*ngIf="metric.summary"
class="m-analytics__metricDelta"
[ngClass]="{
goodChange: metric.hasChanged && metric.positiveTrend,
badChange: metric.hasChanged && !metric.positiveTrend
}"
>
<ng-container *ngIf="metric.hasChanged">
<i class="material-icons" *ngIf="metric.delta > 0">arrow_upward</i>
<i class="material-icons" *ngIf="metric.delta < 0">arrow_downward</i>
</ng-container>
<span *ngIf="metric.delta">{{ metric.delta | percent: '1.0-1' }}</span>
</div>
</div>
</ng-container>
</ng-container>
import { Component, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import {
AnalyticsDashboardService,
Metric as MetricBase,
} from '../../dashboard.service';
import { Session } from '../../../../../services/session';
interface MetricExtended extends MetricBase {
delta: number;
hasChanged: boolean;
positiveTrend: boolean;
permissionGranted: boolean;
}
export { MetricExtended as Metric };
@Component({
selector: 'm-analytics__metrics',
templateUrl: './metrics.component.html',
})
export class AnalyticsMetricsComponent implements OnInit {
subscription: Subscription;
user;
userRoles: string[] = ['user'];
metrics$;
constructor(
private analyticsService: AnalyticsDashboardService,
public session: Session
) {}
ngOnInit() {
this.user = this.session.getLoggedInUser();
if (this.session.isAdmin()) {
this.userRoles.push('admin');
}
if (this.user.pro) {
this.userRoles.push('pro');
}
this.metrics$ = this.analyticsService.metrics$.pipe(
map(_metrics => {
const metrics = _metrics.map(metric => ({ ...metric })); // Clone to avoid updating
for (const metric of metrics) {
metric['permissionGranted'] = metric.permissions.some(role =>
this.userRoles.includes(role)
);
if (metric.summary) {
const cur: number = metric.summary.current_value || 0;
const cmp: number = metric.summary.comparison_value || 0;
let delta: number, hasChanged: boolean, positiveTrend: boolean;
if (cur === cmp) {
// Same values, no changes
hasChanged = false;
delta = 0;
} else if (cmp === 0) {
// Comparison value is 0, cannot calculate %
hasChanged = true;
delta = Infinity; // Will display infinity symbol
positiveTrend = cur > 0;
} else {
// Normal cases
hasChanged = true;
delta = (cur - cmp) / cmp;
positiveTrend = delta > 0;
}
if (!metric.summary.comparison_positive_inclination) {
// If "comparison positive inclination" is not true, it
// represents a "not-so-good" metric. So we'll flip the colors.
// Upwards will be "bad"
// Downwards will be "good"
positiveTrend = !positiveTrend;
}
metric['delta'] = delta;
metric['hasChanged'] = hasChanged;
metric['positiveTrend'] = positiveTrend;
}
}
return metrics;
})
);
}
updateMetric(metric) {
// TODO: if clicked metric is not fully visible, slide() until it is
this.analyticsService.updateMetric(metric.id);
}
}
......@@ -32,10 +32,10 @@
class="m-analyticsDashboard__description"
*ngIf="description$ | async as description"
>
{{ description }}
<ng-container *ngIf="(category$ | async) === 'earnings'">
<a *ngIf="!session.getLoggedInUser().pro" routerLink="/pro"
>Upgrade to Pro</a
<span *ngIf="!session.getLoggedInUser().pro">
In order to start earning,
<a routerLink="/pro">upgrade to Pro</a>.</span
>
<a
*ngIf="
......@@ -49,6 +49,7 @@
>Enable payouts</a
>
</ng-container>
{{ description }}
</p>
<m-analytics__layout--chart
m-dashboardLayout__body
......
......@@ -40,7 +40,7 @@ export class AnalyticsDashboardComponent implements OnInit, OnDestroy {
label: 'Timespan',
options: [],
};
channelFilter: Filter;
// channelFilter: Filter;
layout = 'chart';
constructor(
......@@ -66,6 +66,8 @@ export class AnalyticsDashboardComponent implements OnInit, OnDestroy {
this.updateCategory(cat);
if (cat === 'summary') {
this.layout = 'summary';
} else {
this.layout = 'chart';
}
});
......@@ -87,13 +89,18 @@ export class AnalyticsDashboardComponent implements OnInit, OnDestroy {
this.detectChanges();
});
this.analyticsService.filters$.subscribe(filters => {
this.channelFilter = filters.find(filter => filter.id === 'channel');
// TODO: remove this once channel search is ready
// Temporarily remove channel search from filter options
this.channelFilter.options = this.channelFilter.options.filter(option => {
return option.id === 'all' || option.id === 'self';
});
// const channelFilter = filters.find(filter => filter.id === 'channel');
// if (channelFilter) {
// this.channelFilter = channelFilter;
// // Temporarily remove channel search from filter options
// this.channelFilter.options = this.channelFilter.options.filter(
// option => {
// return option.id === 'all' || option.id === 'self';
// }
// );
// }
this.detectChanges();
});
......
<div class="m-analytics__spinnerContainer" *ngIf="loading$ | async">
<!-- <div class="m-analytics__spinnerContainer" *ngIf="loading$ | async">
<div class="mdl-spinner mdl-js-spinner is-active" [mdl]></div>
</div>
</div> -->
<m-shadowboxLayout
*ngIf="selectedMetric && selectedMetric.visualisation"
[hasHeader]="selectedMetric.visualisation.type === 'chart'"
......@@ -16,6 +16,9 @@
class="m-shadowboxLayout__body"
[ngClass]="{ isTable: isTable, isMobile: isMobile }"
>
<div class="m-analytics__spinnerContainer" *ngIf="loading$ | async">
<div class="mdl-spinner mdl-js-spinner is-active" [mdl]></div>
</div>
<m-analytics__chart
*ngIf="selectedMetric.visualisation.type === 'chart'"
></m-analytics__chart>
......
......@@ -4,3 +4,6 @@
min-width: 420px;
}
}
.m-analytics__spinnerContainer {
min-height: 331px;
}
......@@ -45,18 +45,7 @@
<h2>Experiments</h2>
<ul class="m-canaryExperiments__list">
<li>
Multi Currency Wire
<a href="https://gitlab.com/minds/front/merge_requests/508"
>(front!508)</a
>
- 17th September '19
</li>
<li>
Post scheduler
<a href="https://gitlab.com/minds/front/merge_requests/494"
>(front!494)</a
>
- 17th September '19
Discovery algorithm by post age - 11th December '19
</li>
</ul>
</div>
......
<div class="m-channel--explicit-overlay--content">
<h3>
This channel contains mature content
</h3>
<div
class="m-btn m-btn--slim m-btn--action m-channel--explicit-overlay--action"
(click)="disableFilter()"
>
View
<div class="m-channel--explicit-overlay--container" *ngIf="!hidden">
<div class="m-channel--explicit-overlay--content">
<h3>
This channel contains mature content
</h3>
<div
class="m-btn m-btn--slim m-btn--action m-channel--explicit-overlay--action"
(click)="disableFilter()"
>
View
</div>
</div>
</div>
m-channel--explicit-overlay {
.m-channel--explicit-overlay--container {
display: flex;
justify-content: center;
align-items: center;
......
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ExplicitOverlayComponent } from './overlay.component';
import { Session } from '../../../services/session';
import { sessionMock } from '../../../../tests/session-mock.spec';
import { Storage } from '../../../services/storage';
import { Router } from '@angular/router';
import { storageMock } from '../../../../tests/storage-mock.spec';
let routerMock = new (function() {
this.navigate = jasmine.createSpy('navigate');
})();
describe('OverlayComponent', () => {
let comp: ExplicitOverlayComponent;
let fixture: ComponentFixture<ExplicitOverlayComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ExplicitOverlayComponent],
imports: [],
providers: [
{ provide: Storage, useValue: storageMock },
{ provide: Session, useValue: sessionMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
}));
beforeEach(done => {
jasmine.MAX_PRETTY_PRINT_DEPTH = 10;
jasmine.clock().uninstall();
jasmine.clock().install();
fixture = TestBed.createComponent(ExplicitOverlayComponent);
comp = fixture.componentInstance;
comp.hidden = true;
fixture.detectChanges();
if (fixture.isStable()) {
done();
} else {
fixture.whenStable().then(() => {
done();
});
}
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should not show overlay when mature visibility is set', () => {
comp.channel = {
mature_visibility: true,
};
comp.showOverlay();
fixture.detectChanges();
expect(comp.hidden).toBeTruthy();
});
it('should overlay when channel is mature', () => {
comp._channel = {
is_mature: true,
};
comp.showOverlay();
fixture.detectChanges();
expect(comp.hidden).toBeFalsy();
});
it('should overlay when channel is nsfw for one reason', () => {
comp._channel = {
nsfw: [1],
};
comp.showOverlay();
fixture.detectChanges();
expect(comp.hidden).toBeFalsy();
});
it('should overlay when channel is nsfw for multiple reason', () => {
comp._channel = {
nsfw: [1, 2, 3],
};
comp.showOverlay();
fixture.detectChanges();
expect(comp.hidden).toBeFalsy();
});
it('should overlay not show overlay if channel is not nsfw, mature and no mature_visibility', () => {
comp._channel = {
mature_visibility: false,
is_mature: false,
nsfw: [],
};
comp.showOverlay();
fixture.detectChanges();
expect(comp.hidden).toBeTruthy();
});
it('should not register undefined values as a false positive, and show the overlay', () => {
comp._channel = {
mature_visibility: undefined,
is_mature: undefined,
nsfw: undefined,
};
comp.showOverlay();
fixture.detectChanges();
expect(comp.hidden).toBeTruthy();
});
});
import { Component, HostBinding, Input } from '@angular/core';
import { Component, Input } from '@angular/core';
import { Session } from '../../../services/session';
import { Router } from '@angular/router';
import { Storage } from '../../../services/storage';
......@@ -8,16 +8,12 @@ import { Storage } from '../../../services/storage';
templateUrl: 'overlay.component.html',
})
export class ExplicitOverlayComponent {
@HostBinding('hidden') hidden: boolean;
_channel: any;
public hidden = true;
public _channel: any;
@Input() set channel(value: any) {
this._channel = value;
this.hidden =
!this._channel ||
!this._channel.is_mature ||
this._channel.mature_visibility;
this.showOverlay();
}
constructor(
......@@ -34,8 +30,31 @@ export class ExplicitOverlayComponent {
this.router.navigate(['/login']);
}
disableFilter() {
/**
* Disables overlay screen, revealing channel.
*/
protected disableFilter(): void {
this._channel.mature_visibility = true;
this.hidden = true;
}
/**
* Determines whether the channel overlay should be shown
* over the a channel.
*/
public showOverlay(): void {
if (!this._channel) {
return;
}
if (this._channel.mature_visibility) {
this.hidden = true;
} else if (this._channel.is_mature) {
this.hidden = false;
} else if (this._channel.nsfw && this._channel.nsfw.length > 0) {
this.hidden = false;
} else {
this.hidden = true;
}
}
}
......@@ -263,16 +263,14 @@
</button>
</div>
<ng-container *mIfFeature="'purchase-pro'">
<a
*ngIf="showBecomeProButton"
class="m-btn m-link-btn m-btn--with-icon m-btn--slim m-btn--action"
routerLink="/pro"
>
<i class="material-icons">business_center</i>
<span i18n>Become Pro</span>
</a>
</ng-container>
<a
*ngIf="showBecomeProButton"
class="m-btn m-link-btn m-btn--with-icon m-btn--slim m-btn--action"
[routerLink]="proSettingsRouterLink"
>
<i class="material-icons">business_center</i>
<span i18n>Try Pro</span>
</a>
<a
*ngIf="showProSettings"
......
......@@ -174,33 +174,12 @@
</span>
</div>
<m-video
width="100%"
height="300px"
controls="true"
muted="false"
[poster]="comment.custom_data.thumbnail_src"
[autoplay]="false"
[src]="[
{
res: '360',
uri:
'api/v1/media/' +
comment.custom_data.guid +
'/play?s=comment',
type: 'video/mp4'
}
]"
<m-videoPlayer
[guid]="comment.custom_data.guid"
[playCount]="comment['play:count']"
[torrent]="[
{ res: '360', key: comment.custom_data.guid + '/360.mp4' }
]"
[shouldPlayInModal]="true"
(videoMetadataLoaded)="setVideoDimensions($event)"
[autoplay]="false"
(mediaModalRequested)="openModal()"
>
</m-video>
></m-videoPlayer>
</div>
<!-- Custom type:: batch -->
......
......@@ -25,7 +25,6 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Router } from '@angular/router';
import { FeaturesService } from '../../../services/features.service';
import { MindsVideoComponent } from '../../media/components/video/video.component';
import { MediaModalComponent } from '../../media/modal/modal.component';
import isMobile from '../../../helpers/is-mobile';
......@@ -82,7 +81,6 @@ export class CommentComponent implements OnChanges {
commentAge$: Observable<number>;
videoDimensions: Array<any> = null;
@ViewChild('player', { static: false }) player: MindsVideoComponent;
@ViewChild('batchImage', { static: false }) batchImage: ElementRef;
@Input() canEdit: boolean = false;
......
......@@ -176,31 +176,12 @@
</span>
</div>
<m-video
width="100%"
height="300px"
style="background:#000;"
controls="true"
muted="false"
[poster]="comment.custom_data.thumbnail_src"
[autoplay]="false"
[src]="[
{
res: '360',
uri: 'api/v1/media/' + comment.custom_data.guid + '/play',
type: 'video/mp4'
}
]"
<m-videoPlayer
[guid]="comment.custom_data.guid"
[playCount]="comment['play:count']"
[torrent]="[
{ res: '360', key: comment.custom_data.guid + '/360.mp4' }
]"
[shouldPlayInModal]="true"
(videoMetadataLoaded)="setVideoDimensions($event)"
[autoplay]="false"
(mediaModalRequested)="openModal()"
>
</m-video>
></m-videoPlayer>
</div>
<!-- Custom type:: batch -->
......
......@@ -29,7 +29,6 @@ import { map } from 'rxjs/operators';
import { ActivityService } from '../../../common/services/activity.service';
import { Router } from '@angular/router';
import { FeaturesService } from '../../../services/features.service';
import { MindsVideoComponent } from '../../media/components/video/video.component';
import { MediaModalComponent } from '../../media/modal/modal.component';
import isMobile from '../../../helpers/is-mobile';
......@@ -85,7 +84,6 @@ export class CommentComponentV2
canReply = true;
videoDimensions: Array<any> = null;
@ViewChild('player', { static: false }) player: MindsVideoComponent;
@ViewChild('batchImage', { static: false }) batchImage: ElementRef;
@Input() canEdit: boolean = false;
......
......@@ -122,16 +122,12 @@ export class SidebarSelectorComponent implements OnInit {
}
hashtagVisibilityChange(hashtag) {
if (this.currentHashtag !== hashtag.value) {
this.currentHashtag = hashtag.value;
this.filterChange.emit({
type: 'single',
value: this.currentHashtag,
});
} else {
this.currentHashtag = null;
}
this.currentHashtag =
this.currentHashtag !== hashtag.value ? hashtag.value : null;
this.filterChange.emit({
type: 'single',
value: this.currentHashtag,
});
}
preferredChange() {
......
......@@ -26,7 +26,7 @@ minds-activity {
}
.m-activity--video {
m-video {
m-videoPlayer {
@include m-theme() {
background-color: themed($m-black-always);
}
......
......@@ -296,22 +296,12 @@
</span>
</div>
<m-video
width="100%"
height="300px"
[muted]="false"
[poster]="activity.custom_data.thumbnail_src"
[src]="[{ 'res': '360', 'uri': 'api/v1/media/' + activity.custom_data.guid + '/play?s=activity', 'type': 'video/mp4' }]"
[guid]="activity.custom_data.guid"
[playCount]="activity['play:count']"
[torrent]="[{ res: '360', key: activity.custom_data.guid + '/360.mp4' }]"
<m-videoPlayer
[guid]="activity.entity_guid"
[shouldPlayInModal]="true"
(videoMetadataLoaded)="setVideoDimensions($event)"
[autoplay]="false"
(mediaModalRequested)="openModal()"
#player
>
<video-ads [player]="player" *ngIf="activity.monetized"></video-ads>
</m-video>
></m-videoPlayer>
</div>
<!-- Images -->
......
......@@ -19,7 +19,6 @@ import { OverlayModalService } from '../../../../../services/ux/overlay-modal';
import { MediaModalComponent } from '../../../../media/modal/modal.component';
import { BoostCreatorComponent } from '../../../../boost/creator/creator.component';
import { WireCreatorComponent } from '../../../../wire/creator/creator.component';
import { MindsVideoComponent } from '../../../../media/components/video/video.component';
import { EntitiesService } from '../../../../../common/services/entities.service';
import { Router } from '@angular/router';
import { BlockListService } from '../../../../../common/services/block-list.service';
......@@ -30,6 +29,7 @@ import { AutocompleteSuggestionsService } from '../../../../suggestions/services
import { ActivityService } from '../../../../../common/services/activity.service';
import { FeaturesService } from '../../../../../services/features.service';
import isMobile from '../../../../../helpers/is-mobile';
import { MindsVideoPlayerComponent } from '../../../../media/components/video-player/player.component';
@Component({
moduleId: module.id,
......@@ -153,7 +153,7 @@ export class Activity implements OnInit {
}
}
@ViewChild('player', { static: false }) player: MindsVideoComponent;
@ViewChild('player', { static: false }) player: MindsVideoPlayerComponent;
@ViewChild('batchImage', { static: false }) batchImage: ElementRef;
protected time_created: any;
......
import {
trigger,
state,
style,
transition,
animate,
} from '@angular/animations';
export const PLAYER_ANIMATIONS = [
trigger('fadeAnimation', [
state(
'in',
style({
visibility: 'visible',
opacity: 1,
})
),
state(
'out',
style({
visibility: 'hidden',
opacity: 0,
})
),
transition('in <=> out', [animate('300ms ease-in')]),
]),
];
<ng-container *ngIf="isPlayable(); else placeholder">
<span class="m-videoPlayer__notice--error" *ngIf="status === 'failed'">
There was an error transcoding this video.
</span>
<span
class="m-videoPlayer__notice--transcoding"
*ngIf="status === 'transcoding'"
>
This video is still transcoding.
</span>
<span class="m-videoPlayer__notice--created" *ngIf="status === 'created'">
This video is waiting to be transcoded.
</span>
<div class="m-videoPlayer__screen">
<plyr
style="display: block; width: 100%; height: 100%;"
[plyrPoster]="poster"
[plyrPlaysInline]="true"
[plyrSources]="sources"
[plyrOptions]="options"
(plyrPlay)="onPlayed($event)"
(plyrEnterFullScreen)="fullScreenChange.next($event)"
(plyrExitFullScreen)="fullScreenChange.next($event)"
>
</plyr>
</div>
</ng-container>
<ng-template #placeholder>
<div
class="m-videoPlayer__placeholder"
[style.background-image]="'url(' + poster + ')'"
>
<i
class="material-icons minds-video-play-icon"
(click)="onPlaceholderClick($event)"
>play_circle_outline</i
>
</div>
</ng-template>
.plyr video {
height: 100%;
}
[class*='m-videoPlayer__notice'] {
display: block;
height: auto;
line-height: normal;
padding: 16px;
position: absolute;
z-index: 1;
width: 100%;
box-sizing: border-box;
@include m-theme() {
color: themed($m-white);
@include m-theme() {
background: linear-gradient(
rgba(themed($m-black-always), 0.5),
rgba(themed($m-black-always), 0)
);
color: themed($m-white-always);
}
}
}
m-videoPlayer {
display: block;
position: relative;
width: 100%;
}
.m-videoPlayer__placeholder {
height: 330px;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
background-color: #222;
position: relative;
i {
opacity: 0.8;
display: block;
text-align: center;
top: 50%;
transform: translateY(-50%);
font-size: 100px;
position: absolute;
cursor: pointer;
width: 100%;
transition: opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
@include m-theme() {
color: themed($m-white-always);
text-shadow: 0 0 3px rgba(themed($m-black-always), 0.6);
}
}
&:hover {
i {
opacity: 1;
}
}
}
import {
Component,
OnDestroy,
OnInit,
Input,
ViewChild,
Output,
EventEmitter,
ChangeDetectorRef,
} from '@angular/core';
import { PLAYER_ANIMATIONS } from './player.animations';
import { VideoPlayerService, VideoSource } from './player.service';
import isMobile from '../../../../helpers/is-mobile';
import Plyr from 'plyr';
import { PlyrComponent } from 'ngx-plyr';
@Component({
selector: 'm-videoPlayer',
templateUrl: 'player.component.html',
animations: PLAYER_ANIMATIONS,
providers: [VideoPlayerService],
})
export class MindsVideoPlayerComponent implements OnInit, OnDestroy {
/**
* MH: dislike having to emit an event to open modal, but this is
* the quickest work around for now
*/
@Output() mediaModalRequested: EventEmitter<void> = new EventEmitter();
/**
* Modal needs to know if we have left full screen
*/
@Output() fullScreenChange: EventEmitter<Event> = new EventEmitter();
/**
* This is the video player component
*/
@ViewChild(PlyrComponent, { static: false }) player: PlyrComponent;
/**
* Options for Plyr to use
*/
options: Plyr.Options = {
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'captions',
'settings',
'airplay',
'fullscreen',
],
};
constructor(
private service: VideoPlayerService,
private cd: ChangeDetectorRef
) {}
ngOnInit(): void {
this.service.load().then(() => {
this.cd.markForCheck();
this.cd.detectChanges();
});
}
ngOnDestroy(): void {}
@Input('guid')
set guid(guid: string) {
this.service.setGuid(guid);
}
@Input('autoplay')
set autoplay(autoplay: boolean) {
this.options.autoplay = autoplay;
}
@Input('isModal')
set isModal(isModal: boolean) {
this.service.setIsModal(isModal);
}
@Input('shouldPlayInModal')
set shouldPlayInModal(shouldPlayInModal: boolean) {
this.service.setShouldPlayInModal(shouldPlayInModal);
}
get poster(): string {
return this.service.poster;
}
get sources(): Plyr.Source[] {
return this.service.sources;
}
get status(): string {
return this.service.status;
}
onPlayed(event: Plyr.PlyrEvent): void {
// console.log('played', event);
}
/**
* If the component is in a playable state
* @return boolean
*/
isPlayable(): boolean {
return this.service.isPlayable();
}
/**
* Placeholder clicked
* @param e
* @return void
*/
onPlaceholderClick(e: MouseEvent): void {
// If we have a player, then play
if (this.player) {
this.player.player.play();
return;
}
// Play in modal if required
if (this.service.shouldPlayInModal) {
return this.mediaModalRequested.next();
}
console.error('Placeholder was clicked but we have no action to take');
}
/**
* Pause the player, if there is one
* @return void
*/
pause(): void {
if (this.player) {
this.player.player.pause();
return;
}
}
}
import { Injectable } from '@angular/core';
import { Client } from '../../../../services/api';
import isMobile from '../../../../helpers/is-mobile';
export type VideoSource = {
id: string;
type: string;
size: number;
src: string;
};
@Injectable()
export class VideoPlayerService {
/**
* @var string
*/
guid: string;
/**
* @var VideoSource[]
*/
sources: VideoSource[];
/**
* @var string
*/
status: string;
/**
* A poster is thumbnail
* @var string
*/
poster: string;
/**
* False would be inline
* @var boolean
*/
shouldPlayInModal = true;
/**
* If its a modal or not
* @var boolean
*/
isModal = false;
constructor(private client: Client) {}
/**
* Set the guid that we are interacting with
* @param string guid
* @return VideoPlayerService
*/
setGuid(guid: string): VideoPlayerService {
this.guid = guid;
return this;
}
/**
* Set the guid that we are interacting with
* @param boolean is
* @return VideoPlayerService
*/
setIsModal(isModal: boolean): VideoPlayerService {
this.isModal = isModal;
return this;
}
setShouldPlayInModal(shouldPlayInModal: boolean): VideoPlayerService {
this.shouldPlayInModal = shouldPlayInModal;
return this;
}
/**
* Return the sources for a video
* @return Promise<void>
*/
async load(): Promise<void> {
try {
let response = await this.client.get('api/v2/media/video/' + this.guid);
this.sources = (<any>response).sources;
this.poster = (<any>response).poster;
this.status = (<any>response).transcode_status;
} catch (e) {
console.error(e);
}
}
/**
* @return boolean
*/
private canPlayInModal(): boolean {
const isNotTablet: boolean = Math.min(screen.width, screen.height) < 768;
return isMobile() && isNotTablet;
}
/**
* Returns if the video is able to be played
* @return boolean
*/
isPlayable(): boolean {
return (
this.isModal || // Always playable in modal
!this.shouldPlayInModal || // Equivalent of asking to play inline
(this.canPlayInModal() && !this.isModal)
); // We can play in the modal and this isn't a modal
}
/**
* Record play
*/
async recordPlay(): Promise<void> {
// TODO
}
}
import { Component, Input, ElementRef } from '@angular/core';
import { VideoAdsService } from './ads.service';
@Component({
selector: 'video-ads',
template: ``,
})
export class VideoAds {
service: VideoAdsService = new VideoAdsService();
@Input() player;
adContainer;
adLoader;
adManager;
initialized: boolean = false;
google = window.google;
constructor(private element: ElementRef) {}
ngOnInit() {
//this.setupIMA();
this.element.nativeElement.style.display = 'none';
}
setupIMA() {
this.adContainer = new this.google.ima.AdDisplayContainer(
this.element.nativeElement,
this.player.element
);
this.adLoader = new this.google.ima.AdsLoader(this.adContainer);
// Listen and respond to ads loaded and error events.
this.adLoader.addEventListener(
this.google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
this.onLoaded.bind(this),
false
);
this.adLoader.addEventListener(
this.google.ima.AdErrorEvent.Type.AD_ERROR,
this.onError.bind(this),
false
);
// Request video ads.
var adsRequest = new this.google.ima.AdsRequest();
adsRequest.adTagUrl =
'https://pubads.g.doubleclick.net/gampad/ads? ' +
'sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&' +
'impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&' +
'cust_params=deployment%3Ddevsite%26sample_ct%3Dskippablelinear&correlator=';
adsRequest.linearAdSlotWidth = this.player.element.clientWidth;
adsRequest.linearAdSlotHeight = this.player.element.clientHeight;
adsRequest.nonLinearAdSlotWidth = this.player.element.clientWidth;
adsRequest.nonLinearAdSlotHeight = 150;
adsRequest.setAdWillAutoPlay(true);
this.adLoader.requestAds(adsRequest);
}
onLoaded(e) {
let settings = new this.google.ima.AdsRenderingSettings();
settings.restoreCustomPlaybackStateOnAdBreakComplete = true;
// videoContent should be set to the content video element.
this.adManager = e.getAdsManager(this.player.element, settings);
// Add listeners to the required events.
this.adManager.addEventListener(
this.google.ima.AdErrorEvent.Type.AD_ERROR,
this.onError.bind(this)
);
this.adManager.addEventListener(
this.google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED,
this.onPause.bind(this)
);
this.adManager.addEventListener(
this.google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED,
this.onResume.bind(this)
);
this.adManager.addEventListener(
this.google.ima.AdEvent.Type.ALL_ADS_COMPLETED,
this.onEvent.bind(this)
);
// Listen to any additional events, if necessary.
this.adManager.addEventListener(
this.google.ima.AdEvent.Type.LOADED,
this.onEvent.bind(this)
);
this.adManager.addEventListener(
this.google.ima.AdEvent.Type.STARTED,
this.onEvent.bind(this)
);
this.adManager.addEventListener(
this.google.ima.AdEvent.Type.COMPLETE,
this.onEvent.bind(this)
);
var initWidth = this.player.element.clientWidth;
var initHeight = this.player.element.clientHeight;
//this.adManagerDimensions.width = initWidth;
//this.adsManagerDimensions.height = initHeight;
this.adManager.init(initWidth, initHeight, this.google.ima.ViewMode.NORMAL);
this.adManager.resize(
initWidth,
initHeight,
this.google.ima.ViewMode.NORMAL
);
if (!this.player.muted) {
this.playAds();
} else {
this.player.element.addEventListener(
'volumechange',
this.playAds.bind(this)
);
}
}
playAds() {
if (this.initialized) return;
this.initialized = true;
this.element.nativeElement.style.display = 'block';
this.player.autoplay = true;
this.adContainer.initialize();
try {
// Initialize the ads manager. Ad rules playlist will start at this time.
this.adManager.init(640, 360, this.google.ima.ViewMode.NORMAL);
// Call play to start showing the ad. Single video and overlay ads will
// start at this time; the call will be ignored for ad rules.
this.adManager.start();
} catch (err) {
// An error may be thrown if there was a problem with the VAST response.
//videoContent.play();
console.log(err);
this.element.nativeElement.style.display = 'none';
return false;
}
return true;
}
onEvent(e) {
// Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)
// don't have ad object associated.
var ad = e.getAd();
switch (e.type) {
case this.google.ima.AdEvent.Type.LOADED:
if (!ad.isLinear()) {
// Position AdDisplayContainer correctly for overlay.
// Use ad.width and ad.height.
//this.player.nativeElement.play();
}
break;
case this.google.ima.AdEvent.Type.STARTED:
// This event indicates the ad has started - the video player
// can adjust the UI, for example display a pause button and
// remaining time.
//if (ad.isLinear()) {
//}
break;
case this.google.ima.AdEvent.Type.COMPLETE:
//if (ad.isLinear()) {
//}
this.element.nativeElement.style.display = 'none';
break;
}
}
onPause(e) {
this.element.nativeElement.style.display = 'block';
this.player.element.pause();
}
onResume(e) {
this.player.element.play();
this.element.nativeElement.style.display = 'none';
}
onError(e) {
console.log(e.getError());
this.adManager.destroy();
}
ngOnDestroy() {
if (this.adManager) this.adManager.destroy();
}
}
import { Directive, Input } from '@angular/core';
import { VideoAdsService } from './ads.service';
@Directive({
selector: '[videoAds]',
})
export class VideoAdsDirective {
@Input() autoplay: boolean = true;
@Input() muted: boolean = false;
service: VideoAdsService = new VideoAdsService();
ngOnInit() {
if (this.autoplay && !this.muted) {
//load the service
}
}
}
video-ads {
position: absolute;
display: block;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
z-index: 111111;
> div,
iframe {
position: absolute !important;
width: 100% !important;
height: 100% !important;
top: 0 !important;
left: 0 !important;
}
}
export class VideoAdsService {}
<video
[src]="src"
[autoplay]="autoplay"
[poster]="poster"
[muted]="muted"
preload="none"
allowfullscreen
#player
></video>
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { MindsPlayerInterface } from './player.interface';
@Component({
moduleId: module.id,
selector: 'm-video--direct-http-player',
templateUrl: 'direct-http.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MindsVideoDirectHttpPlayer
implements OnInit, OnDestroy, MindsPlayerInterface {
@ViewChild('player', { static: true }) player: ElementRef;
@Input() muted: boolean = false;
@Input() poster: string = '';
@Input() autoplay: boolean = false;
@Input() guid: string | number;
src: string;
@Input('src') set _src(src: string) {
this.src = src;
const player = this.getPlayer();
if (player) {
this.loading = true;
this.detectChanges();
player.load();
}
}
@Output() onPlay: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onPause: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onEnd: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onError: EventEmitter<{
player: HTMLVideoElement;
e;
}> = new EventEmitter();
@Output() onCanPlayThrough: EventEmitter<any> = new EventEmitter();
@Output() onLoadedMetadata: EventEmitter<any> = new EventEmitter();
loading: boolean = false;
constructor(protected cd: ChangeDetectorRef) {}
protected _emitPlay = () => this.onPlay.emit(this.getPlayer());
protected _emitPause = () => this.onPause.emit(this.getPlayer());
protected _emitEnd = () => this.onEnd.emit(this.getPlayer());
protected _emitError = e =>
this.onError.emit({ player: this.getPlayer(), e });
protected _emitCanPlayThrough = () =>
this.onCanPlayThrough.emit(this.getPlayer());
protected _emitLoadedMetadata = () =>
this.onLoadedMetadata.emit(this.getPlayer());
protected _canPlayThrough = () => {
this.loading = false;
this.detectChanges();
this._emitCanPlayThrough();
};
protected _onPlayerError = e => {
if (
!e.target.error &&
e.target.networkState !== HTMLMediaElement.NETWORK_NO_SOURCE
) {
// Poster error
return;
}
this.loading = false;
this.detectChanges();
this._emitError(e);
};
ngOnInit() {
const player = this.getPlayer();
player.addEventListener('playing', this._emitPlay);
player.addEventListener('pause', this._emitPause);
player.addEventListener('ended', this._emitEnd);
player.addEventListener('error', this._onPlayerError);
player.addEventListener('canplaythrough', this._canPlayThrough);
player.addEventListener('loadedmetadata', this._emitLoadedMetadata);
this.loading = true;
}
ngOnDestroy() {
const player = this.getPlayer();
if (player) {
player.removeEventListener('playing', this._emitPlay);
player.removeEventListener('pause', this._emitPause);
player.removeEventListener('ended', this._emitEnd);
player.removeEventListener('error', this._onPlayerError);
player.removeEventListener('canplaythrough', this._canPlayThrough);
player.removeEventListener('loadedmetadata', this._emitLoadedMetadata);
}
}
getPlayer(): HTMLVideoElement {
return this.player.nativeElement;
}
async play() {
const player = this.getPlayer();
try {
await player.play();
} catch (e) {
console.log(e);
}
}
pause() {
const player = this.getPlayer();
try {
player.pause();
} catch (e) {
console.log(e);
}
}
async toggle() {
const player = this.getPlayer();
if (player.paused) {
await this.play();
} else {
this.pause();
}
}
resumeFromTime(time: number = 0) {
// TODO detect if it's still transcoding
const player = this.getPlayer();
try {
player.currentTime = time;
this.play();
} catch (e) {
console.log(e);
}
}
isPlaying() {
const player = this.getPlayer();
return !player.paused;
}
isLoading() {
return this.loading;
}
requestFullScreen() {
const player: any = this.getPlayer();
if (player.requestFullscreen) {
player.requestFullscreen();
} else if (player.msRequestFullscreen) {
player.msRequestFullscreen();
} else if (player.mozRequestFullScreen) {
player.mozRequestFullScreen();
} else if (player.webkitRequestFullscreen) {
player.webkitRequestFullscreen();
}
}
getInfo() {
return {};
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
}
import { EventEmitter } from '@angular/core';
export interface MindsPlayerInterface {
muted: boolean;
poster: string;
autoplay: boolean;
src: string;
onPlay: EventEmitter<HTMLVideoElement>;
onPause: EventEmitter<HTMLVideoElement>;
onEnd: EventEmitter<HTMLVideoElement>;
onError: EventEmitter<{ player: HTMLVideoElement; e }>;
getPlayer(): HTMLVideoElement;
play(): void;
pause(): void;
toggle(): void;
resumeFromTime(time: number);
isLoading(): boolean;
isPlaying(): boolean;
requestFullScreen(): void;
getInfo(): any;
}
<video
[poster]="poster"
[muted]="muted"
preload="none"
allowfullscreen
#player
></video>
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { MindsPlayerInterface } from './player.interface';
import { WebtorrentService } from '../../../../webtorrent/webtorrent.service';
import { Client } from '../../../../../services/api/client';
import base64ToBlob from '../../../../../helpers/base64-to-blob';
@Component({
moduleId: module.id,
selector: 'm-video--torrent-player',
templateUrl: 'torrent.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MindsVideoTorrentPlayer
implements OnInit, AfterViewInit, OnDestroy, MindsPlayerInterface {
@ViewChild('player', { static: true }) player: ElementRef;
@Input() muted: boolean = false;
@Input() poster: string = '';
@Input() autoplay: boolean = false;
@Input() guid: string | number;
src: string;
@Input('src') set _src(src: string) {
this.src = src;
if (this.initialized) {
this.removeTorrent();
setTimeout(() => {
this.loadTorrent();
}, 0);
}
}
@Output() onPlay: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onPause: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onEnd: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onError: EventEmitter<{ player; e }> = new EventEmitter();
@Output() onCanPlayThrough: EventEmitter<any> = new EventEmitter();
@Output() onLoadedMetadata: EventEmitter<any> = new EventEmitter();
initialized: boolean = false;
loading: boolean = false;
isModal: boolean = false;
protected torrentId: string;
protected torrentReady: boolean = false;
protected torrentInfo = {
progress: 0,
peers: 0,
ul: 0,
dl: 0,
ulspeed: 0,
dlspeed: 0,
};
protected infoTimer$: any;
protected deferredResumeFromTime: number;
constructor(
protected cd: ChangeDetectorRef,
protected client: Client,
protected webtorrent: WebtorrentService
) {}
protected _emitPlay = () => this.onPlay.emit(this.getPlayer());
protected _emitPause = () => this.onPause.emit(this.getPlayer());
protected _emitEnd = () => this.onEnd.emit(this.getPlayer());
protected _emitError = e =>
this.onError.emit({ player: this.getPlayer(), e });
protected _emitCanPlayThrough = () =>
this.onCanPlayThrough.emit(this.getPlayer());
protected _emitLoadedMetadata = () =>
this.onLoadedMetadata.emit(this.getPlayer());
protected _canPlayThrough = () => {
this.loading = false;
this.detectChanges();
this._emitCanPlayThrough();
};
protected _onError = e => {
this.loading = false;
this.detectChanges();
this._emitError(e);
};
protected _onPlayerError = e => {
if (!e.target.error) {
// Poster error
return;
}
this.loading = false;
this.detectChanges();
this._emitError(e);
};
protected _refreshInfo = () => {
if (
!this.torrentId ||
!this.torrentReady ||
!this.webtorrent.get(this.torrentId)
) {
this.torrentInfo = {
progress: 0,
peers: 0,
ul: 0,
dl: 0,
ulspeed: 0,
dlspeed: 0,
};
} else {
const torrent = this.webtorrent.get(this.torrentId);
this.torrentInfo = {
progress: torrent.progress,
peers: torrent.numPeers,
ul: torrent.uploaded,
dl: torrent.downloaded,
ulspeed: torrent.uploadSpeed,
dlspeed: torrent.downloadSpeed,
};
}
this.detectChanges();
};
ngOnInit() {
const player = this.getPlayer();
player.addEventListener('playing', this._emitPlay);
player.addEventListener('pause', this._emitPause);
player.addEventListener('ended', this._emitEnd);
player.addEventListener('error', this._onPlayerError);
player.addEventListener('canplaythrough', this._canPlayThrough);
player.addEventListener('loadedmetadata', this._emitLoadedMetadata);
this.infoTimer$ = setInterval(this._refreshInfo, 1000);
this.isModal = document.body.classList.contains('m-overlay-modal--shown');
}
ngAfterViewInit() {
this.initialized = true;
if (this.autoplay) {
this.play();
}
}
ngOnDestroy() {
if (this.infoTimer$) {
clearInterval(this.infoTimer$);
}
this.removeTorrent();
const player = this.getPlayer();
if (player) {
player.removeEventListener('playing', this._emitPlay);
player.removeEventListener('pause', this._emitPause);
player.removeEventListener('ended', this._emitEnd);
player.removeEventListener('error', this._onPlayerError);
player.removeEventListener('canplaythrough', this._canPlayThrough);
player.removeEventListener('loadedmetadata', this._emitLoadedMetadata);
}
}
getPlayer(): HTMLVideoElement {
return this.player.nativeElement;
}
play() {
const player = this.getPlayer();
try {
if (this.torrentReady) {
player.play();
} else {
this.loadTorrent();
}
} catch (e) {
console.log(e);
}
}
pause() {
const player = this.getPlayer();
try {
player.pause();
} catch (e) {
console.log(e);
}
}
toggle() {
const player = this.getPlayer();
if (player.paused) {
this.play();
} else {
this.pause();
}
}
resumeFromTime(time: number = 0) {
// TODO detect if it's still transcoding
const player = this.getPlayer();
try {
if (this.torrentReady) {
player.currentTime = time;
this.play();
} else {
this.deferredResumeFromTime = time;
if (!this.loading) {
this.loadTorrent();
}
}
} catch (e) {
console.log(e);
}
}
isPlaying() {
const player = this.getPlayer();
return !player.paused;
}
isLoading() {
return this.loading;
}
requestFullScreen() {
const player: any = this.getPlayer();
if (player.requestFullscreen) {
player.requestFullscreen();
} else if (player.msRequestFullscreen) {
player.msRequestFullscreen();
} else if (player.mozRequestFullScreen) {
player.mozRequestFullScreen();
} else if (player.webkitRequestFullscreen) {
player.webkitRequestFullscreen();
}
}
getInfo() {
return this.torrentInfo;
}
detectChanges() {
this.cd.markForCheck();
this.cd.detectChanges();
}
//
async loadTorrent() {
if (this.loading) {
return;
} else if (this.torrentReady) {
this.play();
return;
}
this.removeTorrent();
this.loading = true;
this.torrentReady = false;
this.detectChanges();
let torrentFile;
let infoHash;
let webSeed;
try {
const response: any = await this.client.get(
`api/v2/media/magnet/${this.src}`
);
torrentFile = base64ToBlob(response.encodedTorrent);
infoHash = response.infoHash;
webSeed = response.httpSrc;
} catch (e) {
this.loading = false;
this.detectChanges();
console.error('[TorrentVideo] Magnet download', e);
this._emitError(e);
return;
}
try {
this.torrentId = infoHash;
const torrent = await this.webtorrent.add(torrentFile, infoHash);
if (webSeed) {
torrent.addWebSeed(webSeed);
}
this.loading = false;
this.detectChanges();
const file = torrent.files.find(file => file.name.endsWith('.mp4'));
if (!file) {
this.loading = false;
this.detectChanges();
this.webtorrent.remove(this.torrentId);
this.torrentId = void 0;
console.error('[TorrentVideo] Video file not found');
this._emitError('E_NO_FILE');
return;
}
file.renderTo(this.getPlayer(), err => {
if (err) {
this.loading = false;
this.detectChanges();
this.webtorrent.remove(this.torrentId);
this.torrentId = void 0;
console.error('[TorrentVideo] Video render', err);
this._emitError(err);
return;
}
this.loading = false;
this.torrentReady = true;
this.detectChanges();
if (typeof this.deferredResumeFromTime) {
const time = this.deferredResumeFromTime;
this.deferredResumeFromTime = void 0;
this.resumeFromTime(time);
}
});
} catch (e) {
this.loading = false;
this.detectChanges();
console.error('[TorrentVideo] Webtorrent general error', e);
this._emitError(e);
}
}
removeTorrent() {
if (this.torrentId) {
this.webtorrent.remove(this.torrentId);
this.torrentId = void 0;
this.loading = false;
this.torrentReady = false;
}
}
}
<span id="seeker" class="progress-bar" (click)="seek($event)">
<div class="minds-bar progress" [ngStyle]="{ width: seeked + '%' }"></div>
<div class="seeker-ball" [ngStyle]="{ left: seeked + '%' }"></div>
<div class="minds-bar total"></div>
</span>
<span class="progress-stamps"
>{{ elapsed.minutes }}:{{ elapsed.seconds }}/{{ time.minutes }}:{{
time.seconds
}}</span
>
m-video--progress-bar {
flex: 1;
display: flex;
padding: 12px;
.progress-bar {
flex: 1;
position: relative;
.minds-bar {
position: absolute;
left: 0px;
height: 2px;
vertical-align: middle;
border-radius: 2px;
margin-top: 10px;
@include m-theme() {
background-color: themed($m-white-always);
}
&.total {
width: 100%;
@include m-theme() {
background-color: rgba(themed($m-white-always), 0.5);
}
}
}
}
.progress-stamps {
font-size: 12px;
font-weight: 600;
font-family: 'Roboto', Helvetica, sans-serif;
padding: 0 12px;
line-height: 24px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
cursor: default;
}
.m-play-count {
text-align: right;
> i {
vertical-align: middle;
margin-right: 0.15em;
}
> span {
vertical-align: middle;
font-size: 12px;
font-weight: 300;
}
}
.seeker-ball {
position: absolute;
z-index: 999;
border-radius: 30px;
top: 7px;
left: 0;
width: 1em;
cursor: pointer;
height: 1em;
margin: -0.2em 0px 0px -0.5em;
width: 14px;
height: 14px;
padding: 0px;
-webkit-transition: -webkit-transform 0.15s ease-in-out;
transition: transform 0.15s ease-in-out;
@include m-theme() {
background-color: themed($m-white-always);
}
}
}
///<reference path="../../../../../../../node_modules/@types/jasmine/index.d.ts"/>
import {
async,
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import {
Component,
DebugElement,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { CommonModule as NgCommonModule } from '@angular/common';
import { MindsVideoProgressBar } from './progress-bar.component';
import { MindsVideoDirectHttpPlayer } from '../players/direct-http.component';
import { MindsPlayerInterface } from '../players/player.interface';
class MindsVideoDirectHttpPlayerMock implements MindsPlayerInterface {
@Input() muted: boolean;
@Input() poster: string;
@Input() autoplay: boolean;
@Input() src: string;
@Output() onPlay: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onPause: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onEnd: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onError: EventEmitter<{
player: HTMLVideoElement;
e;
}> = new EventEmitter();
getPlayer = (): HTMLVideoElement => {
return null;
};
play = () => {};
pause = () => {};
toggle = () => {};
resumeFromTime = () => {};
isLoading = (): boolean => {
return false;
};
isPlaying = (): boolean => {
return false;
};
requestFullScreen = jasmine.createSpy('requestFullScreen').and.stub();
getInfo = () => {};
}
describe('MindsVideoProgressBar', () => {
let comp: MindsVideoProgressBar;
let fixture: ComponentFixture<MindsVideoProgressBar>;
let window: any = {};
let e: Event;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MindsVideoProgressBar], // declare the test component
imports: [FormsModule, RouterTestingModule, NgCommonModule],
}).compileComponents(); // compile template and css
}));
// synchronous beforeEach
beforeEach(done => {
window.removeEventListener = () => {};
window.addEventListener = () => {};
jasmine.MAX_PRETTY_PRINT_DEPTH = 10;
jasmine.clock().uninstall();
jasmine.clock().install();
fixture = TestBed.createComponent(MindsVideoProgressBar);
comp = fixture.componentInstance;
const video = document.createElement('video');
video.src = 'thisisavideo.mp4';
comp.element = video;
const playerRef = new MindsVideoDirectHttpPlayerMock();
playerRef.getPlayer = () => {
return video;
};
comp.playerRef = playerRef;
spyOn(window, 'removeEventListener').and.stub();
spyOn(window, 'addEventListener').and.stub();
spyOn(comp.element, 'addEventListener').and.stub();
fixture.detectChanges();
if (fixture.isStable()) {
done();
} else {
fixture.whenStable().then(() => {
done();
});
}
});
afterEach(() => {
jasmine.clock().uninstall();
});
// it('should have a Play icon and a Control bar', () => {
// const seeker = fixture.debugElement.query(By.css('#seeker'));
// const seekerBall = fixture.debugElement.query(By.css('.seeker-ball'));
// const stamps = fixture.debugElement.query(By.css('.progress-stamps'));
// expect(seeker).not.toBeNull();
// expect(seekerBall).not.toBeNull();
// expect(stamps).not.toBeNull();
// });
it('time is properly calculated', () => {
comp.duration = 111;
comp.calculateTime();
fixture.detectChanges();
expect(comp.time).toEqual({ minutes: '01', seconds: 51 });
});
it('time is properly calculated', () => {
comp.duration = 113;
comp.element.currentTime = 111;
comp.calculateElapsed();
fixture.detectChanges();
expect(comp.time).toEqual({ minutes: '00', seconds: '00' });
});
it('time is properly calculated', () => {
comp.duration = 9;
comp.calculateTime();
fixture.detectChanges();
expect(comp.time).toEqual({ minutes: '00', seconds: '09' });
});
it('time is properly calculated', () => {
comp.duration = 9;
comp.element.currentTime = 9;
comp.calculateElapsed();
fixture.detectChanges();
expect(comp.time).toEqual({ minutes: '00', seconds: '00' });
});
it('call play on togglepause', () => {
spyOn(comp.element, 'play').and.callThrough();
comp.togglePause();
fixture.detectChanges();
expect(comp.element.play).toHaveBeenCalled();
});
it('moves over time depending on the event', () => {
comp.element.currentTime = 11;
fixture.detectChanges();
comp.moveToTime(2);
fixture.detectChanges();
expect(comp.element.currentTime).toBe(13);
});
it('execute control should call controls', () => {
comp.element.currentTime = 11;
fixture.detectChanges();
let e: any = {};
e.preventDefault = () => {};
e.keyCode = 37;
comp.executeControl(e);
fixture.detectChanges();
expect(comp.element.currentTime).toBe(9);
});
it('execute control should call controls', () => {
comp.element.currentTime = 11;
fixture.detectChanges();
let e: any = {};
e.preventDefault = () => {};
e.keyCode = 39;
comp.executeControl(e);
fixture.detectChanges();
expect(comp.element.currentTime).toBe(13);
});
it('execute control should call controls', () => {
comp.element.currentTime = 11;
spyOn(comp.element, 'play').and.callThrough();
fixture.detectChanges();
let e: any = {};
e.preventDefault = () => {};
e.keyCode = 32;
comp.executeControl(e);
fixture.detectChanges();
expect(comp.element.play).toHaveBeenCalled();
});
it('should calculate remaining', () => {
comp.element.currentTime = 11;
comp.duration = 111;
fixture.detectChanges();
comp.calculateRemaining();
fixture.detectChanges();
expect(comp.remaining).toBeNull();
});
it('should calculate remaining', () => {
comp.element.currentTime = 3;
comp.duration = 111;
fixture.detectChanges();
comp.calculateRemaining();
fixture.detectChanges();
expect(comp.elapsed).toEqual({ minutes: '00', seconds: '00' });
});
it('should calculate elapsed', () => {
comp.element.currentTime = 11;
comp.duration = 111;
fixture.detectChanges();
comp.calculateElapsed();
fixture.detectChanges();
expect(comp.elapsed).toEqual({ minutes: '00', seconds: 11 });
});
});
import {
Component,
Input,
ElementRef,
ChangeDetectorRef,
OnDestroy,
OnInit,
} from '@angular/core';
import { MindsPlayerInterface } from '../players/player.interface';
@Component({
selector: 'm-video--progress-bar',
templateUrl: 'progress-bar.component.html',
})
export class MindsVideoProgressBar implements OnInit, OnDestroy {
@Input('player') playerRef: MindsPlayerInterface;
element: HTMLVideoElement;
time: { minutes: any; seconds: any } = {
minutes: '00',
seconds: '00',
};
elapsed: { minutes: any; seconds: any } = {
minutes: '00',
seconds: '00',
};
remaining: { minutes: any; seconds: any } | null = null;
seek_interval;
seeked: number = 0;
keyPressListener: any;
duration: number = 0;
constructor(private cd: ChangeDetectorRef, public _element: ElementRef) {}
protected _loadedMetadata = () => {
this.duration = this.element.duration;
this.calculateTime();
};
ngOnInit() {
this.keyPressListener = this.executeControl.bind(this);
this.bindToElement();
}
bindToElement() {
if (this.element) {
this.element.removeEventListener('loadedmetadata', this._loadedMetadata);
}
if (this.playerRef.getPlayer()) {
this.element = this.playerRef.getPlayer();
this.element.addEventListener('loadedmetadata', this._loadedMetadata);
if (this.element.readyState > 0) {
this._loadedMetadata();
}
}
}
ngOnDestroy() {
this.element.removeEventListener('loadedmetadata', this._loadedMetadata);
clearInterval(this.seek_interval);
}
calculateTime() {
const seconds = this.duration;
this.time.minutes = Math.floor(seconds / 60);
if (parseInt(this.time.minutes) < 10)
this.time.minutes = '0' + this.time.minutes;
this.time.seconds = Math.floor(seconds % 60);
if (parseInt(this.time.seconds) < 10)
this.time.seconds = '0' + this.time.seconds;
}
calculateElapsed() {
const seconds = this.element.currentTime;
this.elapsed.minutes = Math.floor(seconds / 60);
if (parseInt(this.elapsed.minutes) < 10)
this.elapsed.minutes = '0' + this.elapsed.minutes;
this.elapsed.seconds = Math.floor(seconds % 60);
if (parseInt(this.elapsed.seconds) < 10)
this.elapsed.seconds = '0' + this.elapsed.seconds;
}
calculateRemaining() {
if (!this.duration || this.element.paused) {
this.remaining = null;
return;
}
const seconds = this.duration - this.element.currentTime;
this.remaining = { seconds: 0, minutes: 0 };
this.remaining.minutes = Math.floor(seconds / 60);
if (parseInt(this.remaining.minutes) < 10)
this.remaining.minutes = '0' + this.remaining.minutes;
this.remaining.seconds = Math.floor(seconds % 60);
if (parseInt(this.remaining.seconds) < 10)
this.remaining.seconds = '0' + this.remaining.seconds;
}
seek(e) {
e.preventDefault();
const seeker = e.target;
const seek = e.offsetX / seeker.offsetWidth;
this.element.currentTime = this.seekerToSeconds(seek);
}
seekerToSeconds(seek) {
const duration = this.element.duration;
return duration * seek;
}
getSeeker() {
if (this.seek_interval) clearInterval(this.seek_interval);
this.seek_interval = setInterval(() => {
this.seeked = (this.element.currentTime / this.element.duration) * 100;
this.calculateElapsed();
this.calculateRemaining();
this.cd.markForCheck();
}, 100);
}
stopSeeker() {
clearInterval(this.seek_interval);
}
enableKeyControls() {
window.removeEventListener('keydown', this.keyPressListener, true);
window.addEventListener('keydown', this.keyPressListener, true);
}
disableKeyControls() {
window.removeEventListener('keydown', this.keyPressListener, true);
}
togglePause() {
if (this.element.paused === false) {
this.element.pause();
} else {
this.element.play();
}
}
moveToTime(offset) {
this.element.currentTime = this.element.currentTime + offset;
}
executeControl(e) {
e.preventDefault();
switch (e.keyCode) {
case 39:
this.moveToTime(2);
break;
case 37:
this.moveToTime(-2);
break;
case 32:
this.togglePause();
break;
}
}
}
<div
*ngIf="qualities?.length > 1"
class="m-video--quality-control-wrapper"
title="Video quality"
i18n-title="@@VIDEO__QUALITY_SELECTOR__VIDEO_QUALITY_TOOLTIP"
>
<i class="material-icons">high_quality</i>
<ul class="m-video--quality-control">
<li
*ngFor="let quality of qualities"
(click)="selectQuality(quality)"
[ngClass]="{ 'm-video--selected-quality': current === quality }"
>
{{ quality }}
</li>
</ul>
</div>
.m-video--quality-control-wrapper {
position: relative;
}
.m-video--quality-control:hover {
display: block;
}
.m-video--quality-control-wrapper:hover .m-video--quality-control {
display: block;
}
.m-video--quality-control {
display: none;
bottom: 24px;
left: 0px;
z-index: 1;
position: absolute;
margin: 0;
list-style: none;
padding: 8px;
cursor: pointer;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.4);
}
}
.m-video--selected-quality {
font-weight: bold;
}
///<reference path="../../../../../../../node_modules/@types/jasmine/index.d.ts"/>
import {
async,
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import {
Component,
DebugElement,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { CommonModule as NgCommonModule } from '@angular/common';
import { MindsVideoQualitySelector } from './quality-selector.component';
describe('MindsVideoQualitySelector', () => {
let comp: MindsVideoQualitySelector;
let fixture: ComponentFixture<MindsVideoQualitySelector>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MindsVideoQualitySelector], // declare the test component
imports: [FormsModule, RouterTestingModule, NgCommonModule],
}).compileComponents(); // compile template and css
}));
beforeEach(done => {
window.addEventListener = () => {};
jasmine.MAX_PRETTY_PRINT_DEPTH = 10;
jasmine.clock().uninstall();
jasmine.clock().install();
fixture = TestBed.createComponent(MindsVideoQualitySelector);
comp = fixture.componentInstance;
comp.qualities = ['720', '360', '128'];
fixture.detectChanges();
if (fixture.isStable()) {
done();
} else {
fixture.whenStable().then(() => {
done();
});
}
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should render a hidden slider, should show as many options as there srcs, and first one should be selected', () => {
comp.current = '720';
fixture.detectChanges();
const wrapper = fixture.debugElement.query(
By.css('.m-video--quality-control-wrapper')
);
const control = fixture.debugElement.query(
By.css('.m-video--quality-control')
);
const icon = fixture.debugElement.query(By.css('.material-icons'));
const selectedOption = fixture.debugElement.query(
By.css('.m-video--selected-quality')
);
expect(control).not.toBeNull();
expect(icon).not.toBeNull();
expect(wrapper).not.toBeNull();
expect(selectedOption).not.toBeNull();
expect(selectedOption.nativeElement.innerText).toBe('720');
});
it('should change quality', () => {
comp.current = '720';
fixture.detectChanges();
const selectedOptions = fixture.debugElement.queryAll(By.css('li'));
selectedOptions[1].nativeElement.click();
fixture.detectChanges();
const selectedOption = fixture.debugElement.query(
By.css('.m-video--selected-quality')
);
expect(selectedOption).not.toBeNull();
expect(selectedOption.nativeElement.innerText).toBe('360');
});
});
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'm-video--quality-selector',
templateUrl: 'quality-selector.component.html',
})
export class MindsVideoQualitySelector {
@Input() current: string;
@Output('select') selectEmitter: EventEmitter<string> = new EventEmitter();
qualities: string[] = [];
@Input('qualities') set _qualities(qualities) {
if (!qualities || !qualities.length) {
this.qualities = [];
return;
}
this.qualities = qualities
.map(quality => quality)
.sort((a, b) => parseFloat(b) - parseFloat(a));
}
selectQuality(quality) {
this.current = quality;
this.selectEmitter.emit(quality);
}
}
export const SOURCE_CANDIDATE_PICK_LINEAR = 1;
export const SOURCE_CANDIDATE_PICK_ZIGZAG = 2;
export class SourceCandidates {
protected candidates: { [index: string]: any[] } = {};
protected blacklist = [];
protected lastBlacklistedType;
setSource(type: string, values: any[]) {
this.candidates[type] = values;
}
markAsClean() {
this.blacklist = [];
this.lastBlacklistedType = void 0;
}
setAsBlacklisted(type: string, value: any) {
this.blacklist.push({ type, value });
this.lastBlacklistedType = type;
// console.log('[sourcecandidates] blacklisted', { type, value }, JSON.stringify(this), this);
}
isBlacklisted(type: string, value: any) {
return (
this.blacklist.findIndex(
item => item.type === type && item.value === value
) > -1
);
}
empty() {
this.candidates = {};
this.markAsClean();
}
pick(
typePriorities: string[],
strategy: number = SOURCE_CANDIDATE_PICK_LINEAR
): { type; value } {
switch (strategy) {
case SOURCE_CANDIDATE_PICK_ZIGZAG:
return this._pickZigZag(typePriorities);
case SOURCE_CANDIDATE_PICK_LINEAR:
default:
return this._pickLinear(typePriorities);
}
}
private _pickZigZag(typePriorities: string[]): { type; value } {
const reorderedTypePriorities = typePriorities;
if (this.lastBlacklistedType) {
const index: number = reorderedTypePriorities.findIndex(
type => type === this.lastBlacklistedType
);
if (index > -1) {
reorderedTypePriorities.push(
...reorderedTypePriorities.splice(index, 1)
);
}
}
return this._pickLinear(reorderedTypePriorities);
}
private _pickLinear(typePriorities: string[]): { type; value } {
for (let type of typePriorities) {
if (!this.candidates[type]) {
continue;
}
const candidates = this.candidates[type].filter(
value => !this.isBlacklisted(type, value)
);
if (candidates.length > 0) {
return {
type,
value: candidates[0],
};
}
}
return void 0;
}
}
<ng-container *ngIf="current">
<m-video--direct-http-player
*ngIf="current.type === 'direct-http' || true"
class="m-video--player"
[src]="current.src"
[poster]="poster"
[muted]="muted"
[autoplay]="autoplay"
[guid]="guid"
(onPlay)="onPlay()"
(onPause)="onPause()"
(onEnd)="onEnd()"
(onError)="onError()"
(onCanPlayThrough)="onCanPlayThrough()"
(onLoadedMetadata)="loadedMetadata()"
(click)="clickedVideo()"
#player
></m-video--direct-http-player>
<m-video--torrent-player
*ngIf="current.type === 'torrent' && false"
class="m-video--player"
[src]="current.src"
[poster]="poster"
[muted]="muted"
[autoplay]="autoplay"
[guid]="guid"
(onPlay)="onPlay()"
(onPause)="onPause()"
(onEnd)="onEnd()"
(onError)="onError()"
(onCanPlayThrough)="onCanPlayThrough()"
(onLoadedMetadata)="loadedMetadata()"
(click)="clickedVideo()"
#player
></m-video--torrent-player>
<ng-container *ngIf="playerRef">
<i
*ngIf="
(!playerRef.isPlaying() && !playerRef.isLoading()) ||
(shouldPlayInModal && !playerRef.isPlaying())
"
class="material-icons minds-video-play-icon"
(click)="clickedVideo()"
>play_circle_outline</i
>
<ng-content></ng-content>
<div *ngIf="transcoding" class="minds-video-bar-top">
<span i18n="@@MEDIA__VIDEO__TRANSCODING_NOTICE"
>The video is being transcoded</span
>
</div>
<div *ngIf="transcodingError" class="minds-video-bar-top">
<span i18n="@@MEDIA__VIDEO__TRANSCODING_NOTICE">{{
transcodingError
}}</span>
</div>
<div
class="minds-video-bar-full"
[@fadeAnimation]="showControls ? 'in' : 'out'"
>
<i class="material-icons" (click)="controlBarToggle($event)">{{
playerRef.isPlaying() || playerRef.isLoading() ? 'pause' : 'play_arrow'
}}</i>
<ng-template #loadingSpinner>
<div class="mdl-spinner mdl-js-spinner is-active" [mdl]></div>
</ng-template>
<m-video--progress-bar
#progressBar
[player]="playerRef"
></m-video--progress-bar>
<m-video--volume-slider
#volumeSlider
[player]="playerRef"
></m-video--volume-slider>
<a
class="material-icons m-video-full-page minds-video--open-new"
*ngIf="guid && !isModal"
[routerLink]="['/media', guid]"
target="_blank"
(click)="playerRef.pause()"
>
lightbulb_outline
</a>
<ng-container *ngIf="current.type === 'torrent'">
<a
class="mdl-color-text--white m-video--info-button"
(click)="toggleTorrentInfo()"
>
<m-tooltip
icon="people_outline"
anchor="bottom"
i18n="@@MEDIA__VIDEO__PEERS_LABEL"
>Peers</m-tooltip
>
<span>{{ playerRef.getInfo().peers | abbr }}</span>
</a>
<a
class="mdl-color-text--white m-video--info-button"
(click)="toggleTorrentInfo()"
>
<m-tooltip
icon="arrow_downward"
anchor="bottom"
i18n="@@MEDIA__VIDEO__DOWNLOADING_LABEL"
>Downloading</m-tooltip
>
<span>{{ playerRef.getInfo().dlspeed | abbr: 2:true }}B/s</span>
</a>
<a
class="mdl-color-text--white m-video--info-button"
(click)="toggleTorrentInfo()"
>
<m-tooltip
icon="arrow_upward"
anchor="bottom"
i18n="@@MEDIA__VIDEO__UPLOADING_LABEL"
>Uploading</m-tooltip
>
<span>{{ playerRef.getInfo().ulspeed | abbr: 2:true }}B/s</span>
</a>
</ng-container>
<m-video--quality-selector
*ngIf="availableQualities?.length > 1"
[current]="currentQuality"
[qualities]="availableQualities"
(select)="selectedQuality($event)"
></m-video--quality-selector>
<i
*ngIf="!isModal && !isActivity"
class="material-icons"
(click)="toggleFullscreen($event)"
>tv</i
>
</div>
<div
class="m-video--torrent-info"
*ngIf="torrentInfo && current.type === 'torrent'"
>
<div class="m-video--torrent-info--cell">
<i class="material-icons">file_download</i>
<span>{{ playerRef.getInfo().progress * 100 | number: '1.2-2' }}%</span>
</div>
<div class="m-video--torrent-info--cell">
<i class="material-icons">people</i>
<span>{{ playerRef.getInfo().peers | number }}</span>
</div>
<div class="m-video--torrent-info--cell">
<i class="material-icons">arrow_downward</i>
<span
>{{ playerRef.getInfo().dl | abbr: 2:true }}B ({{
playerRef.getInfo().dlspeed | abbr: 2:true
}}B/s)</span
>
</div>
<div class="m-video--torrent-info--cell">
<i class="material-icons">arrow_upward</i>
<span
>{{ playerRef.getInfo().ul | abbr: 2:true }}B ({{
playerRef.getInfo().ulspeed | abbr: 2:true
}}B/s)</span
>
</div>
</div>
</ng-container>
</ng-container>
/**
* Minds video components
*/
m-video {
position: relative;
display: block;
&.clickable {
cursor: pointer;
}
&:hover {
.minds-video-play-icon {
opacity: 1;
}
}
video {
width: 100%;
}
.m-video--player {
display: block;
}
.minds-video-bar-min {
position: absolute;
bottom: 8px;
left: 8px;
width: auto;
padding: 4px 8px;
font-size: 11px;
border-radius: 3px;
font-weight: bold;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.4);
color: themed($m-white-always);
}
}
.minds-video-bar-top {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
padding: $minds-padding;
text-align: center;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.4);
color: themed($m-white-always);
}
}
.minds-video-play-icon {
opacity: 0.8;
display: block;
text-align: center;
top: 50%;
transform: translateY(-50%);
font-size: 100px;
position: absolute;
cursor: pointer;
width: 100%;
transition: opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
@include m-theme() {
color: themed($m-white-always);
text-shadow: 0 0 3px rgba(themed($m-black-always), 0.6);
}
}
.minds-video-bar-full {
opacity: 0;
visibility: hidden;
display: flex;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
box-sizing: border-box;
text-align: center;
align-items: center;
@include m-theme() {
color: themed($m-white-always);
background-color: rgba(themed($m-black-always), 0.4);
}
.m-video-full-page {
@include m-theme() {
color: themed($m-white-always);
}
}
.mdl-spinner {
margin: 0 8px;
}
> m-video--quality-selector,
> a,
> i {
cursor: pointer;
text-decoration: none;
padding: 12px;
}
}
&:hover {
.minds-video-bar-min {
display: none;
}
// .minds-video-bar-full{
// display: flex;
// }
}
.m-video--torrent-info {
position: absolute;
top: 8px;
left: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 8px;
font-size: 12px;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.65);
color: rgba(themed($m-white-always), 0.65);
}
}
.m-video--torrent-info--cell {
display: flex;
align-items: center;
margin: 4px 0;
&:last-child {
margin-right: 0;
}
.material-icons {
font-size: 16px;
line-height: 1;
margin-right: 4px;
@include m-theme() {
color: rgba(themed($m-white-always), 0.85);
}
}
}
.m-video--info-button {
display: flex;
align-items: center;
line-height: 1;
margin-right: 4px;
padding: 12px 4px !important;
> .material-icons {
margin-right: 4px;
}
> span {
font-size: 12px;
font-weight: 300;
}
}
}
......@@ -4,15 +4,8 @@ import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '../../../../common/common.module';
import { MindsVideoProgressBar } from './progress-bar/progress-bar.component';
import { MindsVideoQualitySelector } from './quality-selector/quality-selector.component';
import { MindsVideoVolumeSlider } from './volume-slider/volume-slider.component';
import { VideoAdsDirective } from './ads.directive';
import { VideoAds, MindsVideoComponent } from './video.component';
import { MindsVideoDirectHttpPlayer } from './players/direct-http.component';
import { MindsVideoTorrentPlayer } from './players/torrent.component';
import { MindsVideoPlayerComponent } from '../video-player/player.component';
import { PlyrModule } from 'ngx-plyr';
@NgModule({
imports: [
......@@ -20,17 +13,9 @@ import { MindsVideoTorrentPlayer } from './players/torrent.component';
CommonModule,
FormsModule,
RouterModule.forChild([]),
PlyrModule,
],
declarations: [
VideoAdsDirective,
VideoAds,
MindsVideoComponent,
MindsVideoDirectHttpPlayer,
MindsVideoTorrentPlayer,
MindsVideoProgressBar,
MindsVideoQualitySelector,
MindsVideoVolumeSlider,
],
exports: [VideoAdsDirective, VideoAds, MindsVideoComponent],
declarations: [MindsVideoPlayerComponent],
exports: [MindsVideoPlayerComponent],
})
export class VideoModule {}
<div class="m-video--volume-control-wrapper" *ngIf="element">
<i
class="material-icons"
*ngIf="element.volume === 0 || element.muted"
(click)="element.muted = false"
>volume_off</i
>
<i
class="material-icons"
*ngIf="element.volume > 0.01 && element.volume <= 0.1 && !element.muted"
(click)="element.muted = true"
>volume_mute</i
>
<i
class="material-icons"
*ngIf="element.volume > 0.1 && element.volume < 0.9 && !element.muted"
(click)="element.muted = true"
>volume_down</i
>
<i
class="material-icons"
*ngIf="element.volume >= 0.9 && !element.muted"
(click)="element.muted = true"
>volume_up</i
>
<div class="m-video--volume-control">
<div class="m-video--volume-control--background"></div>
<input
type="range"
[(ngModel)]="element.volume"
class="m-video--volume-control-selector"
min="0"
max="1"
step="0.05"
/>
</div>
</div>
m-video--volume-slider {
outline: none;
cursor: pointer;
text-decoration: none;
.m-video--volume-control-wrapper {
position: relative;
display: block;
i {
padding: 12px;
}
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
outline: none;
height: 16px;
width: 16px;
border-radius: 16px;
cursor: pointer;
@include m-theme() {
background-color: themed($m-white-always);
}
}
input[type='range']::-moz-range-thumb {
height: 16px;
width: 16px;
outline: none;
border-radius: 16px;
cursor: pointer;
@include m-theme() {
background-color: themed($m-white-always);
}
}
input[type='range']::-ms-thumb {
height: 16px;
outline: none;
width: 16px;
border-radius: 16px;
cursor: pointer;
@include m-theme() {
background-color: themed($m-white-always);
}
}
.m-video--volume-control-selector {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none; /* WebKit */
-webkit-transform: rotate(270deg);
-moz-transform: rotate(270deg);
-o-transform: rotate(270deg);
-ms-transform: rotate(270deg);
transform: rotate(270deg);
outline: none;
height: 2px;
width: 70px;
position: absolute;
top: 35px;
right: -18px;
@include m-theme() {
background-color: themed($m-white-always);
}
}
.m-video--volume-control {
display: none;
bottom: 32px;
left: calc(50% - 20px);
z-index: 1;
width: 40px;
height: 80px;
position: absolute;
margin: 0;
}
.m-video--volume-control--background {
width: 40px;
height: 72px;
position: absolute;
left: calc(50% - 20px);
bottom: 16px;
@include m-theme() {
background-color: rgba(themed($m-black-always), 0.4);
}
}
.m-video--volume-control:hover {
display: block;
}
.m-video--volume-control-wrapper:hover .m-video--volume-control {
display: block;
}
}
///<reference path="../../../../../../../node_modules/@types/jasmine/index.d.ts"/>
import {
async,
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import {
Component,
DebugElement,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { CommonModule as NgCommonModule } from '@angular/common';
import { MindsVideoVolumeSlider } from './volume-slider.component';
import { MindsPlayerInterface } from '../players/player.interface';
class MindsVideoDirectHttpPlayerMock implements MindsPlayerInterface {
@Input() muted: boolean;
@Input() poster: string;
@Input() autoplay: boolean;
@Input() src: string;
@Output() onPlay: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onPause: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onEnd: EventEmitter<HTMLVideoElement> = new EventEmitter();
@Output() onError: EventEmitter<{
player: HTMLVideoElement;
e;
}> = new EventEmitter();
getPlayer = (): HTMLVideoElement => {
return null;
};
play = () => {};
pause = () => {};
toggle = () => {};
resumeFromTime = () => {};
isLoading = (): boolean => {
return false;
};
isPlaying = (): boolean => {
return false;
};
requestFullScreen = jasmine.createSpy('requestFullScreen').and.stub();
getInfo = () => {};
}
describe('MindsVideoVolumeSlider', () => {
let comp: MindsVideoVolumeSlider;
let fixture: ComponentFixture<MindsVideoVolumeSlider>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MindsVideoVolumeSlider], // declare the test component
imports: [FormsModule, RouterTestingModule, NgCommonModule],
}).compileComponents(); // compile template and css
}));
beforeEach(done => {
window.addEventListener = () => {};
jasmine.MAX_PRETTY_PRINT_DEPTH = 10;
jasmine.clock().uninstall();
jasmine.clock().install();
fixture = TestBed.createComponent(MindsVideoVolumeSlider);
comp = fixture.componentInstance;
const video = document.createElement('video');
video.src = 'thisisavideo.mp4';
comp.element = video;
const playerRef = new MindsVideoDirectHttpPlayerMock();
playerRef.getPlayer = () => {
return video;
};
comp.playerRef = playerRef;
fixture.detectChanges();
if (fixture.isStable()) {
done();
} else {
fixture.whenStable().then(() => {
done();
});
}
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should render a hidden slider', () => {
const wrapper = fixture.debugElement.query(
By.css('.m-video--volume-control-wrapper')
);
const control = fixture.debugElement.query(
By.css('.m-video--volume-control')
);
const icon = fixture.debugElement.query(By.css('.material-icons'));
const input = fixture.debugElement.query(By.css('input'));
expect(control).not.toBeNull();
expect(input).not.toBeNull();
expect(icon).not.toBeNull();
expect(wrapper).not.toBeNull();
});
});
......@@ -17,6 +17,12 @@
}
}
.m-revenueOptions__input {
margin: 16px 0;
width: 300px;
border-radius: 36px;
}
.m-revenue--options-payout-method-bank {
p {
font-family: 'Roboto', Helvetica, sans-serif;
......
This diff is collapsed.
This diff is collapsed.