Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions interfaces/playlist.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Playlists and compilations -- stored in `<hubName>.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';
3 changes: 3 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
172 changes: 172 additions & 0 deletions node/main-playlists.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set();

// files that failed to parse -- never back these up over a good `.bak`
const unreadableFiles: Set<string> = 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);
}
});

}
8 changes: 8 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,7 +156,10 @@ import { YearPipe } from './pipes/year.pipe';
MetaComponent,
ModalComponent,
PlaylistOnlyPipe,
PlaylistPickerComponent,
PlaylistPipe,
PlaylistsPanelComponent,
PlaylistViewComponent,
RecentlyOpenedComponent,
RegexSearchPipe,
RenameFileComponent,
Expand Down Expand Up @@ -185,6 +192,7 @@ import { YearPipe } from './pipes/year.pipe';
TagTrayComponent,
ThumbnailComponent,
TimesPlayedFilterPipe,
ToBodyDirective,
TimesPlayedPipe,
TitleBarComponent,
TopComponent,
Expand Down
13 changes: 10 additions & 3 deletions src/app/common/settings-buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type SettingsButtonKey = 'autoFileTags'
| 'showMoreInfo'
| 'showOnlyFavorites'
| 'showOnlyPlaylist'
| 'showPlaylists'
| 'showRecent'
| 'showRecentlyPlayed'
| 'showRelatedVideosTray'
Expand Down Expand Up @@ -143,7 +144,6 @@ export const SettingsButtonsGroups: SettingsButtonKey[][] = [
'sortOptionCreated',
'sortOptionTags',
'sortOptionAspectRatio',
'sortOptionPlaylist',
'sortOptionFps',
'sortOptionFolderSize',
],
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Loading