Add support for videos and audios in gallery view

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/4/head
trivernis 3 years ago
parent 47738b392d
commit 4a0b1eb2ae

@ -1473,8 +1473,8 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "mediarepo-api"
version = "0.11.1"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=d56e2c7406e9b8b15b22bc2714eebff00f63d778#d56e2c7406e9b8b15b22bc2714eebff00f63d778"
version = "0.12.0"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=16b892eab0b3446198601a8f5829d0bd54d2efdf#16b892eab0b3446198601a8f5829d0bd54d2efdf"
dependencies = [
"async-trait",
"chrono",
@ -2309,9 +2309,9 @@ dependencies = [
[[package]]
name = "rmp-ipc"
version = "0.10.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc14d76d6718bdbdf12596ed68f5db46f4fa46fca0bd3acf85f7e347788df3c2"
checksum = "573eb3e2e1008f550b7b5a53053d5ed8378fbda57731fa3739c3cfb18ad667f6"
dependencies = [
"async-trait",
"byteorder",

@ -30,7 +30,7 @@ features = ["env-filter"]
[dependencies.mediarepo-api]
git = "https://github.com/Trivernis/mediarepo-api.git"
rev = "d56e2c7406e9b8b15b22bc2714eebff00f63d778"
rev = "16b892eab0b3446198601a8f5829d0bd54d2efdf"
features = ["tauri-plugin"]
[features]

@ -13,7 +13,7 @@
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.tauri.dev",
"identifier": "net.trivernis.mediarepo",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -24,8 +24,8 @@
"resources": [],
"externalBin": [],
"copyright": "",
"category": "DeveloperTool",
"shortDescription": "",
"category": "Productivity",
"shortDescription": "A media mangagement tool",
"longDescription": "",
"deb": {
"depends": [],

@ -61,6 +61,10 @@ import { ContextMenuComponent } from './components/context-menu/context-menu.com
import {FileContextMenuComponent} from './components/context-menu/file-context-menu/file-context-menu.component';
import {ContentViewerComponent} from './components/file-gallery/content-viewer/content-viewer.component';
import {ImageViewerComponent} from './components/file-gallery/content-viewer/image-viewer/image-viewer.component';
import {VideoViewerComponent} from './components/file-gallery/content-viewer/video-viewer/video-viewer.component';
import {HttpClientModule} from "@angular/common/http";
import { AudioViewerComponent } from './components/file-gallery/content-viewer/audio-viewer/audio-viewer.component';
import { BusyIndicatorComponent } from './components/busy-indicator/busy-indicator.component';
@NgModule({
declarations: [
@ -92,6 +96,9 @@ import { ImageViewerComponent } from './components/file-gallery/content-viewer/i
FileContextMenuComponent,
ContentViewerComponent,
ImageViewerComponent,
VideoViewerComponent,
AudioViewerComponent,
BusyIndicatorComponent,
],
imports: [
BrowserModule,
@ -128,6 +135,7 @@ import { ImageViewerComponent } from './components/file-gallery/content-viewer/i
MatMenuModule,
MatExpansionModule,
MatCheckboxModule,
HttpClientModule,
],
providers: [],
bootstrap: [AppComponent]

@ -0,0 +1,4 @@
<ng-content></ng-content>
<div *ngIf="this.busy" class="busy-indicator-overlay" [class.blur]="this.blurBackground" [class.darken]="this.darkenBackground">
<mat-progress-spinner color="primary" [mode]="mode" [value]="value"></mat-progress-spinner>
</div>

@ -0,0 +1,31 @@
.busy-indicator-overlay {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
z-index: 998;
mat-progress-spinner {
z-index: 999;
margin: auto;
}
}
.busy-indicator-overlay.blur {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.busy-indicator-overlay.darken {
background-color: rgba(0, 0, 0, 0.2);
}
::ng-deep app-busy-indicator {
width: 100%;
height: 100%;
position: relative;
display: block;
}

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BusyIndicatorComponent } from './busy-indicator.component';
describe('BusyIndicatorComponent', () => {
let component: BusyIndicatorComponent;
let fixture: ComponentFixture<BusyIndicatorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BusyIndicatorComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BusyIndicatorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,48 @@
import {Component, Input, OnInit} from '@angular/core';
import {ProgressSpinnerMode} from "@angular/material/progress-spinner";
@Component({
selector: 'app-busy-indicator',
templateUrl: './busy-indicator.component.html',
styleUrls: ['./busy-indicator.component.scss']
})
export class BusyIndicatorComponent {
@Input() busy: boolean = false;
@Input() blurBackground: boolean = false;
@Input() darkenBackground: boolean = false;
@Input() mode: ProgressSpinnerMode = "indeterminate";
@Input() value: number | undefined;
constructor() { }
public setBusy(busy: boolean) {
this.busy = busy;
}
public wrapOperation<T>(operation: Function): T | undefined {
this.setBusy(true)
try {
const result = operation();
this.setBusy(false);
return result;
} catch {
return undefined;
} finally {
this.setBusy(false);
}
}
public async wrapAsyncOperation<T>(operation: Function): Promise<T | undefined> {
this.setBusy(true)
try {
const result = await operation();
this.setBusy(false);
return result;
} catch {
return undefined;
} finally {
this.setBusy(false);
}
}
}

@ -5,6 +5,7 @@ import {clipboard, dialog} from "@tauri-apps/api";
import {FileService} from "../../../services/file/file.service";
import {ErrorBrokerService} from "../../../services/error-broker/error-broker.service";
import {downloadDir} from "@tauri-apps/api/path";
import {FileHelper} from "../../../services/file/file.helper";
@Component({
selector: 'app-file-context-menu',
@ -29,17 +30,8 @@ export class FileContextMenuComponent {
}
public async exportFile(): Promise<void> {
let extension;
if (this.file.mime_type) {
extension = FileContextMenuComponent.getExtensionForMime(this.file.mime_type);
}
const downloadDirectory = await downloadDir();
const suggestionPath = downloadDirectory + this.file.hash + "." + extension;
const path = await FileHelper.getFileDownloadLocation(this.file)
const path = await dialog.save({
defaultPath: suggestionPath,
filters: [{name: this.file.mime_type ?? "All", extensions: [extension ?? "*"]}, {name: "All", extensions: ["*"]}]
});
if (path) {
try {
await this.fileService.saveFile(this.file, path);
@ -48,25 +40,4 @@ export class FileContextMenuComponent {
}
}
}
/**
* Returns the extension for a mime type
* @param {string} mime
* @returns {string | undefined}
* @private
*/
private static getExtensionForMime(mime: string): string | undefined {
let parts = mime.split("/");
if (parts.length === 2) {
const type = parts[0];
const subtype = parts[1];
return FileContextMenuComponent.convertMimeSubtypeToExtension(subtype);
}
return undefined;
}
private static convertMimeSubtypeToExtension(subtype: string): string {
return subtype;
}
}

@ -0,0 +1,4 @@
<div class="audio-container">
<audio controls [src]="this.blobUrl">
</audio>
</div>

@ -0,0 +1,8 @@
.audio-container {
height: 100%;
width: 100%;
display: flex;
audio {
margin: auto;
}
}

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AudioViewerComponent } from './audio-viewer.component';
describe('AudioViewerComponent', () => {
let component: AudioViewerComponent;
let fixture: ComponentFixture<AudioViewerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AudioViewerComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AudioViewerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,14 @@
import {Component, Input, OnInit} from '@angular/core';
import {SafeResourceUrl} from "@angular/platform-browser";
@Component({
selector: 'app-audio-viewer',
templateUrl: './audio-viewer.component.html',
styleUrls: ['./audio-viewer.component.scss']
})
export class AudioViewerComponent {
@Input() blobUrl!: SafeResourceUrl;
constructor() { }
}

@ -1 +1,9 @@
<app-image-viewer *ngIf="getContentType() === 'image'" [imageUrl]="contentUrl"></app-image-viewer>
<app-image-viewer *ngIf="getContentType() === 'image' && contentUrl" [imageUrl]="contentUrl"></app-image-viewer>
<app-video-viewer *ngIf="getContentType() === 'video' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-video-viewer>
<app-audio-viewer *ngIf="getContentType() === 'audio' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-audio-viewer>
<div *ngIf="getContentType() === 'other'" class="download-prompt">
<span>Unsupported content type <b>{{this.file.mime_type}}</b></span>
<button mat-flat-button color="primary" (click)="this.downloadContent()">Download</button>
</div>
<app-busy-indicator></app-busy-indicator>

@ -1,4 +1,19 @@
app-image-viewer {
app-image-viewer, app-video-viewer, app-audio-viewer {
width: 100%;
height: 100%;
}
.download-prompt {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
button {
margin: 1em 0 auto;
align-self: center;
}
span {
margin: auto 0 0;
align-self: center;
}
}

@ -1,25 +1,68 @@
import {Component, Input, OnInit} from '@angular/core';
import {
AfterContentInit, AfterViewInit,
Component,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges, ViewChild
} from '@angular/core';
import {SafeResourceUrl} from "@angular/platform-browser";
import {File} from "../../../models/File";
import {FileService} from "../../../services/file/file.service";
import {FileHelper} from "../../../services/file/file.helper";
import {ErrorBrokerService} from "../../../services/error-broker/error-broker.service";
import {BusyIndicatorComponent} from "../../busy-indicator/busy-indicator.component";
type ContentType = "image" | "video" | "text" | "other";
type ContentType = "image" | "video" | "audio" | "other";
@Component({
selector: 'app-content-viewer',
templateUrl: './content-viewer.component.html',
styleUrls: ['./content-viewer.component.scss']
})
export class ContentViewerComponent {
export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() file!: File;
@Input() contentUrl!: SafeResourceUrl | string;
@Input() mimeType: string | undefined;
public contentUrl: SafeResourceUrl | undefined;
public blobUrl: SafeResourceUrl | undefined;
constructor() { }
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
constructor(
private errorBroker: ErrorBrokerService,
private fileService: FileService
) {
}
public async ngAfterViewInit() {
if (["audio", "video"].includes(this.getContentType())) {
await this.loadBlobUrl();
} else {
this.contentUrl = this.fileService.buildContentUrl(this.file);
}
}
public async ngOnChanges(changes:SimpleChanges) {
if (changes["file"]) {
if (["audio", "video"].includes(this.getContentType()) && this.busyIndicator) {
await this.loadBlobUrl();
} else {
this.contentUrl = this.fileService.buildContentUrl(this.file);
this.unloadBlobUrl();
}
}
}
public ngOnDestroy(): void {
this.unloadBlobUrl();
}
public getContentType(): ContentType {
if (!this.mimeType) {
if (!this.file.mime_type) {
return "other";
}
let mimeParts = this.mimeType.split("/");
let mimeParts = this.file.mime_type.split("/");
const type = mimeParts.shift() ?? "other";
const subtype = mimeParts.shift() ?? "*";
@ -28,10 +71,40 @@ export class ContentViewerComponent {
return "image";
case "video":
return "video";
case "text":
return "text";
case "audio":
return "audio";
default:
return "other";
}
}
public async downloadContent() {
const path = await FileHelper.getFileDownloadLocation(this.file)
if (path) {
try {
await this.fileService.saveFile(this.file, path);
} catch (err) {
this.errorBroker.showError(err);
}
}
}
public async loadBlobUrl(): Promise<void> {
await this.busyIndicator.wrapAsyncOperation(async () => {
const startId = this.file.id;
this.unloadBlobUrl();
const url = await this.fileService.readFile(this.file);
if (startId === this.file.id) {
this.blobUrl = url;
}
});
}
private unloadBlobUrl() {
if (this.blobUrl) {
URL?.revokeObjectURL(this.blobUrl as string);
this.blobUrl = undefined;
}
}
}

@ -0,0 +1,3 @@
<video controls [src]="this.blobUrl">
Unsupported video type
</video>

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoViewerComponent } from './video-viewer.component';
describe('VideoViewerComponent', () => {
let component: VideoViewerComponent;
let fixture: ComponentFixture<VideoViewerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ VideoViewerComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(VideoViewerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,14 @@
import {
Component,
Input,
} from '@angular/core';
import {SafeResourceUrl} from "@angular/platform-browser";
@Component({
selector: 'app-video-viewer',
templateUrl: './video-viewer.component.html',
styleUrls: ['./video-viewer.component.scss']
})
export class VideoViewerComponent {
@Input() blobUrl!: SafeResourceUrl;
}

@ -4,10 +4,8 @@
</button>
<div (dblclick)="this.selectedFile? this.fileDblClickEvent.emit(this.selectedFile.data) : null" class="file-full-view"
fxFlex="80%">
<div *ngIf="!this.fileContentUrl" class="url-loading-backdrop">
<mat-progress-spinner color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
<app-content-viewer *ngIf="this.fileContentUrl" [contentUrl]="this.fileContentUrl" [mimeType]="this.selectedFile!.data.mime_type"></app-content-viewer>
<app-content-viewer
(contextmenu)="this.selectedFile && fileContextMenu.onContextMenu($event, this.selectedFile!.data)" [file]="this.selectedFile!.data"></app-content-viewer>
</div>
<mat-divider fxFlex></mat-divider>
<div class="file-scroll-view" fxFlex="20%">

@ -32,9 +32,10 @@ app-file-gallery-entry {
overflow: hidden;
}
app-content-aware-image {
app-content-viewer{
height: 100%;
width: 100%;
display: block;
}
.close-button {

@ -5,16 +5,17 @@
[selectedFiles]="this.selectedFiles"></app-files-tab-sidebar>
</mat-drawer>
<mat-drawer-content>
<div *ngIf="contentLoading" class="spinner-overlay">
<mat-progress-spinner color="primary" mode="indeterminate"></mat-progress-spinner>
</div>
<app-file-grid (fileDblClickEvent)="openGallery($event)" (fileMultiselectEvent)="onFileMultiSelect($event)" (fileSelectEvent)="onFileSelect($event)"
<app-busy-indicator [busy]="contentLoading" [blurBackground]="true">
<app-file-grid (fileDblClickEvent)="openGallery($event)" (fileMultiselectEvent)="onFileMultiSelect($event)"
(fileSelectEvent)="onFileSelect($event)"
*ngIf="!this.showGallery"
[files]="files"
[preselectedFile]="this.preselectedFile"
></app-file-grid>
<app-file-gallery (closeEvent)="this.closeGallery($event.selectedFile?.data)" (fileSelectEvent)="onFileSelect($event)" *ngIf="this.showGallery"
<app-file-gallery (closeEvent)="this.closeGallery($event.selectedFile?.data)"
(fileSelectEvent)="onFileSelect($event)" *ngIf="this.showGallery"
[files]="files"
[preselectedFile]="this.preselectedFile"></app-file-gallery>
</app-busy-indicator>
</mat-drawer-content>
</mat-drawer-container>

@ -22,6 +22,7 @@ export class FilesystemImportComponent {
public filters: DialogFilter[] = [
{name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "bmp"]},
{name: "Videos", extensions: ["mp4", "mkv", "wmv", "avi", "webm"]},
{name: "Audio", extensions: ["mp3", "ogg", "wav", "flac", "aac"]},
{name: "Documents", extensions: ["pdf", "doc", "docx", "odf"]},
{name: "Text", extensions: ["txt", "md"]},
{name: "All", extensions: ["*"]}

@ -26,3 +26,7 @@
overflow-y: auto;
height: calc(100% - 5em);
}
app-repository-card{
position: relative;
}

@ -1,3 +1,4 @@
<app-busy-indicator [darkenBackground]="true">
<mat-card>
<mat-card-title>{{repository.name}}</mat-card-title>
<div [class]="'repository-status ' + this.getDaemonStatusClass()">
@ -8,16 +9,20 @@
<p *ngIf="!repository.local" class="repository-address">{{repository.address}}</p>
</mat-card-content>
<mat-action-list>
<button (click)="startDaemonAndSelectRepository()" *ngIf="!this.isSelectedRepository() && repository.local" color="primary"
<button (click)="startDaemonAndSelectRepository()" *ngIf="!this.isSelectedRepository() && repository.local"
color="primary"
mat-flat-button>Open
</button>
<button (click)="selectRepository()" *ngIf="!this.isSelectedRepository() && !repository.local" [disabled]="!this.daemonRunning"
<button (click)="selectRepository()" *ngIf="!this.isSelectedRepository() && !repository.local"
[disabled]="!this.daemonRunning"
color="primary" mat-flat-button>Connect
</button>
<button (click)="this.repoService.closeSelectedRepository()" *ngIf="this.isSelectedRepository() && repository.local" color="primary"
<button (click)="this.repoService.closeSelectedRepository()"
*ngIf="this.isSelectedRepository() && repository.local" color="primary"
mat-flat-button>Close
</button>
<button (click)="this.repoService.disconnectSelectedRepository()" *ngIf="this.isSelectedRepository() && !repository.local" color="primary"
<button (click)="this.repoService.disconnectSelectedRepository()"
*ngIf="this.isSelectedRepository() && !repository.local" color="primary"
mat-flat-button>Disconnect
</button>
<button [mat-menu-trigger-for]="menu" class="menu-button" mat-button>
@ -29,3 +34,4 @@
</mat-menu>
</mat-action-list>
</mat-card>
</app-busy-indicator>

@ -1,10 +1,11 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {Component, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {Repository} from "../../../../models/Repository";
import {RepositoryService} from "../../../../services/repository/repository.service";
import {Router} from "@angular/router";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {MatDialog} from "@angular/material/dialog";
import {ConfirmDialogComponent} from "../../../../components/confirm-dialog/confirm-dialog.component";
import {BusyIndicatorComponent} from "../../../../components/busy-indicator/busy-indicator.component";
@Component({
selector: 'app-repository-card',
@ -14,6 +15,7 @@ import {ConfirmDialogComponent} from "../../../../components/confirm-dialog/conf
export class RepositoryCardComponent implements OnInit, OnDestroy {
@Input() repository!: Repository;
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
public daemonRunning: boolean = false;
@ -95,11 +97,13 @@ export class RepositoryCardComponent implements OnInit, OnDestroy {
}
public async selectRepository() {
this.busyIndicator.setBusy(true);
try {
await this.repoService.setRepository(this.repository);
} catch (err) {
this.errorBroker.showError(err);
}
this.busyIndicator.setBusy(false);
}
async checkRemoteRepositoryStatus() {

@ -0,0 +1,49 @@
import {downloadDir} from "@tauri-apps/api/path";
import {dialog} from "@tauri-apps/api";
import {File} from "../../models/File";
export class FileHelper {
/**
* Opens a dialog to get a download location for the given file
* @param {File} file
*/
public static async getFileDownloadLocation(file: File): Promise<string | undefined> {
let extension;
if (file.mime_type) {
extension = FileHelper.getExtensionForMime(file.mime_type);
}
const downloadDirectory = await downloadDir();
const suggestionPath = downloadDirectory + file.hash + "." + extension;
return await dialog.save({
defaultPath: suggestionPath,
filters: [{
name: file.mime_type ?? "All",
extensions: [extension ?? "*"]
}, {name: "All", extensions: ["*"]}]
})
}
/**
* Returns the extension for a mime type
* @param {string} mime
* @returns {string | undefined}
* @private
*/
public static getExtensionForMime(mime: string): string | undefined {
let parts = mime.split("/");
if (parts.length === 2) {
const type = parts[0];
const subtype = parts[1];
return FileHelper.convertMimeSubtypeToExtension(subtype);
}
return undefined;
}
private static convertMimeSubtypeToExtension(subtype: string): string {
return subtype;
}
}

@ -8,6 +8,9 @@ import {TagQuery} from "../../models/TagQuery";
import {SortKey} from "../../models/SortKey";
import {RepositoryService} from "../repository/repository.service";
import {FilterExpression} from "../../models/FilterExpression";
import {HttpClient} from "@angular/common/http";
import {map} from "rxjs/operators";
import {http} from "@tauri-apps/api";
@Injectable({
providedIn: 'root'
@ -17,7 +20,11 @@ export class FileService {
displayedFiles = new BehaviorSubject<File[]>([]);
thumbnailCache: {[key: number]: Thumbnail[]} = {};
constructor(@Inject(DomSanitizer) private sanitizer: DomSanitizer, private repoService: RepositoryService) {
constructor(
@Inject(DomSanitizer) private sanitizer: DomSanitizer,
private repoService: RepositoryService,
private http: HttpClient,
) {
repoService.selectedRepository.subscribe(_ => this.clearCache());
}
@ -79,4 +86,16 @@ export class FileService {
public async deleteThumbnails(file: File) {
await invoke("plugin:mediarepo|delete_thumbnails", {id: file.id});
}
/**
* Reads the contents of a file and returns the object url for it
* @param {File} file
* @returns {Promise<SafeResourceUrl>}
*/
public async readFile(file: File): Promise<SafeResourceUrl> {
const data = await invoke<number[]>("plugin:mediarepo|read_file", {hash: file.hash, mimeType: file.mime_type});
const blob = new Blob([new Uint8Array(data)], {type: file.mime_type});
const url = URL?.createObjectURL(blob);
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
}

Loading…
Cancel
Save