Merge pull request #118 from voc0der/feature/autoplay

Add autoplay and repeat controls in player
pull/1163/head
voc0der 2 months ago committed by GitHub
commit 0afb25c105
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -255,20 +255,31 @@ export class RecentVideosComponent implements OnInit {
navigateToFile(file: DatabaseFile, new_tab: boolean): void {
localStorage.setItem('player_navigator', this.router.url);
if (file.sub_id) {
if (!new_tab) {
this.router.navigate(['/player', {uid: file.uid, type: file.isAudio ? 'audio' : 'video'}]);
} else {
window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'}`);
}
const routeParams = this.getPlayerRouteParams(file);
if (!new_tab) {
this.router.navigate(['/player', routeParams]);
} else {
// normal files
if (!new_tab) {
this.router.navigate(['/player', {type: file.isAudio ? 'audio' : 'video', uid: file.uid}]);
} else {
window.open(`/#/player;type=${file.isAudio ? 'audio' : 'video'};uid=${file.uid}`);
}
const routeURL = this.router.serializeUrl(this.router.createUrlTree(['/player', routeParams]));
window.open(`/#${routeURL}`);
}
}
getPlayerRouteParams(file: DatabaseFile): Record<string, string> {
const routeParams: Record<string, string> = {
type: file.isAudio ? 'audio' : 'video',
uid: file.uid,
queue_sort_by: this.sortProperty,
queue_sort_order: this.descendingMode ? '-1' : '1',
queue_file_type_filter: this.getFileTypeFilter(),
queue_favorite_filter: '' + this.getFavoriteFilter()
};
if (this.search_mode && this.search_text?.trim()) {
routeParams.queue_search = this.search_text.trim();
}
if (this.sub_id) {
routeParams.queue_sub_id = this.sub_id;
}
return routeParams;
}
goToSubscription(file: DatabaseFile): void {

@ -46,6 +46,25 @@
position: relative;
}
.action-column {
display: flex;
justify-content: flex-end;
}
.action-buttons-row {
align-items: center;
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
overflow-x: auto;
white-space: nowrap;
width: 100%;
}
.action-buttons-row button {
flex: 0 0 auto;
}
.save-button {
right: 25px;
position: fixed;
@ -93,4 +112,12 @@
position: absolute;
right: 20px;
bottom: 75px;
}
}
.playback-mode-button {
opacity: 0.6;
}
.playback-mode-button.active {
opacity: 1;
}

@ -32,37 +32,45 @@
</p>
}
</div>
<div class="col-2">
@if (db_playlist) {
<span class="buttons">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></button>
@if (downloading) {
<mat-spinner class="spinner" [diameter]="35"></mat-spinner>
}
@if ((!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto) {
<button (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
}
</span>
}
@if (db_file) {
<span class="buttons">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon></button>
@if (downloading) {
<mat-spinner class="spinner" [diameter]="35"></mat-spinner>
}
@if (!postsService.isLoggedIn || postsService.permissions.includes('sharing')) {
<button (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
}
</span>
}
@if (db_file || playlist[currentIndex]) {
}
@if (db_file || db_playlist) {
<button (click)="openFileInfoDialog()" mat-icon-button><mat-icon>info</mat-icon></button>
}
@if (db_file && db_file.url.includes('twitch.tv')) {
<button (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
}
<div class="col-2 action-column">
<span class="action-buttons-row">
@if (db_playlist) {
<span class="buttons">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></button>
@if (downloading) {
<mat-spinner class="spinner" [diameter]="35"></mat-spinner>
}
@if ((!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto) {
<button (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
}
</span>
}
@if (db_file) {
<span class="buttons">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon></button>
@if (downloading) {
<mat-spinner class="spinner" [diameter]="35"></mat-spinner>
}
@if (!postsService.isLoggedIn || postsService.permissions.includes('sharing')) {
<button (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
}
</span>
}
@if (db_file || playlist[currentIndex]) {
<button class="playback-mode-button" [class.active]="autoplay_enabled" (click)="toggleAutoplay()" matTooltip="Autoplay" i18n-matTooltip="Autoplay toggle tooltip" mat-icon-button>
<mat-icon>playlist_play</mat-icon>
</button>
<button class="playback-mode-button" [class.active]="repeat_enabled" (click)="toggleRepeat()" matTooltip="Repeat current video" i18n-matTooltip="Repeat current video toggle tooltip" mat-icon-button>
<mat-icon>repeat_one</mat-icon>
</button>
}
@if (db_file || db_playlist) {
<button (click)="openFileInfoDialog()" mat-icon-button><mat-icon>info</mat-icon></button>
}
@if (db_file && db_file.url.includes('twitch.tv')) {
<button (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
}
</span>
</div>
</div>
</div>

@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { DatabaseFile, FileType, Playlist } from '../../api-types';
import { DatabaseFile, FileType, FileTypeFilter, Playlist, Sort } from '../../api-types';
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
import { saveAs } from 'file-saver';
@ -20,6 +20,9 @@ export interface IMedia {
uid?: string;
}
const AUTOPLAY_STORAGE_KEY = 'player_autoplay_enabled';
const REPEAT_STORAGE_KEY = 'player_repeat_enabled';
@Component({
selector: 'app-player',
templateUrl: './player.component.html',
@ -51,6 +54,12 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video
timestamp = null;
auto = null;
queue_sort_by = 'registered';
queue_sort_order = -1;
queue_file_type_filter: FileTypeFilter = null;
queue_favorite_filter = false;
queue_search = null;
queue_sub_id = null;
db_playlist: Playlist = null;
db_file: DatabaseFile = null;
@ -69,9 +78,16 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
save_volume_timer = null;
original_volume = null;
autoplay_enabled = false;
repeat_enabled = false;
autoplay_queue_loading = false;
autoplay_queue_initialized = false;
pending_autoplay_advance = false;
@ViewChild('twitchchat') twitchChat: TwitchChatComponent;
ngOnInit(): void {
this.initPlaybackModeToggles();
this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
this.uid = this.route.snapshot.paramMap.get('uid');
this.sub_id = this.route.snapshot.paramMap.get('sub_id');
@ -80,6 +96,12 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.uuid = this.route.snapshot.paramMap.get('uuid');
this.timestamp = this.route.snapshot.paramMap.get('timestamp');
this.auto = this.route.snapshot.paramMap.get('auto');
this.queue_sort_by = this.route.snapshot.paramMap.get('queue_sort_by') ?? 'registered';
this.queue_sort_order = this.parseSortOrder(this.route.snapshot.paramMap.get('queue_sort_order'));
this.queue_file_type_filter = this.parseFileTypeFilter(this.route.snapshot.paramMap.get('queue_file_type_filter'));
this.queue_favorite_filter = this.route.snapshot.paramMap.get('queue_favorite_filter') === 'true';
this.queue_search = this.route.snapshot.paramMap.get('queue_search');
this.queue_sub_id = this.route.snapshot.paramMap.get('queue_sub_id');
// loading config
if (this.postsService.initialized) {
@ -189,50 +211,33 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
});
}
parseFileNames(): void {
parseFileNames(): void {
this.playlist = [];
this.autoplay_queue_initialized = false;
if (!this.queue_file_type_filter && this.db_file) {
this.queue_file_type_filter = this.db_file.isAudio ? FileTypeFilter.AUDIO_ONLY : FileTypeFilter.VIDEO_ONLY;
}
for (let i = 0; i < this.uids.length; i++) {
const file_obj = this.playlist_id ? this.file_objs[i]
: this.sub_id ? this.subscription['videos'][i]
: this.db_file;
: this.sub_id ? this.subscription['videos'][i]
: this.db_file;
const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4'
const baseLocation = 'stream/';
let fullLocation = this.baseStreamPath + baseLocation + `?test=test&uid=${file_obj['uid']}`;
if (this.postsService.isLoggedIn) {
fullLocation += `&jwt=${this.postsService.token}`;
} else if (this.postsService.auth_token) {
fullLocation += `&apiKey=${this.postsService.auth_token}`;
}
if (this.uuid) {
fullLocation += `&uuid=${this.uuid}`;
}
if (this.sub_id) {
fullLocation += `&sub_id=${this.sub_id}`;
} else if (this.playlist_id) {
fullLocation += `&playlist_id=${this.playlist_id}`;
}
const mediaObject: IMedia = {
title: file_obj['title'],
src: fullLocation,
type: mime_type,
label: file_obj['title'],
url: file_obj['url'],
uid: file_obj['uid']
}
const mediaObject: IMedia = this.createMediaObject(file_obj);
this.playlist.push(mediaObject);
}
if (this.db_playlist && this.db_playlist['randomize_order']) {
this.shuffleArray(this.playlist);
}
const currentUID = this.currentItem?.uid;
const currentIndex = currentUID ? this.playlist.findIndex(file_obj => file_obj.uid === currentUID) : this.currentIndex;
this.currentIndex = currentIndex >= 0 ? currentIndex : 0;
this.currentItem = this.playlist[this.currentIndex];
this.original_playlist = JSON.stringify(this.playlist);
this.show_player = true;
if (this.autoplay_enabled) {
this.ensureAutoplayQueueReady();
}
}
onPlayerReady(api: VgApiService): void {
@ -263,13 +268,23 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}
nextVideo(): void {
if (this.currentIndex === this.playlist.length - 1) {
// dont continue playing
// this.currentIndex = 0;
return;
if (this.repeat_enabled) {
this.repeatCurrentVideo();
return;
}
if (!this.autoplay_enabled) {
return;
}
this.updateCurrentItem(this.playlist[this.currentIndex], ++this.currentIndex);
if (this.advanceToNextVideo()) {
return;
}
if (this.shouldAutoloadWholeLibraryQueue()) {
this.pending_autoplay_advance = true;
this.ensureAutoplayQueueReady();
}
}
updateCurrentItem(newCurrentItem: IMedia, newCurrentIndex: number) {
@ -282,9 +297,33 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}
onClickPlaylistItem(item: IMedia, index: number): void {
this.currentIndex = index;
this.currentItem = item;
this.updateCurrentItem(this.currentItem, this.currentIndex);
this.updateCurrentItem(item, index);
}
toggleAutoplay(): void {
this.autoplay_enabled = !this.autoplay_enabled;
if (this.autoplay_enabled) {
this.repeat_enabled = false;
this.saveRepeatMode();
this.ensureAutoplayQueueReady();
} else {
this.pending_autoplay_advance = false;
this.autoplay_queue_loading = false;
this.collapseAutoplayQueueToCurrentItem();
}
this.saveAutoplayMode();
}
toggleRepeat(): void {
this.repeat_enabled = !this.repeat_enabled;
if (this.repeat_enabled) {
this.autoplay_enabled = false;
this.saveAutoplayMode();
this.pending_autoplay_advance = false;
this.autoplay_queue_loading = false;
this.collapseAutoplayQueueToCurrentItem();
}
this.saveRepeatMode();
}
getFileNames(): string[] {
@ -405,6 +444,161 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.api.playbackRate = speed;
}
initPlaybackModeToggles(): void {
this.autoplay_enabled = localStorage.getItem(AUTOPLAY_STORAGE_KEY) === 'true';
this.repeat_enabled = localStorage.getItem(REPEAT_STORAGE_KEY) === 'true';
if (this.autoplay_enabled && this.repeat_enabled) {
this.repeat_enabled = false;
this.saveRepeatMode();
}
}
saveAutoplayMode(): void {
localStorage.setItem(AUTOPLAY_STORAGE_KEY, `${this.autoplay_enabled}`);
}
saveRepeatMode(): void {
localStorage.setItem(REPEAT_STORAGE_KEY, `${this.repeat_enabled}`);
}
parseSortOrder(sortOrder: string): number {
return sortOrder === '1' ? 1 : -1;
}
parseFileTypeFilter(fileTypeFilter: string): FileTypeFilter {
if (fileTypeFilter === FileTypeFilter.AUDIO_ONLY || fileTypeFilter === FileTypeFilter.VIDEO_ONLY || fileTypeFilter === FileTypeFilter.BOTH) {
return fileTypeFilter;
}
return null;
}
createMediaObject(file_obj: DatabaseFile): IMedia {
const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4';
const mediaObject: IMedia = {
title: file_obj.title,
src: this.createStreamURL(file_obj.uid),
type: mime_type,
label: file_obj.title,
url: file_obj.url,
uid: file_obj.uid
};
return mediaObject;
}
createStreamURL(uid: string): string {
const baseLocation = 'stream/';
let fullLocation = this.baseStreamPath + baseLocation + `?test=test&uid=${uid}`;
if (this.postsService.isLoggedIn) {
fullLocation += `&jwt=${this.postsService.token}`;
} else if (this.postsService.auth_token) {
fullLocation += `&apiKey=${this.postsService.auth_token}`;
}
if (this.uuid) {
fullLocation += `&uuid=${this.uuid}`;
}
if (this.sub_id) {
fullLocation += `&sub_id=${this.sub_id}`;
} else if (this.playlist_id) {
fullLocation += `&playlist_id=${this.playlist_id}`;
}
return fullLocation;
}
shouldAutoloadWholeLibraryQueue(): boolean {
return this.isSingleFileMode() && this.playlist.length <= 1;
}
isSingleFileMode(): boolean {
return !!this.uid && !this.playlist_id && !this.sub_id;
}
collapseAutoplayQueueToCurrentItem(): void {
if (!this.isSingleFileMode() || !this.autoplay_queue_initialized || !this.currentItem) {
return;
}
this.playlist = [this.currentItem];
this.currentIndex = 0;
this.original_playlist = JSON.stringify(this.playlist);
this.autoplay_queue_initialized = false;
}
ensureAutoplayQueueReady(): void {
if (!this.shouldAutoloadWholeLibraryQueue() || this.autoplay_queue_loading || this.autoplay_queue_initialized) {
return;
}
this.autoplay_queue_loading = true;
const sort: Sort = {
by: this.queue_sort_by,
order: this.queue_sort_order
};
const fileTypeFilter = this.resolveQueueFileTypeFilter();
const textSearch = this.queue_search?.trim() ? this.queue_search.trim() : null;
const queueSubID = this.queue_sub_id || null;
this.postsService.getAllFiles(sort, null, textSearch, fileTypeFilter, this.queue_favorite_filter, queueSubID).subscribe(res => {
if (!this.autoplay_enabled) {
this.autoplay_queue_loading = false;
this.pending_autoplay_advance = false;
return;
}
this.autoplay_queue_loading = false;
const files = res['files'] ?? [];
if (files.length === 0) return;
const current_uid = this.currentItem?.uid || this.uid;
const newPlaylist = files.map(file_obj => this.createMediaObject(file_obj));
const currentIndex = newPlaylist.findIndex(file_obj => file_obj.uid === current_uid);
if (currentIndex === -1) return;
this.playlist = newPlaylist;
this.currentIndex = currentIndex;
this.currentItem = this.playlist[currentIndex];
this.original_playlist = JSON.stringify(this.playlist);
this.autoplay_queue_initialized = true;
if (this.pending_autoplay_advance) {
this.pending_autoplay_advance = false;
this.advanceToNextVideo();
}
}, err => {
console.error('Failed to load autoplay queue');
console.error(err);
this.autoplay_queue_loading = false;
this.pending_autoplay_advance = false;
});
}
resolveQueueFileTypeFilter(): FileTypeFilter {
if (this.queue_file_type_filter) {
return this.queue_file_type_filter;
}
if (this.db_file) {
return this.db_file.isAudio ? FileTypeFilter.AUDIO_ONLY : FileTypeFilter.VIDEO_ONLY;
}
return FileTypeFilter.BOTH;
}
repeatCurrentVideo(): void {
if (!this.api) return;
this.api.seekTime(0);
this.api.play();
}
advanceToNextVideo(): boolean {
const nextIndex = this.currentIndex + 1;
if (nextIndex >= this.playlist.length) {
return false;
}
this.updateCurrentItem(this.playlist[nextIndex], nextIndex);
return true;
}
shuffleArray(array: unknown[]): void {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));

Loading…
Cancel
Save