diff --git a/i18n/en.json b/i18n/en.json index 1f09c2bb4..29330a4f6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -161,6 +161,9 @@ "showOnlyPlaylistDescription": "Show videos you added to the playlist", "showOnlyPlaylistHint": "Show playlist", "showOnlyPlaylistMoreInfo": "Click the icon that shows up over duration in thumbnails view to add / remove video from the playlist", + "showPlaylistsDescription": "Playlists and compilations", + "showPlaylistsHint": "Playlists", + "showPlaylistsMoreInfo": "Shows your playlists and compilations in the sidebar. Right-click any video and choose \"Add to playlist\" to add it", "showRecentDescription": "Show recently-opened video hub history", "showRecentHint": "Show recently opened hubs", "showRecentPlayed": "Recently played", @@ -252,7 +255,52 @@ "yearSliderHint": "Year Filter", "yearSliderMoreInfo": "Show the year slider filter" }, + "PLAYLISTS": { + "addedToSnack": "Added {{count}} to {{name}}", + "addSelected": "Add {{count}} selected videos", + "addTo": "Add to", + "addToNamed": "Add to {{name}}", + "allVideos": "All videos", + "alreadyIn": "Already in {{name}}", + "backToGallery": "Back to gallery", + "cancel": "Cancel", + "copiedToSnack": "Copied {{count}} to {{name}}", + "copyTo": "Copy to", + "copyToEllipsis": "Copy to…", + "countClip": "1 clip", + "countClips": "{{count}} clips", + "countVideo": "1 video", + "countVideos": "{{count}} videos", + "delete": "Delete", + "createdSnack": "Created {{name}}. Right-click any video and choose \"Add to playlist\" to fill it", + "deleteConfirm": "Delete \"{{name}}\"? The videos themselves are not touched.", + "doubleClickRename": "Double-click to rename", + "duplicate": "Duplicate", + "emptyPanel": "No playlists yet. Use + above, or right-click any video.", + "emptyPlaylist": "This playlist is empty. Go back to All videos, right-click any video and choose \"Add to playlist\".", + "missingVideo": "missing from this hub", + "movedToSnack": "Moved {{count}} to {{name}}", + "moveTo": "Move to", + "moveToEllipsis": "Move to…", + "newCompilation": "New compilation", + "newCompilationName": "Compilation name", + "newPlaylist": "New playlist", + "newPlaylistName": "Playlist name", + "nothingToPlay": "Nothing to play: the videos are missing or their folder is offline", + "offlineVideo": "folder offline", + "play": "Play", + "playFromHere": "Play from here", + "removeCount": "Remove {{count}} from playlist", + "removeFromNamed": "Remove from {{name}}", + "rename": "Rename", + "restoredFromBackup": "Playlists file was damaged and was restored from the backup", + "saveFailed": "Could not save playlists (hover for details)", + "shufflePlay": "Shuffle play", + "title": "Playlists", + "unreadable": "Playlists file could not be read. Changes are disabled so nothing gets overwritten." + }, "RIGHTCLICK": { + "addToPlaylist": "Add to playlist…", "cancelRename": "cancel rename", "delete": "Delete", "errorFileNameExists": "A file exists with this filename", diff --git a/interfaces/playlist.interface.ts b/interfaces/playlist.interface.ts new file mode 100644 index 000000000..4cf42a29c --- /dev/null +++ b/interfaces/playlist.interface.ts @@ -0,0 +1,49 @@ +// Playlists and compilations -- stored in `.playlists.json` next to the `.vha2` file +// so that the official app, which rebuilds the `.vha2` from known fields, can never erase them. +// See `docs/playlists-and-compilations.md` + +export type PlaylistKind = 'playlist' | 'compilation'; // display hint only -- both hold the same entries + +export interface PlaylistEntry { + id: string; // uuid -- the same video may appear more than once + hash: string; // `ImageElement.hash` -- content-based so survives rename & move + start?: number; // seconds -- both `start` and `end` absent => whole video + end?: number; // seconds + label?: string; // e.g. "Grandma's birthday cake" + added: number; // timestamp + // hints to re-link the entry if the file was edited (which changes the hash) + fileName: string; + partialPath: string; + inputSource: number; +} + +export interface Playlist { + id: string; // uuid + name: string; + kind: PlaylistKind; + created: number; // timestamp + modified: number; // timestamp + entries: PlaylistEntry[]; // order === play order +} + +export interface PlaylistFile { + version: 1; + hubName: string; + lastUsedPlaylistId?: string; // target for the thumbnail playlist icon + playlists: Playlist[]; +} + +// What the renderer sends to node to build an `.m3u8` for an external player +export interface ExternalPlaylistItem { + duration: number; // seconds + fullPath: string; + title: string; + start?: number; // seconds + end?: number; // seconds +} + +// Sent back after `please-load-playlists` +// - '' -- all good (data may be `null` when the hub has no playlists file yet) +// - 'restoredFromBackup' -- main file was unreadable; data is from the `.bak` +// - 'unreadable' -- neither file could be read; renderer must not overwrite anything +export type PlaylistLoadStatus = '' | 'restoredFromBackup' | 'unreadable'; diff --git a/main.ts b/main.ts index 353cf77a7..521fc03cb 100644 --- a/main.ts +++ b/main.ts @@ -13,6 +13,7 @@ const windowStateKeeper = require('electron-window-state'); // Methods import { createTouchBar } from './node/main-touch-bar'; +import { setUpIpcForPlaylists } from './node/main-playlists'; import { setUpIpcForServer } from './node/server'; import { setUpIpcMessages } from './node/main-ipc'; import { sendFinalObjectToAngular, setUpDirectoryWatchers, upgradeToVersion3, writeVhaFileToDisk, parseAdditionalExtensions } from './node/main-support'; @@ -324,6 +325,8 @@ setUpIpcMessages(ipcMain, win, pathToAppData, systemMessages); setUpIpcForServer(ipcMain); +setUpIpcForPlaylists(ipcMain); + /** * Once Angular loads it sends over the `ready` status * Load up the settings.json and send settings over to Angular diff --git a/node/main-playlists.ts b/node/main-playlists.ts new file mode 100644 index 000000000..bfa89219d --- /dev/null +++ b/node/main-playlists.ts @@ -0,0 +1,172 @@ +import { shell } from 'electron'; + +import * as path from 'path'; +const fs = require('fs'); +const exec = require('child_process').exec; + +import { GLOBALS } from './main-globals'; +import type { ExternalPlaylistItem, PlaylistFile, PlaylistLoadStatus } from '../interfaces/playlist.interface'; + +// files already backed up during this session -- so the `.bak` keeps the state from before the session +const backedUpThisSession: Set = new Set(); + +// files that failed to parse -- never back these up over a good `.bak` +const unreadableFiles: Set = new Set(); + +/** + * `C:/videos/my-hub.vha2` => `C:/videos/my-hub.playlists.json` + */ +export function playlistsPathFor(vhaPath: string): string { + return vhaPath.replace(/\.vha2$/i, '') + '.playlists.json'; +} + +/** + * Read and sanity-check a playlists file; throws if it is missing or malformed + */ +function readPlaylistFile(filePath: string): PlaylistFile { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (!parsed || !Array.isArray(parsed.playlists)) { + throw new Error('not a playlists file'); + } + return parsed; +} + +/** + * Write `contents` to `filePath` by writing a temp file and renaming it over the original, + * so a crash mid-write never leaves a half-written file behind. + * The first save of a session copies the previous file to `.bak`. + */ +function writeFileAtomically(filePath: string, contents: string): void { + const tempPath: string = filePath + '.tmp'; + + fs.writeFileSync(tempPath, contents, 'utf8'); + + if (fs.existsSync(filePath)) { + if (unreadableFiles.has(filePath)) { + // keep the broken file for inspection, and leave the good `.bak` alone + fs.renameSync(filePath, filePath + '.unreadable-' + Date.now()); + unreadableFiles.delete(filePath); + } else if (!backedUpThisSession.has(filePath)) { + fs.copyFileSync(filePath, filePath + '.bak'); + } + } + backedUpThisSession.add(filePath); + + try { + fs.renameSync(tempPath, filePath); + } catch (err) { + // e.g. antivirus briefly holding the file -- fall back to a direct write + console.log('rename failed, writing directly', err); + fs.writeFileSync(filePath, contents, 'utf8'); + try { fs.unlinkSync(tempPath); } catch {} + } +} + +/** + * Write an `.m3u8` for an external player + * `#EXTVLCOPT` start/stop times are honored by VLC; other players play whole files + */ +function createM3u8File(savePath: string, name: string, items: ExternalPlaylistItem[]): void { + const lines: string[] = ['#EXTM3U', '#PLAYLIST:' + name]; + + items.forEach((item: ExternalPlaylistItem) => { + const hasRange: boolean = item.start !== undefined && item.end !== undefined; + const duration: number = hasRange ? item.end - item.start : item.duration; + + lines.push('#EXTINF:' + Math.round(duration) + ',' + item.title); + if (hasRange) { + lines.push('#EXTVLCOPT:start-time=' + item.start.toFixed(3)); + lines.push('#EXTVLCOPT:stop-time=' + item.end.toFixed(3)); + } + lines.push(item.fullPath); + }); + + lines.push(''); + + fs.writeFileSync(savePath, lines.join('\n'), 'utf8'); +} + +/** + * Set up the listeners for playlists + */ +export function setUpIpcForPlaylists(ipc): void { + + /** + * Load the playlists file belonging to a hub + * Replies with `playlists-returning` (vhaPath, data | null, status) + */ + ipc.on('please-load-playlists', (event, vhaPath: string): void => { + const filePath: string = playlistsPathFor(vhaPath); + + let data: PlaylistFile = null; + let status: PlaylistLoadStatus = ''; + + if (fs.existsSync(filePath)) { + try { + data = readPlaylistFile(filePath); + unreadableFiles.delete(filePath); + } catch (err) { + console.log('playlists file unreadable', filePath, err); + unreadableFiles.add(filePath); + try { + data = readPlaylistFile(filePath + '.bak'); + status = 'restoredFromBackup'; + } catch { + status = 'unreadable'; + } + } + } + + event.sender.send('playlists-returning', vhaPath, data, status); + }); + + /** + * Save the playlists file belonging to a hub + * Synchronous on purpose: when the app closes, this finishes before `close-window` is handled + * Replies with `playlists-saved` (vhaPath, errorMessage) + */ + ipc.on('please-save-playlists', (event, vhaPath: string, data: PlaylistFile): void => { + if (!vhaPath) { + return; + } + + let errorMessage = ''; + + try { + writeFileAtomically(playlistsPathFor(vhaPath), JSON.stringify(data, null, 2)); + } catch (err) { + console.log('could not save playlists', err); + errorMessage = String(err && err.message ? err.message : err); + } + + try { + event.sender.send('playlists-saved', vhaPath, errorMessage); + } catch {} // window may already be closing + }); + + /** + * Write an `.m3u8` and open it with the preferred player, or the system default + */ + ipc.on('please-play-playlist-externally', (event, name: string, items: ExternalPlaylistItem[], execPath: string): void => { + if (!items || !items.length) { + return; + } + + const savePath: string = path.join(GLOBALS.settingsPath, 'temp-playlist.m3u8'); + + try { + createM3u8File(savePath, name, items); + } catch (err) { + console.log('could not write playlist file', err); + return; + } + + if (execPath) { + const cmdline = `"${path.normalize(execPath)}" "${path.normalize(savePath)}"`; + exec(cmdline); + } else { + shell.openPath(savePath); + } + }); + +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b19957c1e..bbe76020a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -46,7 +46,11 @@ import { HomeComponent } from './components/home.component'; import { IconComponent } from './components/icon/icon.component'; import { MetaComponent } from './components/meta/meta.component'; import { ModalComponent } from './components/modal/modal.component'; +import { PlaylistPickerComponent } from './components/playlists/playlist-picker.component'; +import { PlaylistsPanelComponent } from './components/playlists/playlists-panel.component'; +import { PlaylistViewComponent } from './components/playlists/playlist-view.component'; import { RecentlyOpenedComponent } from './components/recently-opened/recently-opened.component'; +import { ToBodyDirective } from './components/playlists/to-body.directive'; import { RenameFileComponent } from './components/rename-file/rename-file.component'; import { RenameModalComponent } from './components/rename-modal/rename-modal.component'; import { ResolutionFilterComponent } from './components/resolution-filter/resolution-filter.component'; @@ -152,7 +156,10 @@ import { YearPipe } from './pipes/year.pipe'; MetaComponent, ModalComponent, PlaylistOnlyPipe, + PlaylistPickerComponent, PlaylistPipe, + PlaylistsPanelComponent, + PlaylistViewComponent, RecentlyOpenedComponent, RegexSearchPipe, RenameFileComponent, @@ -185,6 +192,7 @@ import { YearPipe } from './pipes/year.pipe'; TagTrayComponent, ThumbnailComponent, TimesPlayedFilterPipe, + ToBodyDirective, TimesPlayedPipe, TitleBarComponent, TopComponent, diff --git a/src/app/common/settings-buttons.ts b/src/app/common/settings-buttons.ts index 2ff73d997..257963502 100644 --- a/src/app/common/settings-buttons.ts +++ b/src/app/common/settings-buttons.ts @@ -61,6 +61,7 @@ export type SettingsButtonKey = 'autoFileTags' | 'showMoreInfo' | 'showOnlyFavorites' | 'showOnlyPlaylist' + | 'showPlaylists' | 'showRecent' | 'showRecentlyPlayed' | 'showRelatedVideosTray' @@ -143,7 +144,6 @@ export const SettingsButtonsGroups: SettingsButtonKey[][] = [ 'sortOptionCreated', 'sortOptionTags', 'sortOptionAspectRatio', - 'sortOptionPlaylist', 'sortOptionFps', 'sortOptionFolderSize', ], @@ -167,8 +167,7 @@ export const SettingsButtonsGroups: SettingsButtonKey[][] = [ 'randomizeFoldersScreenshots', 'showTags', 'showOnlyFavorites', - 'showOnlyPlaylist', - 'playPlaylist', + 'showPlaylists', // replaced the legacy single playlist: `showOnlyPlaylist`, `playPlaylist`, `sortOptionPlaylist` ], [ // 6 - Bottom tray -- hidden from ribbon via !== 6 in ribbon.component.html 'showFreq', @@ -745,6 +744,14 @@ export const SettingsButtons: SettingsButtonsType = { title: 'BUTTONS.showOnlyPlaylistHint', toggled: false }, + 'showPlaylists': { + description: 'BUTTONS.showPlaylistsDescription', + hidden: false, + iconName: 'icon-playlist', + moreInfo: 'BUTTONS.showPlaylistsMoreInfo', + title: 'BUTTONS.showPlaylistsHint', + toggled: true + }, 'showRecent': { description: 'BUTTONS.showRecentDescription', hidden: false, diff --git a/src/app/components/home.component.html b/src/app/components/home.component.html index b19a25585..e83800b8a 100644 --- a/src/app/components/home.component.html +++ b/src/app/components/home.component.html @@ -88,6 +88,9 @@ @rightClickAnimation >
    + @if (settingsButtons['showPlaylists'].toggled) { +
  • {{ 'RIGHTCLICK.addToPlaylist' | translate }}
  • + }
  • } + +@if (playlistPickerShowing) { + +} + @if (sheetOverlayShowing) { + + @if (settingsButtons['showPlaylists'].toggled) { + + } + - @if (numberOfVideosFound === 0) { + + @if (settingsButtons['showPlaylists'].toggled && playlistService.openPlaylist) { + + } + + @if (numberOfVideosFound === 0 && !(settingsButtons['showPlaylists'].toggled && playlistService.openPlaylist)) {
    uniqueKey)) { this.savePreviousViewSize(); @@ -1771,6 +1789,9 @@ export class HomeComponent implements OnInit, AfterViewInit { if (uniqueKey === 'showMoreInfo') { this.computeTextBufferAmount(); } + if (uniqueKey === 'showPlaylists' && !this.settingsButtons['showPlaylists'].toggled) { + this.playlistService.close(); + } if (uniqueKey === 'hideSidebar') { setTimeout(() => { this.virtualScroller().refresh(); @@ -1946,6 +1967,7 @@ export class HomeComponent implements OnInit, AfterViewInit { * @param origin -- number in filter array of the filter to target */ onEnterKey(value: string, origin: number): void { + this.playlistService.close(); // a new search shows the gallery const trimmed = value.trim(); if (origin === 6) { @@ -2082,6 +2104,12 @@ export class HomeComponent implements OnInit, AfterViewInit { this.settingsButtons['showTags'].toggled = false; // never show tags on load (they don't load right anyway) + // the legacy single playlist became named playlists -- its filter & sort have no UI any more + this.settingsButtons['showOnlyPlaylist'].toggled = false; + if (this.sortType === 'playlistAsc' || this.sortType === 'playlistDesc') { + this.sortType = 'default'; + } + if (this.settingsButtons['showTagTray'].toggled) { this.settingsButtons['showTagTray'].toggled = false; setTimeout(() => { @@ -2128,6 +2156,7 @@ export class HomeComponent implements OnInit, AfterViewInit { * Handle right-click and `Show similar` */ showSimilarNow(): void { + this.playlistService.close(); this.findMostSimilar = this.currentRightClickedItem.cleanName; this.showSimilar = true; } @@ -2152,11 +2181,58 @@ export class HomeComponent implements OnInit, AfterViewInit { const howFarFromBottom: number = winHeight - clientY; this.rightClickPosition.x = (howFarFromRight < 150) ? clientX - 150 + (howFarFromRight) : clientX; - this.rightClickPosition.y = (howFarFromBottom < 210) ? clientY - 210 + (howFarFromBottom) : clientY; + this.rightClickPosition.y = (howFarFromBottom < 240) ? clientY - 240 + (howFarFromBottom) : clientY; this.rightClickShowing = true; } + /** + * Whether a button changes what the gallery shows -- if so, the open playlist steps aside + */ + buttonShowsGallery(uniqueKey: SettingsButtonKey | SupportedView | SupportedTrayView): boolean { + return AllSupportedViews.includes(uniqueKey) + || FilterKeyNames.includes(uniqueKey) + || [ + 'clearAllFilters', + 'duplicateHash', + 'duplicateLength', + 'duplicateSize', + 'durationFilter', + 'fuzzy', + 'hideOffline', + 'magic', + 'regex', + 'resolutionFilter', + 'showFolders', + 'showOnlyFavorites', + 'shuffleGalleryNow', + 'sizeFilter', + 'sortOrder', + 'starFilter', + 'startsWith', + 'timesPlayedFilter', + 'yearFilter', + ].includes(uniqueKey); + } + + /** + * Right-click menu `Add to playlist…` -- shows the playlist picker where the menu was + */ + openPlaylistPickerNow(): void { + this.playlistPickerPosition = { ...this.rightClickPosition }; + this.playlistPickerShowing = true; + } + + addRightClickedToPlaylist(playlistId: string): void { + this.playlistPickerShowing = false; + const name: string = this.playlistService.get(playlistId)?.name; + const added: number = this.playlistService.addVideos(playlistId, [this.currentRightClickedItem]); + this.modalService.openSnackbar( + this.translate.instant(added ? 'PLAYLISTS.addedToSnack' : 'PLAYLISTS.alreadyIn', { count: added, name: name }), + 1500 + ); + } + /** * When in double-click mode and a video is clicked - `currentClickedItemName` updated * @param item @@ -2269,6 +2345,7 @@ export class HomeComponent implements OnInit, AfterViewInit { * @param type */ selectFilterOrder(type: SortType): void { + this.playlistService.close(); this.sortType = type; this.appState.currentSort = type; } @@ -2545,6 +2622,7 @@ export class HomeComponent implements OnInit, AfterViewInit { * It resets all filter arrays, bounds, and toggles all filter buttons off */ clearAllFilters(): void { + this.playlistService.close(); // Clear all filter arrays and bools this.filters.forEach((filter) => { filter.array = []; diff --git a/src/app/components/icon/svg-definitions.component.html b/src/app/components/icon/svg-definitions.component.html index 0ecbfad65..28218ab5c 100644 --- a/src/app/components/icon/svg-definitions.component.html +++ b/src/app/components/icon/svg-definitions.component.html @@ -773,6 +773,13 @@ + + + + + + + diff --git a/src/app/components/layout.scss b/src/app/components/layout.scss index df0364506..8386ae626 100644 --- a/src/app/components/layout.scss +++ b/src/app/components/layout.scss @@ -170,6 +170,10 @@ @include variables.scrollBarSquare; } +.playlist-view-container { + z-index: 2; // above the gallery it covers +} + .gallery-container-dark-mode { background: variables.$black; } diff --git a/src/app/components/playlists/playlist-picker.component.html b/src/app/components/playlists/playlist-picker.component.html new file mode 100644 index 000000000..d64776c82 --- /dev/null +++ b/src/app/components/playlists/playlist-picker.component.html @@ -0,0 +1,52 @@ +
    + +
    + +
    +
    + @switch (mode()) { + @case ('move') { {{ 'PLAYLISTS.moveTo' | translate }} } + @case ('copy') { {{ 'PLAYLISTS.copyTo' | translate }} } + @default { {{ 'PLAYLISTS.addTo' | translate }} } + } +
    + +
      + @for (playlist of choices(); track playlist.id) { +
    • + {{ holdsVideo(playlist) ? '✓' : '' }} + + {{ playlist.name }} + {{ playlist.entries.length }} +
    • + } +
    + + @if (creatingNew) { + + } @else { +
    + + {{ (newKind() === 'compilation' ? 'PLAYLISTS.newCompilation' : 'PLAYLISTS.newPlaylist') | translate }} +
    + } +
    + +
    diff --git a/src/app/components/playlists/playlist-picker.component.scss b/src/app/components/playlists/playlist-picker.component.scss new file mode 100644 index 000000000..163aee458 --- /dev/null +++ b/src/app/components/playlists/playlist-picker.component.scss @@ -0,0 +1,105 @@ +@use "../variables.scss"; + +.picker-overlay { + background: transparent; + height: 100vh; + left: 0; + position: fixed; + top: 0; + width: 100vw; + z-index: 40; +} + +.picker { + background: variables.$gray-10; + border: 1px solid variables.$gray-50; + border-radius: 10px; + box-shadow: 0 3px 7px 3px rgba(0, 0, 0, 0.3); + box-sizing: border-box; + color: variables.$gray-90; + font-size: 12px; + max-height: 340px; + padding: 8px; + position: fixed; + width: 230px; + z-index: 41; + + display: flex; + flex-direction: column; +} + +.picker-dark { + background: variables.$gray-80; + border-color: variables.$gray-60; + color: variables.$gray-10; +} + +.picker-title { + color: variables.$gray-50; + font-style: italic; + padding: 2px 6px 6px; +} + +.picker-list { + margin: 0; + overflow-y: auto; + padding: 0; + + @include variables.scrollBar; + + li { + align-items: center; + border-radius: 6px; + cursor: pointer; + display: flex; + gap: 6px; + list-style: none; + padding: 5px 6px; + + &:hover { + background: rgba(114, 154, 255, 0.25); + } + } +} + +.picker-check { + color: variables.$pretty-green; + flex: 0 0 10px; + font-weight: bold; +} + +.picker-icon { + flex: 0 0 14px; + height: 14px; + width: 14px; +} + +.picker-name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-count { + color: variables.$gray-50; + flex: 0 0 auto; +} + +.picker-new { + border-top: 1px solid variables.$gray-30; + color: variables.$pretty-blue; + cursor: pointer; + margin-top: 6px; + padding: 8px 6px 2px; +} + +.picker-new-input { + border: 1px solid variables.$gray-40; + border-radius: 6px; + box-sizing: border-box; + font-size: 12px; + margin-top: 8px; + padding: 5px 7px; + width: 100%; +} diff --git a/src/app/components/playlists/playlist-picker.component.ts b/src/app/components/playlists/playlist-picker.component.ts new file mode 100644 index 000000000..d69b25f6d --- /dev/null +++ b/src/app/components/playlists/playlist-picker.component.ts @@ -0,0 +1,98 @@ +import type { ElementRef, OnInit } from '@angular/core'; +import { Component, computed, input, output, viewChild } from '@angular/core'; + +import { PlaylistService } from '../../services/playlist.service'; + +import type { ContextMenuCoordinate } from '../../../../interfaces/shared-interfaces'; +import type { Playlist, PlaylistKind } from '../../../../interfaces/playlist.interface'; + +export type PlaylistPickerMode = 'add' | 'move' | 'copy'; + +const PICKER_WIDTH = 230; +const PICKER_MAX_HEIGHT = 340; + +/** + * Small popover listing all playlists -- used for `Add to playlist`, `Move to` and `Copy to` + * Includes an inline box to create a new playlist and pick it in one go + */ +@Component({ + standalone: false, + selector: 'app-playlist-picker', + templateUrl: './playlist-picker.component.html', + styleUrls: ['./playlist-picker.component.scss'] +}) +export class PlaylistPickerComponent implements OnInit { + + readonly newNameInput = viewChild('newNameInput'); + + readonly closed = output(); + readonly picked = output(); + + readonly darkMode = input(false); + readonly excludePlaylistId = input(null); // the source playlist when moving / copying + readonly hashToCheck = input(null); // shows a check mark on playlists that hold this video + readonly mode = input('add'); + readonly newKind = input('playlist'); + readonly position = input({ x: 0, y: 0 }); + + creatingNew = false; + newName = ''; + + readonly clampedPosition = computed(() => { + const pos: ContextMenuCoordinate = this.position(); + return { + x: Math.max(4, Math.min(pos.x, window.innerWidth - PICKER_WIDTH - 4)), + y: Math.max(4, Math.min(pos.y, window.innerHeight - PICKER_MAX_HEIGHT - 4)), + }; + }); + + constructor( + public playlistService: PlaylistService, + ) { } + + ngOnInit(): void { + if (!this.choices().length) { + this.startCreating(); + } + } + + choices(): Playlist[] { + this.playlistService.version(); // re-evaluate on change + return this.playlistService.playlists.filter((playlist: Playlist) => playlist.id !== this.excludePlaylistId()); + } + + holdsVideo(playlist: Playlist): boolean { + const hash: string = this.hashToCheck(); + return !!hash && this.playlistService.containsWholeVideo(playlist, hash); + } + + pick(playlistId: string): void { + this.picked.emit(playlistId); + } + + startCreating(): void { + this.creatingNew = true; + setTimeout(() => this.newNameInput()?.nativeElement.focus(), 0); + } + + createAndPick(): void { + const name: string = this.newName.trim(); + if (!name) { + return; + } + const playlist: Playlist = this.playlistService.create(name, this.newKind()); + if (playlist) { + this.pick(playlist.id); + } + } + + onNewNameKey(event: KeyboardEvent): void { + event.stopPropagation(); // keep app shortcuts from firing while typing + if (event.key === 'Enter') { + this.createAndPick(); + } else if (event.key === 'Escape') { + this.closed.emit(); + } + } + +} diff --git a/src/app/components/playlists/playlist-utils.ts b/src/app/components/playlists/playlist-utils.ts new file mode 100644 index 000000000..ed83d769c --- /dev/null +++ b/src/app/components/playlists/playlist-utils.ts @@ -0,0 +1,24 @@ +// Drag & drop payload types -- custom so they never clash with tags or files dragged around the app +export const DRAG_ENTRIES = 'application/x-vha-playlist-entries'; // { from: playlistId, ids: entryIds[] } +export const DRAG_PLAYLIST = 'application/x-vha-playlist'; // playlistId + +export interface DraggedEntries { + from: string; + ids: string[]; +} + +/** + * 75 => `1:15`, 3725 => `1:02:05` + */ +export function formatDuration(totalSeconds: number): string { + const seconds: number = Math.max(0, Math.round(totalSeconds || 0)); + const h: number = Math.floor(seconds / 3600); + const m: number = Math.floor((seconds % 3600) / 60); + const s: number = seconds % 60; + const ss: string = s.toString().padStart(2, '0'); + return h > 0 ? `${h}:${m.toString().padStart(2, '0')}:${ss}` : `${m}:${ss}`; +} + +export function hasDragType(event: DragEvent, type: string): boolean { + return !!event.dataTransfer && Array.from(event.dataTransfer.types).includes(type); +} diff --git a/src/app/components/playlists/playlist-view.component.html b/src/app/components/playlists/playlist-view.component.html new file mode 100644 index 000000000..f1abc9b75 --- /dev/null +++ b/src/app/components/playlists/playlist-view.component.html @@ -0,0 +1,157 @@ +@if (playlist(); as playlist) { + +
    + +
    + + + + + @if (renaming) { + + } @else { +

    + {{ playlist.name }} +

    + } + + + {{ countKey(playlist) | translate: { count: playlist.entries.length } }} + · {{ totalDuration() }} + + + + + + + +
    + +@if (!playlist.entries.length) { +
    + {{ 'PLAYLISTS.emptyPlaylist' | translate }} +
    +} + +
      + @for (row of rows(); track row.entry.id; let i = $index) { +
    • + {{ row.number }} + +
      + @if (row.thumbnail) { + @if (row.isHighlight) { + + } @else { + + } + } + @if (row.isHighlight) { + {{ formatDuration(row.entry.end - row.entry.start) }} + } +
      + +
      +
      {{ row.title }}
      +
      + @if (row.missing) { + {{ 'PLAYLISTS.missingVideo' | translate }} + } @else if (row.offline) { + {{ 'PLAYLISTS.offlineVideo' | translate }} + } + @if (row.isHighlight && row.entry.label && row.element) { + {{ row.element.cleanName }} + } + {{ row.folder }} +
      +
      + + {{ row.timing }} +
    • + } + @if (dropAtEnd) { +
    • + } +
    + +
    + +@if (menuRow) { +
    +
    +
    +
      +
    • {{ 'PLAYLISTS.playFromHere' | translate }}
    • +
    • {{ 'PLAYLISTS.moveToEllipsis' | translate }}
    • +
    • {{ 'PLAYLISTS.copyToEllipsis' | translate }}
    • + @if (menuRow.element) { +
    • {{ 'RIGHTCLICK.openFolder' | translate }}
    • + } + +
    +
    +
    +} + +@if (pickerMode) { + +} + +} diff --git a/src/app/components/playlists/playlist-view.component.scss b/src/app/components/playlists/playlist-view.component.scss new file mode 100644 index 000000000..362a89441 --- /dev/null +++ b/src/app/components/playlists/playlist-view.component.scss @@ -0,0 +1,305 @@ +@use "../variables.scss"; + +:host { + display: block; + outline: none; +} + +.playlist-view { + color: variables.$gray-90; + font-size: 13px; + min-height: 100%; + padding: 0 18px 160px; +} + +.playlist-view-dark { + color: variables.$gray-10; +} + +// ------------------------------------------------ header + +.view-header { + align-items: center; + background: inherit; + display: flex; + gap: 10px; + padding: 16px 0 12px; +} + +.header-icon { + height: 22px; + width: 22px; +} + +.playlist-view-dark .header-icon { + fill: variables.$gray-30; +} + +.header-name { + font-size: 20px; + font-weight: 600; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header-rename { + border: 1px solid variables.$gray-40; + border-radius: 6px; + font-size: 18px; + padding: 3px 8px; +} + +.header-meta { + color: variables.$gray-50; + white-space: nowrap; +} + +.header-spacer { + flex: 1 1 auto; +} + +.header-button { + background: variables.$gray-05; + border: 1px solid variables.$gray-30; + border-radius: 6px; + color: variables.$gray-90; + cursor: pointer; + font-size: 12px; + padding: 6px 12px; + white-space: nowrap; + + &:hover:not(:disabled) { + border-color: variables.$pretty-blue; + } + + &:disabled { + cursor: default; + opacity: 0.5; + } +} + +.header-play { + background: variables.$pretty-blue; + border-color: variables.$pretty-blue; + color: variables.$white; +} + +.header-back { + margin-right: 6px; +} + +.header-close { + padding: 6px 10px; +} + +.playlist-view-dark .header-button:not(.header-play) { + background: variables.$gray-80; + border-color: variables.$gray-60; + color: variables.$gray-10; +} + +.view-empty { + color: variables.$gray-50; + padding: 40px 0; + text-align: center; +} + +// ------------------------------------------------ rows + +.view-rows { + margin: 0; + padding: 0; +} + +.view-row { + align-items: center; + border-radius: 8px; + border-top: 2px solid transparent; + cursor: default; + display: flex; + gap: 12px; + list-style: none; + padding: 5px 8px; + user-select: none; + + &:hover { + background: rgba(114, 154, 255, 0.12); + } +} + +.row-selected, +.row-selected:hover { + background: rgba(114, 154, 255, 0.3); +} + +.row-drop-before { + border-top-color: variables.$pretty-blue; +} + +.view-drop-end { + border-top: 2px solid variables.$pretty-blue; + list-style: none; +} + +.row-missing, +.row-offline { + .row-thumb, + .row-title { + opacity: 0.45; + } +} + +.row-number { + color: variables.$gray-50; + flex: 0 0 26px; + font-size: 11px; + text-align: right; +} + +.row-thumb { + background: variables.$gray-30; + border-radius: 4px; + flex: 0 0 96px; + height: 54px; + overflow: hidden; + position: relative; + width: 96px; + + img { + display: block; + pointer-events: none; + } +} + +.playlist-view-dark .row-thumb { + background: variables.$gray-80; +} + +.thumb-single { + height: 100%; + object-fit: cover; + width: 100%; +} + +.thumb-filmstrip { + height: auto; + left: 0; + position: absolute; + top: 50%; + margin-top: -27px; // roughly centered; frames vary slightly in aspect +} + +.thumb-badge { + background: rgba(0, 0, 0, 0.7); + border-radius: 3px; + bottom: 3px; + color: variables.$white; + font-size: 10px; + padding: 0 4px; + position: absolute; + right: 3px; +} + +.row-text { + flex: 1 1 auto; + min-width: 0; +} + +.row-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-detail { + color: variables.$gray-50; + display: flex; + font-size: 11px; + gap: 8px; + margin-top: 3px; + overflow: hidden; + white-space: nowrap; +} + +.row-warning { + color: variables.$pretty-red; + flex: 0 0 auto; +} + +.row-source { + flex: 0 0 auto; +} + +.row-folder { + overflow: hidden; + text-overflow: ellipsis; +} + +.row-timing { + color: variables.$gray-60; + flex: 0 0 auto; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.playlist-view-dark .row-timing { + color: variables.$gray-30; +} + +// ------------------------------------------------ right-click menu + +.view-menu-overlay { + height: 100vh; + left: 0; + position: fixed; + top: 0; + width: 100vw; + z-index: 40; +} + +.view-menu { + background: variables.$gray-10; + border: 1px solid variables.$gray-50; + border-radius: 10px; + box-shadow: 0 3px 7px 3px rgba(0, 0, 0, 0.3); + color: variables.$gray-90; + font-size: 12px; + min-width: 170px; + position: fixed; + z-index: 41; + + ul { + margin: 0; + padding: 8px; + + li { + border-radius: 5px; + cursor: pointer; + list-style: none; + padding: 6px; + + &:hover { + background: rgba(114, 154, 255, 0.25); + } + } + } + + .menu-remove { + border-top: 1px solid variables.$gray-20; + color: variables.$dark-red; + margin-top: 4px; + } +} + +.view-menu-dark { + background: variables.$gray-80; + border-color: variables.$gray-60; + color: variables.$gray-10; + + .menu-remove { + border-top-color: variables.$gray-60; + color: variables.$pretty-red-lighter; + } +} diff --git a/src/app/components/playlists/playlist-view.component.ts b/src/app/components/playlists/playlist-view.component.ts new file mode 100644 index 000000000..df539d256 --- /dev/null +++ b/src/app/components/playlists/playlist-view.component.ts @@ -0,0 +1,380 @@ +import type { ElementRef } from '@angular/core'; +import { Component, input, viewChild } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { FilePathService } from '../views/file-path.service'; +import { ImageElementService } from '../../services/image-element.service'; +import { ModalService } from '../modal/modal.service'; +import { PlaylistService } from '../../services/playlist.service'; +import { SourceFolderService } from '../statistics/source-folder.service'; + +import type { ContextMenuCoordinate } from '../../../../interfaces/shared-interfaces'; +import type { ImageElement } from '../../../../interfaces/final-object.interface'; +import type { Playlist, PlaylistEntry } from '../../../../interfaces/playlist.interface'; +import type { PlaylistPickerMode } from './playlist-picker.component'; + +import { DRAG_ENTRIES, formatDuration, hasDragType } from './playlist-utils'; +import type { DraggedEntries } from './playlist-utils'; + +export interface PlaylistRow { + entry: PlaylistEntry; + element: ImageElement; // undefined when missing from the hub + number: number; // 1-based position + title: string; + folder: string; + timing: string; // `1:12 – 1:31` for highlights, duration for whole videos + isHighlight: boolean; + missing: boolean; + offline: boolean; + thumbnail: string; // thumbnail for whole videos; filmstrip for highlights + frame: number; // which filmstrip frame to show + screens: number; // frames in the filmstrip +} + +/** + * Shows the entries of the open playlist in place of the gallery + * - click / Ctrl+click / Shift+click to select; Delete removes; Ctrl+A selects all + * - drag rows to reorder; drag onto a playlist in the sidebar to move (Ctrl to copy) + * - double-click plays from that row + */ +@Component({ + standalone: false, + selector: 'app-playlist-view', + templateUrl: './playlist-view.component.html', + styleUrls: ['./playlist-view.component.scss'], + host: { + 'tabindex': '0', + '(keydown)': 'onKey($event)', + } +}) +export class PlaylistViewComponent { + + readonly renameInput = viewChild('renameInput'); + + readonly darkMode = input(false); + readonly hubName = input(''); + readonly outputFolder = input(''); + + selected: Set = new Set(); + private anchorId: string = null; // for Shift+click ranges + + dropBeforeId: string = null; // drop indicator: before this row + dropAtEnd = false; // drop indicator: after the last row + private dragging = false; + + menuRow: PlaylistRow = null; + menuPosition: ContextMenuCoordinate = { x: 0, y: 0 }; + + pickerMode: PlaylistPickerMode = null; + pickerPosition: ContextMenuCoordinate = { x: 0, y: 0 }; + + renaming = false; + renameValue = ''; + + // rows are cached -- rebuilt only when the playlist or the hub's videos change + private cachedRows: PlaylistRow[] = []; + private cachedFor = ''; + private cachedImages: ImageElement[] = null; + private cachedImagesLength = -1; + private shownPlaylistId: string = null; + + formatDuration = formatDuration; + + constructor( + public filePathService: FilePathService, + public imageElementService: ImageElementService, + public modalService: ModalService, + public playlistService: PlaylistService, + public sourceFolderService: SourceFolderService, + public translate: TranslateService, + ) { } + + playlist(): Playlist { + this.playlistService.version(); // re-evaluate on change + const playlist: Playlist = this.playlistService.openPlaylist; + if (playlist && playlist.id !== this.shownPlaylistId) { + // switched to another playlist -- reset local state + this.shownPlaylistId = playlist.id; + this.selected = new Set(); + this.anchorId = null; + this.renaming = false; + } + return playlist; + } + + rows(): PlaylistRow[] { + const playlist: Playlist = this.playlist(); + if (!playlist) { + return []; + } + + const images: ImageElement[] = this.imageElementService.imageElements; + // folders connect a moment after the hub loads, so their state is part of the key + const connected: string = JSON.stringify(this.sourceFolderService.sourceFolderConnected); + const key: string = playlist.id + ':' + this.playlistService.version() + ':' + connected; + + if (key !== this.cachedFor || images !== this.cachedImages || images.length !== this.cachedImagesLength) { + this.cachedRows = playlist.entries.map((entry: PlaylistEntry, i: number) => this.buildRow(entry, i)); + this.cachedFor = key; + this.cachedImages = images; + this.cachedImagesLength = images.length; + } + + return this.cachedRows; + } + + countKey(playlist: Playlist): string { + const one: boolean = playlist.entries.length === 1; + return playlist.kind === 'compilation' + ? (one ? 'PLAYLISTS.countClip' : 'PLAYLISTS.countClips') + : (one ? 'PLAYLISTS.countVideo' : 'PLAYLISTS.countVideos'); + } + + totalDuration(): string { + const playlist: Playlist = this.playlist(); + return playlist ? formatDuration(this.playlistService.playlistDuration(playlist)) : ''; + } + + private buildRow(entry: PlaylistEntry, i: number): PlaylistRow { + const element: ImageElement = this.playlistService.resolve(entry); + const isHighlight: boolean = entry.start !== undefined && entry.end !== undefined; + + const row: PlaylistRow = { + entry: entry, + element: element, + number: i + 1, + title: entry.label || (element ? element.cleanName : entry.fileName), + folder: element ? element.partialPath : entry.partialPath, + timing: isHighlight + ? formatDuration(entry.start) + ' – ' + formatDuration(entry.end) + : (element ? formatDuration(element.duration) : ''), + isHighlight: isHighlight, + missing: !element, + offline: !!element && !this.sourceFolderService.sourceFolderConnected[element.inputSource], + thumbnail: '', + frame: 0, + screens: 1, + }; + + if (element) { + if (isHighlight && element.screens > 0) { + // screenshots are taken at duration / (screens + 1) * (n + 1) + const step: number = element.duration / (element.screens + 1); + row.frame = Math.min(element.screens - 1, Math.max(0, Math.round(entry.start / step) - 1)); + row.screens = element.screens; + row.thumbnail = this.filePathService.createFilePath(this.outputFolder(), this.hubName(), 'filmstrips', element.hash); + } else { + row.thumbnail = this.filePathService.createFilePath(this.outputFolder(), this.hubName(), 'thumbnails', element.hash); + } + } + + return row; + } + + // ---------------------------------------------------------------- header actions + + play(fromEntryId?: string, shuffle?: boolean): void { + if (!this.playlistService.playExternally(this.playlist().id, fromEntryId, shuffle)) { + this.modalService.openSnackbar(this.translate.instant('PLAYLISTS.nothingToPlay'), 2500); + } + } + + close(): void { + this.playlistService.close(); + } + + startRenaming(): void { + this.renaming = true; + this.renameValue = this.playlist().name; + setTimeout(() => this.renameInput()?.nativeElement.select(), 0); + } + + renameKey(event: KeyboardEvent): void { + event.stopPropagation(); + if (event.key === 'Enter') { + this.finishRenaming(); + } else if (event.key === 'Escape') { + this.renaming = false; + } + } + + finishRenaming(): void { + if (this.renaming) { + this.playlistService.rename(this.playlist().id, this.renameValue); + this.renaming = false; + } + } + + // ---------------------------------------------------------------- selection + + rowClicked(event: MouseEvent, row: PlaylistRow): void { + const id: string = row.entry.id; + + if (event.shiftKey && this.anchorId) { + const ids: string[] = this.rows().map((r: PlaylistRow) => r.entry.id); + const from: number = ids.indexOf(this.anchorId); + const to: number = ids.indexOf(id); + if (from !== -1 && to !== -1) { + if (!event.ctrlKey) { + this.selected = new Set(); + } + ids.slice(Math.min(from, to), Math.max(from, to) + 1).forEach((rangeId: string) => this.selected.add(rangeId)); + return; + } + } + + if (event.ctrlKey || event.metaKey) { + if (this.selected.has(id)) { + this.selected.delete(id); + } else { + this.selected.add(id); + } + } else { + this.selected = new Set([id]); + } + this.anchorId = id; + } + + selectedIdsInOrder(): string[] { + return this.rows() + .map((row: PlaylistRow) => row.entry.id) + .filter((id: string) => this.selected.has(id)); + } + + removeSelected(): void { + const ids: string[] = this.selectedIdsInOrder(); + if (ids.length) { + this.playlistService.removeEntries(this.playlist().id, ids); + this.selected = new Set(); + } + } + + onKey(event: KeyboardEvent): void { + if (this.renaming || (event.target as HTMLElement).tagName === 'INPUT') { + return; + } + if (event.key === 'Delete' || event.key === 'Backspace') { + event.stopPropagation(); + this.removeSelected(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.stopPropagation(); + event.preventDefault(); + this.selected = new Set(this.rows().map((row: PlaylistRow) => row.entry.id)); + } else if (event.key === 'Escape' && this.selected.size) { + event.stopPropagation(); + this.selected = new Set(); + } else if (event.key === 'Enter' && this.selected.size) { + event.stopPropagation(); + this.play(this.selectedIdsInOrder()[0]); + } + } + + // ---------------------------------------------------------------- right-click menu + + openMenu(event: MouseEvent, row: PlaylistRow): void { + event.preventDefault(); + if (!this.selected.has(row.entry.id)) { + this.selected = new Set([row.entry.id]); + this.anchorId = row.entry.id; + } + this.menuRow = row; + this.menuPosition = { + x: Math.min(event.clientX, window.innerWidth - 200), + y: Math.min(event.clientY, window.innerHeight - 200), + }; + } + + closeMenu(): void { + this.menuRow = null; + } + + openPicker(mode: PlaylistPickerMode): void { + this.pickerMode = mode; + this.pickerPosition = { ...this.menuPosition }; + this.closeMenu(); + } + + picked(targetId: string): void { + const copy: boolean = this.pickerMode === 'copy'; + const target: Playlist = this.playlistService.get(targetId); + const count: number = this.playlistService.transferEntries(this.playlist().id, targetId, this.selectedIdsInOrder(), copy); + this.pickerMode = null; + if (!copy) { + this.selected = new Set(); + } + this.modalService.openSnackbar( + this.translate.instant(copy ? 'PLAYLISTS.copiedToSnack' : 'PLAYLISTS.movedToSnack', { count: count, name: target.name }), + 2000 + ); + } + + openContainingFolder(): void { + const element: ImageElement = this.menuRow.element; + this.closeMenu(); + if (element) { + (window as any).myElectron.sendToMain('open-in-explorer', this.filePathService.getPathFromImageElement(element)); + } + } + + // ---------------------------------------------------------------- drag & drop + + dragStart(event: DragEvent, row: PlaylistRow): void { + if (!this.selected.has(row.entry.id)) { + this.selected = new Set([row.entry.id]); + this.anchorId = row.entry.id; + } + const payload: DraggedEntries = { from: this.playlist().id, ids: this.selectedIdsInOrder() }; + event.dataTransfer.setData(DRAG_ENTRIES, JSON.stringify(payload)); + event.dataTransfer.effectAllowed = 'copyMove'; + this.dragging = true; + } + + dragEnd(): void { + this.dragging = false; + this.dropBeforeId = null; + this.dropAtEnd = false; + } + + dragOverRow(event: DragEvent, row: PlaylistRow, index: number): void { + if (!this.dragging || !hasDragType(event, DRAG_ENTRIES)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + + const rect: DOMRect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + const lowerHalf: boolean = event.clientY > rect.top + rect.height / 2; + const rows: PlaylistRow[] = this.rows(); + + if (lowerHalf) { + const next: PlaylistRow = rows[index + 1]; + this.dropBeforeId = next ? next.entry.id : null; + this.dropAtEnd = !next; + } else { + this.dropBeforeId = row.entry.id; + this.dropAtEnd = false; + } + } + + dragOverEnd(event: DragEvent): void { + if (!this.dragging || !hasDragType(event, DRAG_ENTRIES)) { + return; + } + event.preventDefault(); + this.dropBeforeId = null; + this.dropAtEnd = true; + } + + drop(event: DragEvent): void { + if (!this.dragging || !hasDragType(event, DRAG_ENTRIES)) { + return; + } + event.preventDefault(); + const dragged: DraggedEntries = JSON.parse(event.dataTransfer.getData(DRAG_ENTRIES)); + if (this.dropBeforeId || this.dropAtEnd) { + this.playlistService.reorderEntries(dragged.from, dragged.ids, this.dropAtEnd ? null : this.dropBeforeId); + } + this.dragEnd(); + } + +} diff --git a/src/app/components/playlists/playlists-panel.component.html b/src/app/components/playlists/playlists-panel.component.html new file mode 100644 index 000000000..d12c067d4 --- /dev/null +++ b/src/app/components/playlists/playlists-panel.component.html @@ -0,0 +1,137 @@ +
    + +
    + {{ 'PLAYLISTS.title' | translate }} + + + + +
    + + @switch (playlistService.loadProblem()) { + @case ('unreadable') { +
    {{ 'PLAYLISTS.unreadable' | translate }}
    + } + @case ('restoredFromBackup') { +
    {{ 'PLAYLISTS.restoredFromBackup' | translate }}
    + } + } + + @if (playlistService.saveError()) { +
    {{ 'PLAYLISTS.saveFailed' | translate }}
    + } + + @if (creatingKind) { + + } + +
      +
    • + + {{ 'PLAYLISTS.allVideos' | translate }} +
    • + @for (playlist of list(); track playlist.id) { +
    • + + @if (renamingId === playlist.id) { + + } @else { + {{ playlist.name }} + {{ playlist.entries.length }} + } +
    • + } +
    + +
    + @if (!list().length && !creatingKind) { + {{ 'PLAYLISTS.emptyPanel' | translate }} + } +
    + +
    + +@if (menuFor) { +
    +
    + +
    + @if (!confirmingDelete) { +
      +
    • {{ 'PLAYLISTS.play' | translate }}
    • +
    • {{ 'PLAYLISTS.shufflePlay' | translate }}
    • +
    • {{ 'PLAYLISTS.rename' | translate }}
    • +
    • {{ 'PLAYLISTS.duplicate' | translate }}
    • + @if (selectedVideos.length) { +
    • {{ 'PLAYLISTS.addSelected' | translate: { count: selectedVideos.length } }}
    • + } + +
    + } @else { + + } +
    +
    +} diff --git a/src/app/components/playlists/playlists-panel.component.scss b/src/app/components/playlists/playlists-panel.component.scss new file mode 100644 index 000000000..0971b547e --- /dev/null +++ b/src/app/components/playlists/playlists-panel.component.scss @@ -0,0 +1,231 @@ +@use "../variables.scss"; + +.playlists-panel { + color: variables.$gray-80; + font-size: 12px; + margin: 10px 0 14px; +} + +.playlists-dark { + color: variables.$gray-20; +} + +.panel-header { + align-items: center; + display: flex; + gap: 6px; + margin-bottom: 6px; +} + +.panel-label { + color: variables.$gray-50; + flex: 1 1 auto; + font-size: 10px; + font-style: italic; +} + +.panel-add { + align-items: center; + border-radius: 4px; + color: variables.$gray-50; + cursor: pointer; + display: flex; + font-size: 11px; + padding: 1px 3px; + + &:hover { + background: rgba(114, 154, 255, 0.25); + color: variables.$pretty-blue; + } +} + +.panel-add-icon { + height: 13px; + width: 13px; +} + +.panel-input { + border: 1px solid variables.$gray-40; + border-radius: 5px; + box-sizing: border-box; + font-size: 12px; + margin-bottom: 4px; + padding: 3px 6px; + width: 100%; +} + +.panel-warning, +.panel-note { + border-radius: 5px; + font-size: 11px; + margin-bottom: 6px; + padding: 4px 6px; +} + +.panel-warning { + background: rgba(255, 102, 102, 0.2); + color: variables.$dark-red; +} + +.playlists-dark .panel-warning { + color: variables.$pretty-red-lighter; +} + +.panel-note { + background: rgba(114, 154, 255, 0.2); +} + +.panel-list { + margin: 0; + padding: 0; + + li { + align-items: center; + border-radius: 5px; + border-top: 2px solid transparent; + cursor: pointer; + display: flex; + gap: 6px; + list-style: none; + padding: 3px 5px; + + &:hover { + background: rgba(114, 154, 255, 0.15); + } + } + + .item-open { + background: rgba(114, 154, 255, 0.3); + color: variables.$dark-blue; + } + + .item-drop-target { + background: rgba(102, 180, 82, 0.35); + outline: 1px dashed variables.$pretty-green; + } + + .item-drop-before { + border-top-color: variables.$pretty-blue; + } +} + +.playlists-dark .panel-list .item-open { + color: variables.$light-blue; +} + +.panel-list .item-all-videos { + border-bottom: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 5px 5px 0 0; + margin-bottom: 3px; +} + +.item-icon { + flex: 0 0 14px; + height: 14px; + width: 14px; +} + +.item-name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-count { + color: variables.$gray-50; + flex: 0 0 auto; + font-size: 11px; +} + +.item-rename { + margin: 0; +} + +.panel-end-drop { + min-height: 10px; +} + +.panel-empty { + color: variables.$gray-50; + display: block; + font-size: 11px; + padding: 2px 5px; +} + +// ------------------------------------------------ right-click menu + +.panel-menu-overlay { + height: 100vh; + left: 0; + position: fixed; + top: 0; + width: 100vw; + z-index: 40; +} + +.panel-menu { + background: variables.$gray-10; + border: 1px solid variables.$gray-50; + border-radius: 10px; + box-shadow: 0 3px 7px 3px rgba(0, 0, 0, 0.3); + color: variables.$gray-90; + font-size: 12px; + min-width: 170px; + position: fixed; + z-index: 41; + + ul { + margin: 0; + padding: 8px; + + li { + border-radius: 5px; + cursor: pointer; + list-style: none; + padding: 6px; + + &:hover { + background: rgba(114, 154, 255, 0.25); + } + } + } + + .menu-delete { + border-top: 1px solid variables.$gray-20; + color: variables.$dark-red; + margin-top: 4px; + } +} + +.panel-menu-dark { + background: variables.$gray-80; + border-color: variables.$gray-60; + color: variables.$gray-10; + + .menu-delete { + border-top-color: variables.$gray-60; + color: variables.$pretty-red-lighter; + } +} + +.menu-confirm { + max-width: 200px; + padding: 12px; +} + +.menu-confirm-buttons { + display: flex; + gap: 14px; + justify-content: flex-end; + margin-top: 10px; + + span { + cursor: pointer; + } +} + +.menu-confirm-yes { + color: variables.$pretty-red; + font-weight: bold; +} diff --git a/src/app/components/playlists/playlists-panel.component.ts b/src/app/components/playlists/playlists-panel.component.ts new file mode 100644 index 000000000..a20580a26 --- /dev/null +++ b/src/app/components/playlists/playlists-panel.component.ts @@ -0,0 +1,249 @@ +import type { ElementRef } from '@angular/core'; +import { Component, input, viewChild } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { ImageElementService } from '../../services/image-element.service'; +import { ModalService } from '../modal/modal.service'; +import { PlaylistService } from '../../services/playlist.service'; + +import type { ContextMenuCoordinate } from '../../../../interfaces/shared-interfaces'; +import type { ImageElement } from '../../../../interfaces/final-object.interface'; +import type { Playlist, PlaylistKind } from '../../../../interfaces/playlist.interface'; + +import { DRAG_ENTRIES, DRAG_PLAYLIST, formatDuration, hasDragType } from './playlist-utils'; +import type { DraggedEntries } from './playlist-utils'; + +/** + * Sidebar list of playlists & compilations + * - click to open, double-click to rename, right-click for more + * - drop playlist rows on an item to move them there (hold Ctrl to copy) + * - drag items to reorder the list + */ +@Component({ + standalone: false, + selector: 'app-playlists-panel', + templateUrl: './playlists-panel.component.html', + styleUrls: ['./playlists-panel.component.scss'] +}) +export class PlaylistsPanelComponent { + + readonly newNameInput = viewChild('newNameInput'); + readonly renameInput = viewChild('renameInput'); + + readonly darkMode = input(false); + + creatingKind: PlaylistKind = null; + newName = ''; + + renamingId: string = null; + renameValue = ''; + + menuFor: Playlist = null; + menuPosition: ContextMenuCoordinate = { x: 0, y: 0 }; + confirmingDelete = false; + selectedVideos: ImageElement[] = []; + + dropTargetId: string = null; // entries being dropped onto this playlist + dropBeforeId: string = null; // playlist being reordered to before this one + + formatDuration = formatDuration; + + constructor( + public imageElementService: ImageElementService, + public modalService: ModalService, + public playlistService: PlaylistService, + public translate: TranslateService, + ) { } + + list(): Playlist[] { + this.playlistService.version(); // re-evaluate on change + return this.playlistService.playlists; + } + + /** + * Clicking the open playlist again goes back to the gallery + */ + open(playlist: Playlist): void { + if (this.renamingId === playlist.id) { + return; + } + if (this.playlistService.openPlaylistId === playlist.id) { + this.playlistService.close(); + } else { + this.playlistService.open(playlist.id); + } + } + + // ---------------------------------------------------------------- create + + startCreating(kind: PlaylistKind): void { + this.creatingKind = kind; + this.newName = ''; + setTimeout(() => this.newNameInput()?.nativeElement.focus(), 0); + } + + newNameKey(event: KeyboardEvent): void { + event.stopPropagation(); // keep app shortcuts from firing while typing + if (event.key === 'Enter') { + this.finishCreating(); + } else if (event.key === 'Escape') { + this.creatingKind = null; + } + } + + /** + * Creates the playlist but stays in the gallery -- an empty playlist has nothing to show, + * and videos are added from the gallery anyway + */ + finishCreating(): void { + if (this.creatingKind && this.newName.trim()) { + const playlist: Playlist = this.playlistService.create(this.newName, this.creatingKind); + if (playlist) { + this.modalService.openSnackbar(this.translate.instant('PLAYLISTS.createdSnack', { name: playlist.name }), 3000); + } + } + this.creatingKind = null; + } + + // ---------------------------------------------------------------- rename + + startRenaming(playlist: Playlist): void { + this.closeMenu(); + this.renamingId = playlist.id; + this.renameValue = playlist.name; + setTimeout(() => this.renameInput()?.nativeElement.select(), 0); + } + + renameKey(event: KeyboardEvent, playlist: Playlist): void { + event.stopPropagation(); + if (event.key === 'Enter') { + this.finishRenaming(playlist); + } else if (event.key === 'Escape') { + this.renamingId = null; + } + } + + finishRenaming(playlist: Playlist): void { + if (this.renamingId === playlist.id) { + this.playlistService.rename(playlist.id, this.renameValue); + this.renamingId = null; + } + } + + // ---------------------------------------------------------------- right-click menu + + openMenu(event: MouseEvent, playlist: Playlist): void { + event.preventDefault(); + this.menuFor = playlist; + this.confirmingDelete = false; + this.selectedVideos = this.imageElementService.imageElements.filter((element: ImageElement) => element.selected && !element.deleted); + this.menuPosition = { + x: Math.min(event.clientX, window.innerWidth - 200), + y: Math.min(event.clientY, window.innerHeight - 240), + }; + } + + closeMenu(): void { + this.menuFor = null; + this.confirmingDelete = false; + } + + play(shuffle: boolean): void { + const playlist: Playlist = this.menuFor; + this.closeMenu(); + if (!this.playlistService.playExternally(playlist.id, undefined, shuffle)) { + this.modalService.openSnackbar(this.translate.instant('PLAYLISTS.nothingToPlay'), 2500); + } + } + + duplicate(): void { + const copy: Playlist = this.playlistService.duplicate(this.menuFor.id); + this.closeMenu(); + if (copy) { + this.playlistService.open(copy.id); + } + } + + addSelected(): void { + const playlist: Playlist = this.menuFor; + const added: number = this.playlistService.addVideos(playlist.id, this.selectedVideos); + this.closeMenu(); + this.modalService.openSnackbar(this.translate.instant('PLAYLISTS.addedToSnack', { count: added, name: playlist.name }), 2000); + } + + delete(): void { + if (!this.confirmingDelete) { + this.confirmingDelete = true; + return; + } + this.playlistService.delete(this.menuFor.id); + this.closeMenu(); + } + + // ---------------------------------------------------------------- drag & drop + + dragPlaylistStart(event: DragEvent, playlist: Playlist): void { + if (this.renamingId) { + event.preventDefault(); + return; + } + event.dataTransfer.setData(DRAG_PLAYLIST, playlist.id); + event.dataTransfer.effectAllowed = 'move'; + } + + dragOver(event: DragEvent, playlist: Playlist): void { + if (hasDragType(event, DRAG_ENTRIES)) { + event.preventDefault(); + event.dataTransfer.dropEffect = event.ctrlKey ? 'copy' : 'move'; + this.dropTargetId = playlist.id; + } else if (hasDragType(event, DRAG_PLAYLIST)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + this.dropBeforeId = playlist.id; + } + } + + dragLeave(): void { + this.dropTargetId = null; + this.dropBeforeId = null; + } + + drop(event: DragEvent, playlist: Playlist): void { + event.preventDefault(); + event.stopPropagation(); + this.dragLeave(); + + if (hasDragType(event, DRAG_ENTRIES)) { + const dragged: DraggedEntries = JSON.parse(event.dataTransfer.getData(DRAG_ENTRIES)); + if (dragged.from === playlist.id) { + return; + } + const copy: boolean = event.ctrlKey; + const count: number = this.playlistService.transferEntries(dragged.from, playlist.id, dragged.ids, copy); + this.modalService.openSnackbar( + this.translate.instant(copy ? 'PLAYLISTS.copiedToSnack' : 'PLAYLISTS.movedToSnack', { count: count, name: playlist.name }), + 2000 + ); + } else if (hasDragType(event, DRAG_PLAYLIST)) { + this.playlistService.reorderPlaylist(event.dataTransfer.getData(DRAG_PLAYLIST), playlist.id); + } + } + + /** + * Dropping a playlist on the empty area below the list moves it to the end + */ + dropAtEnd(event: DragEvent): void { + if (hasDragType(event, DRAG_PLAYLIST)) { + event.preventDefault(); + this.dragLeave(); + this.playlistService.reorderPlaylist(event.dataTransfer.getData(DRAG_PLAYLIST), null); + } + } + + dragOverEnd(event: DragEvent): void { + if (hasDragType(event, DRAG_PLAYLIST)) { + event.preventDefault(); + } + } + +} diff --git a/src/app/components/playlists/to-body.directive.ts b/src/app/components/playlists/to-body.directive.ts new file mode 100644 index 000000000..fb84f722b --- /dev/null +++ b/src/app/components/playlists/to-body.directive.ts @@ -0,0 +1,25 @@ +import type { OnDestroy, OnInit } from '@angular/core'; +import { Directive, ElementRef } from '@angular/core'; + +/** + * Moves the element to `document.body` while it exists + * The sidebar and gallery use CSS transforms, which trap `position: fixed` popups inside them + * (clipped by the sidebar's edge and offset); at the body level a popup can go anywhere + */ +@Directive({ + standalone: false, + selector: '[appToBody]', +}) +export class ToBodyDirective implements OnInit, OnDestroy { + + constructor(private elementRef: ElementRef) { } + + ngOnInit(): void { + document.body.appendChild(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + this.elementRef.nativeElement.remove(); + } + +} diff --git a/src/app/components/views/thumbnail/thumbnail.component.html b/src/app/components/views/thumbnail/thumbnail.component.html index c9acb5fc2..477a00bb1 100644 --- a/src/app/components/views/thumbnail/thumbnail.component.html +++ b/src/app/components/views/thumbnail/thumbnail.component.html @@ -166,10 +166,10 @@
    diff --git a/src/app/components/views/thumbnail/thumbnail.component.ts b/src/app/components/views/thumbnail/thumbnail.component.ts index 8f2865f09..5c67dfc93 100644 --- a/src/app/components/views/thumbnail/thumbnail.component.ts +++ b/src/app/components/views/thumbnail/thumbnail.component.ts @@ -6,6 +6,7 @@ import { FilePathService } from '../file-path.service'; import { metaAppear, textAppear } from '../../../common/animations'; import { ImageElementService } from './../../../services/image-element.service'; +import { PlaylistService } from './../../../services/playlist.service'; import type { ImageElement } from '../../../../../interfaces/final-object.interface'; import type { VideoClickEmit, RightClickEmit } from '../../../../../interfaces/shared-interfaces'; @@ -61,6 +62,7 @@ export class ThumbnailComponent implements OnInit, OnDestroy { constructor( public filePathService: FilePathService, public imageElementService: ImageElementService, + public playlistService: PlaylistService, ) { } ngOnInit() { @@ -142,10 +144,22 @@ export class ThumbnailComponent implements OnInit, OnDestroy { this.heartPressed.emit(); } + inLastUsedPlaylist(): boolean { + this.playlistService.version(); // re-evaluate on change + return this.playlistService.isInLastUsed(this.video.hash); + } + + lastUsedPlaylistName(): string { + return this.playlistService.lastUsedPlaylist?.name || 'Playlist'; + } + + /** + * Add to / remove from the last used playlist + */ togglePlaylist(leftClick: PointerEvent): void { leftClick.stopPropagation(); - this.imageElementService.updatePlaylist(this.video.index); + this.playlistService.toggleInLastUsed(this.video); this.refreshPlaylist.emit(); } diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts new file mode 100644 index 000000000..1c607767e --- /dev/null +++ b/src/app/services/playlist.service.ts @@ -0,0 +1,600 @@ +import { Injectable, NgZone, signal } from '@angular/core'; + +import { FilePathService } from '../components/views/file-path.service'; +import { ImageElementService } from './image-element.service'; +import { SourceFolderService } from '../components/statistics/source-folder.service'; + +import type { ImageElement } from '../../../interfaces/final-object.interface'; +import type { + ExternalPlaylistItem, + Playlist, + PlaylistEntry, + PlaylistFile, + PlaylistKind, + PlaylistLoadStatus +} from '../../../interfaces/playlist.interface'; + +const SAVE_DELAY_MS = 2000; + +export interface EntryRange { + start: number; + end: number; + label?: string; +} + +/** + * Named playlists & compilations for the currently open hub + * Persisted by node in `.playlists.json` (see `node/main-playlists.ts`) + */ +@Injectable({ providedIn: 'root' }) +export class PlaylistService { + + playlists: Playlist[] = []; + + openPlaylistId: string = null; // playlist shown in place of the gallery + lastUsedPlaylistId: string = null; // target of the thumbnail playlist icon + + // set by `HomeComponent` -- its `appState` object is replaced when settings load, so it is read through this + preferredPlayer: () => string = () => ''; + + // bumps on every change so views can recompute + readonly version = signal(0); + + // set when the playlists file could not be read -- show a message and never overwrite it + readonly loadProblem = signal(''); + readonly saveError = signal(''); + + private vhaPath = ''; + private hubName = ''; + private loaded = false; + private savingBlocked = false; + + private saveTimeout: any = null; + private dirty = false; + + // lookup tables -- rebuilt when `imageElements` changes + private indexedArray: ImageElement[] = null; + private indexedLength = -1; + private byHash: Map = new Map(); + private byPath: Map = new Map(); + + // legacy (single, unnamed) playlist from the `.vha2` -- migrated when the hub has no playlists file + private legacyCandidates: ImageElement[] = []; + + constructor( + private filePathService: FilePathService, + private imageElementService: ImageElementService, + private sourceFolderService: SourceFolderService, + private zone: NgZone, + ) { + (window as any).myElectron.receiveFromMain('playlists-returning', ( + vhaPath: string, + data: PlaylistFile, + status: PlaylistLoadStatus + ) => { + this.zone.run(() => this.playlistsReturned(vhaPath, data, status)); + }); + + (window as any).myElectron.receiveFromMain('playlists-saved', (vhaPath: string, errorMessage: string) => { + this.zone.run(() => { + if (vhaPath === this.vhaPath) { + this.saveError.set(errorMessage); + } + }); + }); + } + + // =========================================================================================== + // Loading & saving + // ------------------------------------------------------------------------------------------- + + /** + * Called whenever a hub finishes loading + * @param vhaPath full path to the `.vha2` file + * @param hubName + * @param images used only to migrate the legacy single playlist + */ + loadForHub(vhaPath: string, hubName: string, images: ImageElement[]): void { + this.flush(); // just in case -- should already have happened before the hub switched + + clearTimeout(this.saveTimeout); + this.vhaPath = vhaPath; + this.hubName = hubName; + this.playlists = []; + this.openPlaylistId = null; + this.lastUsedPlaylistId = null; + this.loaded = false; + this.savingBlocked = false; + this.dirty = false; + this.loadProblem.set(''); + this.saveError.set(''); + this.indexedArray = null; + + this.legacyCandidates = images + .filter((element: ImageElement) => element.playlist) + .sort((a: ImageElement, b: ImageElement) => a.playlist - b.playlist); + + this.bump(); + + (window as any).myElectron.sendToMain('please-load-playlists', vhaPath); + } + + private playlistsReturned(vhaPath: string, data: PlaylistFile, status: PlaylistLoadStatus): void { + if (vhaPath !== this.vhaPath) { + return; // user already switched to another hub + } + + this.loadProblem.set(status); + + if (status === 'unreadable') { + this.savingBlocked = true; + } else if (data) { + this.playlists = data.playlists; + this.lastUsedPlaylistId = data.lastUsedPlaylistId || null; + if (status === 'restoredFromBackup') { + this.dirty = true; + } + } else if (this.legacyCandidates.length) { + const migrated: Playlist = this.newPlaylistObject('Playlist', 'playlist'); + migrated.entries = this.legacyCandidates.map((element: ImageElement) => this.newEntry(element)); + this.playlists = [migrated]; + this.lastUsedPlaylistId = migrated.id; + this.dirty = true; + } + + this.legacyCandidates = []; + this.loaded = true; + + if (this.dirty) { + this.scheduleSave(); + } + this.bump(); + } + + /** + * Whether playlists can be changed right now + */ + get ready(): boolean { + return this.loaded && !this.savingBlocked; + } + + /** + * Save immediately if anything changed -- call before closing the app or switching hubs + */ + flush(): void { + clearTimeout(this.saveTimeout); + + if (!this.dirty || !this.loaded || this.savingBlocked || !this.vhaPath) { + return; + } + + const data: PlaylistFile = { + version: 1, + hubName: this.hubName, + lastUsedPlaylistId: this.lastUsedPlaylistId || undefined, + playlists: this.playlists, + }; + + (window as any).myElectron.sendToMain('please-save-playlists', this.vhaPath, data); + this.dirty = false; + } + + private changed(playlist?: Playlist): void { + if (playlist) { + playlist.modified = Date.now(); + } + this.dirty = true; + this.scheduleSave(); + this.bump(); + } + + private scheduleSave(): void { + clearTimeout(this.saveTimeout); + this.saveTimeout = setTimeout(() => this.flush(), SAVE_DELAY_MS); + } + + private bump(): void { + this.version.update(value => value + 1); + if (!NgZone.isInAngularZone()) { + // changes made from outside Angular (e.g. IPC callbacks) still refresh the screen + this.zone.run(() => {}); + } + } + + // =========================================================================================== + // Playlists + // ------------------------------------------------------------------------------------------- + + get(playlistId: string): Playlist { + return this.playlists.find((playlist: Playlist) => playlist.id === playlistId); + } + + get openPlaylist(): Playlist { + return this.openPlaylistId ? this.get(this.openPlaylistId) : undefined; + } + + get lastUsedPlaylist(): Playlist { + return this.lastUsedPlaylistId ? this.get(this.lastUsedPlaylistId) : undefined; + } + + open(playlistId: string): void { + this.openPlaylistId = playlistId; + this.bump(); + } + + close(): void { + this.openPlaylistId = null; + this.bump(); + } + + create(name: string, kind: PlaylistKind): Playlist { + if (!this.ready) { + return undefined; + } + const playlist: Playlist = this.newPlaylistObject(this.uniqueName(name), kind); + this.playlists.push(playlist); + this.lastUsedPlaylistId = playlist.id; + this.changed(); + return playlist; + } + + rename(playlistId: string, newName: string): void { + const playlist: Playlist = this.get(playlistId); + const trimmed: string = (newName || '').trim(); + if (!this.ready || !playlist || !trimmed || trimmed === playlist.name) { + return; + } + playlist.name = trimmed; + this.changed(playlist); + } + + duplicate(playlistId: string): Playlist { + const original: Playlist = this.get(playlistId); + if (!this.ready || !original) { + return undefined; + } + const copy: Playlist = this.newPlaylistObject(this.uniqueName(original.name), original.kind); + copy.entries = original.entries.map((entry: PlaylistEntry) => ({ ...entry, id: this.uuid() })); + this.playlists.splice(this.playlists.indexOf(original) + 1, 0, copy); + this.changed(); + return copy; + } + + delete(playlistId: string): void { + const index: number = this.playlists.findIndex((playlist: Playlist) => playlist.id === playlistId); + if (!this.ready || index === -1) { + return; + } + this.playlists.splice(index, 1); + if (this.openPlaylistId === playlistId) { + this.openPlaylistId = null; + } + if (this.lastUsedPlaylistId === playlistId) { + this.lastUsedPlaylistId = null; + } + this.changed(); + } + + /** + * Move a playlist in the sidebar list to just before another one (or to the end) + */ + reorderPlaylist(playlistId: string, beforePlaylistId: string | null): void { + const playlist: Playlist = this.get(playlistId); + if (!this.ready || !playlist || playlistId === beforePlaylistId) { + return; + } + this.playlists.splice(this.playlists.indexOf(playlist), 1); + const target: number = beforePlaylistId + ? this.playlists.findIndex((p: Playlist) => p.id === beforePlaylistId) + : -1; + this.playlists.splice(target === -1 ? this.playlists.length : target, 0, playlist); + this.changed(); + } + + // =========================================================================================== + // Entries + // ------------------------------------------------------------------------------------------- + + /** + * Add videos (whole) or one highlight to a playlist + * Whole videos already in the playlist are skipped, highlights never are + * @returns number of entries added + */ + addVideos(playlistId: string, elements: ImageElement[], range?: EntryRange): number { + const playlist: Playlist = this.get(playlistId); + if (!this.ready || !playlist) { + return 0; + } + + const videos: ImageElement[] = elements.filter((element: ImageElement) => element && element.cleanName !== '*FOLDER*'); + + let added = 0; + videos.forEach((element: ImageElement) => { + if (!range && this.containsWholeVideo(playlist, element.hash)) { + return; + } + playlist.entries.push(this.newEntry(element, range)); + added++; + }); + + this.lastUsedPlaylistId = playlist.id; + this.changed(playlist); + return added; + } + + removeEntries(playlistId: string, entryIds: string[]): void { + const playlist: Playlist = this.get(playlistId); + if (!this.ready || !playlist) { + return; + } + playlist.entries = playlist.entries.filter((entry: PlaylistEntry) => !entryIds.includes(entry.id)); + this.changed(playlist); + } + + /** + * Move or copy entries into another playlist (appended at the end) + * When copying, whole videos already in the target are skipped + */ + transferEntries(fromId: string, toId: string, entryIds: string[], copy: boolean): number { + const from: Playlist = this.get(fromId); + const to: Playlist = this.get(toId); + if (!this.ready || !from || !to || fromId === toId) { + return 0; + } + + const toTransfer: PlaylistEntry[] = from.entries.filter((entry: PlaylistEntry) => entryIds.includes(entry.id)); + + let transferred = 0; + toTransfer.forEach((entry: PlaylistEntry) => { + const isWholeVideo: boolean = entry.start === undefined; + if (isWholeVideo && this.containsWholeVideo(to, entry.hash)) { + return; // already there -- not added twice, but a move still removes it from the source below + } + to.entries.push({ ...entry, id: this.uuid(), added: Date.now() }); + transferred++; + }); + + if (!copy) { + from.entries = from.entries.filter((entry: PlaylistEntry) => !entryIds.includes(entry.id)); + from.modified = Date.now(); + } + + this.lastUsedPlaylistId = to.id; + this.changed(to); + return transferred; + } + + /** + * Move a set of entries (keeping their relative order) to just before another entry, or to the end + */ + reorderEntries(playlistId: string, entryIds: string[], beforeEntryId: string | null): void { + const playlist: Playlist = this.get(playlistId); + if (!this.ready || !playlist || entryIds.includes(beforeEntryId)) { + return; + } + + const moving: PlaylistEntry[] = playlist.entries.filter((entry: PlaylistEntry) => entryIds.includes(entry.id)); + const rest: PlaylistEntry[] = playlist.entries.filter((entry: PlaylistEntry) => !entryIds.includes(entry.id)); + + const target: number = beforeEntryId ? rest.findIndex((entry: PlaylistEntry) => entry.id === beforeEntryId) : -1; + rest.splice(target === -1 ? rest.length : target, 0, ...moving); + + playlist.entries = rest; + this.changed(playlist); + } + + /** + * Thumbnail playlist icon -- add to or remove from the last used playlist (creating "Playlist" if needed) + */ + toggleInLastUsed(element: ImageElement): void { + if (!this.ready) { + return; + } + + let playlist: Playlist = this.lastUsedPlaylist; + if (!playlist) { + playlist = this.playlists.find((p: Playlist) => p.kind === 'playlist') || this.create('Playlist', 'playlist'); + } + + if (this.containsWholeVideo(playlist, element.hash)) { + playlist.entries = playlist.entries.filter((entry: PlaylistEntry) => !(entry.hash === element.hash && entry.start === undefined)); + this.lastUsedPlaylistId = playlist.id; + this.changed(playlist); + } else { + this.addVideos(playlist.id, [element]); + } + } + + isInLastUsed(hash: string): boolean { + const playlist: Playlist = this.lastUsedPlaylist; + return !!playlist && this.containsWholeVideo(playlist, hash); + } + + containsWholeVideo(playlist: Playlist, hash: string): boolean { + return playlist.entries.some((entry: PlaylistEntry) => entry.hash === hash && entry.start === undefined); + } + + // =========================================================================================== + // Resolving entries to videos + // ------------------------------------------------------------------------------------------- + + /** + * Find the video an entry points to; `undefined` if missing from the hub + * Re-links the entry by path when the hash changed (file edited) + */ + resolve(entry: PlaylistEntry): ImageElement { + this.ensureIndex(); + + let element: ImageElement = this.byHash.get(entry.hash); + + if (!element || element.deleted) { + element = this.byPath.get(this.pathKey(entry.inputSource, entry.partialPath, entry.fileName)); + if (!element || element.deleted) { + return undefined; + } + entry.hash = element.hash; // re-link + this.dirty = true; + this.scheduleSave(); + } + + // keep the re-link hints current (e.g. after a rename inside the app) -- saved with the next save + entry.fileName = element.fileName; + entry.partialPath = element.partialPath; + entry.inputSource = element.inputSource; + + return element; + } + + entryDuration(entry: PlaylistEntry, element?: ImageElement): number { + if (entry.start !== undefined && entry.end !== undefined) { + return entry.end - entry.start; + } + const video: ImageElement = element || this.resolve(entry); + return video ? video.duration : 0; + } + + playlistDuration(playlist: Playlist): number { + return playlist.entries.reduce((total: number, entry: PlaylistEntry) => total + this.entryDuration(entry), 0); + } + + /** + * Build the list for an external player -- skips missing videos and disconnected folders + */ + externalItems(playlist: Playlist, fromEntryId?: string, shuffle?: boolean): ExternalPlaylistItem[] { + let entries: PlaylistEntry[] = playlist.entries; + + if (fromEntryId) { + const start: number = entries.findIndex((entry: PlaylistEntry) => entry.id === fromEntryId); + entries = entries.slice(Math.max(start, 0)); + } + + const items: ExternalPlaylistItem[] = []; + + entries.forEach((entry: PlaylistEntry) => { + const element: ImageElement = this.resolve(entry); + if (!element || !this.sourceFolderService.sourceFolderConnected[element.inputSource]) { + return; + } + items.push({ + duration: element.duration, + end: entry.end, + fullPath: this.filePathService.getPathFromImageElement(element), + start: entry.start, + title: entry.label || element.cleanName, + }); + }); + + if (shuffle) { + for (let i = items.length - 1; i > 0; i--) { + const j: number = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + } + + return items; + } + + /** + * Open the playlist in the preferred external player (or the system default) as an `.m3u8` + * @returns number of entries sent -- 0 when nothing playable + */ + playExternally(playlistId: string, fromEntryId?: string, shuffle?: boolean): number { + const playlist: Playlist = this.get(playlistId); + if (!playlist) { + return 0; + } + const items: ExternalPlaylistItem[] = this.externalItems(playlist, fromEntryId, shuffle); + if (items.length) { + (window as any).myElectron.sendToMain('please-play-playlist-externally', playlist.name, items, this.preferredPlayer()); + } + return items.length; + } + + private ensureIndex(): void { + const images: ImageElement[] = this.imageElementService.imageElements; + + if (images === this.indexedArray && images.length === this.indexedLength) { + return; + } + + this.byHash = new Map(); + this.byPath = new Map(); + + images.forEach((element: ImageElement) => { + if (element.deleted) { + return; + } + if (!this.byHash.has(element.hash)) { + this.byHash.set(element.hash, element); + } + this.byPath.set(this.pathKey(element.inputSource, element.partialPath, element.fileName), element); + }); + + this.indexedArray = images; + this.indexedLength = images.length; + } + + private pathKey(inputSource: number, partialPath: string, fileName: string): string { + return inputSource + '|' + partialPath + '|' + fileName; + } + + // =========================================================================================== + // Helpers + // ------------------------------------------------------------------------------------------- + + private newPlaylistObject(name: string, kind: PlaylistKind): Playlist { + const now: number = Date.now(); + return { + id: this.uuid(), + name: name, + kind: kind, + created: now, + modified: now, + entries: [], + }; + } + + private newEntry(element: ImageElement, range?: EntryRange): PlaylistEntry { + const entry: PlaylistEntry = { + id: this.uuid(), + hash: element.hash, + added: Date.now(), + fileName: element.fileName, + partialPath: element.partialPath, + inputSource: element.inputSource, + }; + if (range) { + entry.start = range.start; + entry.end = range.end; + if (range.label) { + entry.label = range.label; + } + } + return entry; + } + + /** + * `Summer` => `Summer 2` if a playlist called `Summer` exists + */ + private uniqueName(name: string): string { + const base: string = (name || '').trim() || 'Playlist'; + const taken: Set = new Set(this.playlists.map((playlist: Playlist) => playlist.name.toLowerCase())); + if (!taken.has(base.toLowerCase())) { + return base; + } + let n = 2; + while (taken.has((base + ' ' + n).toLowerCase())) { + n++; + } + return base + ' ' + n; + } + + private uuid(): string { + try { + return crypto.randomUUID(); + } catch { + return Date.now().toString(36) + Math.random().toString(36).slice(2); + } + } + +}