@@ -134,13 +138,15 @@
diff --git a/src/renderer/main.js b/src/renderer/main.js
index 43e30da39ac2e..7b400874e9e98 100644
--- a/src/renderer/main.js
+++ b/src/renderer/main.js
@@ -16,6 +16,7 @@ import {
faArrowDown,
faArrowLeft,
faArrowRight,
+ faArrowUp,
faBars,
faBookmark,
faCheck,
@@ -26,6 +27,7 @@ import {
faCommentDots,
faCopy,
faDownload,
+ faEdit,
faEllipsisH,
faEllipsisV,
faEnvelope,
@@ -48,11 +50,13 @@ import {
faNewspaper,
faPause,
faPlay,
+ faPlus,
faQuestionCircle,
faRandom,
faRetweet,
faRss,
faSatelliteDish,
+ faSave,
faSearch,
faShareAlt,
faSlidersH,
@@ -66,6 +70,7 @@ import {
faThumbtack,
faTimes,
faTimesCircle,
+ faTrash,
faUsers,
} from '@fortawesome/free-solid-svg-icons'
import {
@@ -89,6 +94,7 @@ library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
+ faArrowUp,
faBars,
faBookmark,
faCheck,
@@ -99,6 +105,7 @@ library.add(
faCommentDots,
faCopy,
faDownload,
+ faEdit,
faEllipsisH,
faEllipsisV,
faEnvelope,
@@ -121,11 +128,13 @@ library.add(
faNewspaper,
faPause,
faPlay,
+ faPlus,
faQuestionCircle,
faRandom,
faRetweet,
faRss,
faSatelliteDish,
+ faSave,
faSearch,
faShareAlt,
faSlidersH,
@@ -139,6 +148,7 @@ library.add(
faThumbtack,
faTimes,
faTimesCircle,
+ faTrash,
faUsers,
// brand icons
diff --git a/src/renderer/scss-partials/_ft-list-item.scss b/src/renderer/scss-partials/_ft-list-item.scss
index 6537e99f14d00..607554af451b4 100644
--- a/src/renderer/scss-partials/_ft-list-item.scss
+++ b/src/renderer/scss-partials/_ft-list-item.scss
@@ -78,7 +78,7 @@ $watched-transition-duration: 0.5s;
.videoWatched,
.videoDuration,
.externalPlayerIcon,
- .favoritesIcon,
+ .playlistIcons,
.watchedProgressBar,
.videoCountContainer,
.background,
@@ -148,13 +148,22 @@ $watched-transition-duration: 0.5s;
margin-inline-start: 4px;
}
- .favoritesIcon {
- font-size: 17px;
+ .playlistIcons {
justify-self: end;
margin-inline-end: 3px;
margin-block-start: 3px;
+
+ display: grid;
+ grid-auto-flow: column;
+ justify-content: flex-end;
block-size: fit-content;
}
+ .addToPlaylistIcon,
+ .trashIcon,
+ .upArrowIcon,
+ .downArrowIcon {
+ font-size: 17px;
+ }
.watchedProgressBar {
align-self: flex-end;
@@ -321,9 +330,15 @@ $watched-transition-duration: 0.5s;
.videoThumbnail,
.channelThumbnail {
margin-block-end: 12px;
+
+ .thumbnailImage {
+ // Ensure placeholder image displayed at same aspect ratio as most other images
+ aspect-ratio: 16/9;
+ }
}
- .thumbnailImage, .channelThumbnail {
+ .thumbnailImage,
+ .channelThumbnail {
inline-size: 100%;
}
@@ -337,31 +352,27 @@ $watched-transition-duration: 0.5s;
}
}
- .favoritesIcon,
+ .playlistIcons,
.externalPlayerIcon {
opacity: $thumbnail-overlay-opacity;
}
@media (hover: hover) {
- .favoritesIcon.favorited,
- &:hover .favoritesIcon,
+ &:hover .addToPlaylistIcon:not(.alwaysVisible),
&:hover .externalPlayerIcon,
- &:focus-within .favoritesIcon,
+ &:focus-within .addToPlaylistIcon:not(.alwaysVisible),
&:focus-within .externalPlayerIcon {
- visibility: visible;
opacity: $thumbnail-overlay-opacity;
}
&:hover .optionsButton,
&:focus-within .optionsButton {
- visibility: visible;
opacity: 1;
}
- .favoritesIcon,
+ .addToPlaylistIcon:not(.alwaysVisible),
.externalPlayerIcon,
.optionsButton {
- visibility: none;
opacity: 0;
transition: visibility 0s, opacity 0.2s linear;
}
diff --git a/src/renderer/store/modules/history.js b/src/renderer/store/modules/history.js
index bd073bf0c214c..1a31f31d0c4d3 100644
--- a/src/renderer/store/modules/history.js
+++ b/src/renderer/store/modules/history.js
@@ -73,14 +73,14 @@ const actions = {
}
},
- async updateLastViewedPlaylist({ commit }, { videoId, lastViewedPlaylistId }) {
+ async updateLastViewedPlaylist({ commit }, { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId }) {
try {
- await DBHistoryHandlers.updateLastViewedPlaylist(videoId, lastViewedPlaylistId)
- commit('updateRecordLastViewedPlaylistIdInHistoryCache', { videoId, lastViewedPlaylistId })
+ await DBHistoryHandlers.updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId)
+ commit('updateRecordLastViewedPlaylistIdInHistoryCache', { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId })
} catch (errMessage) {
console.error(errMessage)
}
- }
+ },
}
const mutations = {
@@ -118,13 +118,15 @@ const mutations = {
vueSet(state.historyCacheById, videoId, targetRecord)
},
- updateRecordLastViewedPlaylistIdInHistoryCache(state, { videoId, lastViewedPlaylistId }) {
+ updateRecordLastViewedPlaylistIdInHistoryCache(state, { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId }) {
const i = state.historyCacheSorted.findIndex((currentRecord) => {
return currentRecord.videoId === videoId
})
const targetRecord = Object.assign({}, state.historyCacheSorted[i])
targetRecord.lastViewedPlaylistId = lastViewedPlaylistId
+ targetRecord.lastViewedPlaylistType = lastViewedPlaylistType
+ targetRecord.lastViewedPlaylistItemId = lastViewedPlaylistItemId
state.historyCacheSorted.splice(i, 1, targetRecord)
vueSet(state.historyCacheById, videoId, targetRecord)
},
diff --git a/src/renderer/store/modules/playlists.js b/src/renderer/store/modules/playlists.js
index 8bd9de51665a0..0f55dcfbace12 100644
--- a/src/renderer/store/modules/playlists.js
+++ b/src/renderer/store/modules/playlists.js
@@ -1,32 +1,81 @@
import { DBPlaylistHandlers } from '../../../datastores/handlers/index'
+function generateRandomPlaylistId() {
+ return `ft-playlist--${generateRandomUniqueId()}`
+}
+
+function generateRandomPlaylistName() {
+ return `Playlist ${new Date().toISOString()}-${Math.floor(Math.random() * 10000)}`
+}
+
+function generateRandomUniqueId() {
+ // To avoid importing `crypto` from NodeJS
+ return crypto.randomUUID ? crypto.randomUUID() : `id-${Date.now()}-${Math.floor(Math.random() * 10000)}`
+}
+
const state = {
- playlists: [
+ // Playlist loading takes time on app load (new windows)
+ // This is necessary to let components to know when to start data loading
+ // which depends on playlist data being ready
+ playlistsReady: false,
+ playlists: [],
+ defaultPlaylists: [
{
playlistName: 'Favorites',
- protected: true,
- videos: []
+ protected: false,
+ description: 'Your favorite videos',
+ videos: [],
+ _id: 'favorites',
},
{
- playlistName: 'WatchLater',
- protected: true,
- removeOnWatched: true,
- videos: []
- }
- ]
+ playlistName: 'Watch Later',
+ protected: false,
+ description: 'Videos to watch later',
+ videos: [],
+ _id: 'watchLater',
+ },
+ ],
}
const getters = {
+ getPlaylistsReady: () => state.playlistsReady,
getAllPlaylists: () => state.playlists,
- getFavorites: () => state.playlists[0],
- getPlaylist: (playlistId) => state.playlists.find(playlist => playlist._id === playlistId),
- getWatchLater: () => state.playlists[1]
+ getPlaylist: (state) => (playlistId) => {
+ return state.playlists.find(playlist => playlist._id === playlistId)
+ },
}
const actions = {
async addPlaylist({ commit }, payload) {
+ // In case internal id is forgotten, generate one (instead of relying on caller and have a chance to cause data corruption)
+ if (payload._id == null) {
+ // {Time now in unix time}-{0-9999}
+ payload._id = generateRandomPlaylistId()
+ }
+ // Ensure playlist name trimmed
+ if (typeof payload.playlistName === 'string') {
+ payload.playlistName = payload.playlistName.trim()
+ }
+ // Ensure playlist description trimmed
+ if (typeof payload.description === 'string') {
+ payload.description = payload.description.trim()
+ }
+ payload.createdAt = Date.now()
+ payload.lastUpdatedAt = Date.now()
+ // Ensure all videos has required attributes
+ if (Array.isArray(payload.videos)) {
+ payload.videos.forEach(videoData => {
+ if (videoData.timeAdded == null) {
+ videoData.timeAdded = new Date().getTime()
+ }
+ if (videoData.playlistItemId == null) {
+ videoData.playlistItemId = generateRandomUniqueId()
+ }
+ })
+ }
+
try {
- await DBPlaylistHandlers.create(payload)
+ await DBPlaylistHandlers.create([payload])
commit('addPlaylist', payload)
} catch (errMessage) {
console.error(errMessage)
@@ -42,10 +91,53 @@ const actions = {
}
},
+ async updatePlaylist({ commit }, playlist) {
+ // Ensure playlist name trimmed
+ if (typeof playlist.playlistName === 'string') {
+ playlist.playlistName = playlist.playlistName.trim()
+ }
+ // Ensure playlist description trimmed
+ if (typeof playlist.description === 'string') {
+ playlist.description = playlist.description.trim()
+ }
+ // Caller no need to assign last updated time
+ playlist.lastUpdatedAt = Date.now()
+
+ try {
+ await DBPlaylistHandlers.upsert(playlist)
+ commit('upsertPlaylistToList', playlist)
+ } catch (errMessage) {
+ console.error(errMessage)
+ }
+ },
+
+ async updatePlaylistLastPlayedAt({ commit }, playlist) {
+ // This action does NOT update `lastUpdatedAt` on purpose
+ // Only `lastPlayedAt` should be updated
+ playlist.lastPlayedAt = Date.now()
+
+ try {
+ await DBPlaylistHandlers.upsert(playlist)
+ commit('upsertPlaylistToList', playlist)
+ } catch (errMessage) {
+ console.error(errMessage)
+ }
+ },
+
async addVideo({ commit }, payload) {
try {
- const { playlistName, videoData } = payload
- await DBPlaylistHandlers.upsertVideoByPlaylistName(playlistName, videoData)
+ const { _id, videoData } = payload
+ if (videoData.timeAdded == null) {
+ videoData.timeAdded = new Date().getTime()
+ }
+ if (videoData.playlistItemId == null) {
+ videoData.playlistItemId = generateRandomUniqueId()
+ }
+ // For backward compatibility
+ if (videoData.type == null) {
+ videoData.type = 'video'
+ }
+ await DBPlaylistHandlers.upsertVideoByPlaylistId(_id, videoData)
commit('addVideo', payload)
} catch (errMessage) {
console.error(errMessage)
@@ -53,10 +145,35 @@ const actions = {
},
async addVideos({ commit }, payload) {
+ // Assumes videos are added NOT from export
+ // Since this action will ensure uniqueness of `playlistItemId` of added video entries
try {
- const { playlistId, videoIds } = payload
- await DBPlaylistHandlers.upsertVideoIdsByPlaylistId(playlistId, videoIds)
- commit('addVideos', payload)
+ const { _id, videos } = payload
+ const newVideoObjects = videos.map((video) => {
+ // Create a new object to prevent changing existing values outside
+ const videoData = Object.assign({}, video)
+ if (videoData.timeAdded == null) {
+ videoData.timeAdded = new Date().getTime()
+ }
+ videoData.playlistItemId = generateRandomUniqueId()
+ // For backward compatibility
+ if (videoData.type == null) {
+ videoData.type = 'video'
+ }
+ // Undesired attributes, even with `null` values
+ [
+ 'description',
+ 'viewCount',
+ ].forEach(attrName => {
+ if (typeof videoData[attrName] !== 'undefined') {
+ delete videoData[attrName]
+ }
+ })
+
+ return videoData
+ })
+ await DBPlaylistHandlers.upsertVideosByPlaylistId(_id, newVideoObjects)
+ commit('addVideos', { _id, videos: newVideoObjects })
} catch (errMessage) {
console.error(errMessage)
}
@@ -64,13 +181,125 @@ const actions = {
async grabAllPlaylists({ commit, dispatch, state }) {
try {
- const payload = await DBPlaylistHandlers.find()
+ const payload = (await DBPlaylistHandlers.find()).filter((e) => e != null)
if (payload.length === 0) {
- commit('setAllPlaylists', state.playlists)
- dispatch('addPlaylists', payload)
+ // Not using `addPlaylists` to ensure required attributes with dynamic values added
+ state.defaultPlaylists.forEach(playlist => {
+ dispatch('addPlaylist', playlist)
+ })
} else {
+ payload.forEach((playlist) => {
+ let anythingUpdated = false
+ // Assign generated playlist ID in case DB data corrupted
+ if (playlist._id == null) {
+ // {Time now in unix time}-{0-9999}
+ playlist._id = generateRandomPlaylistId()
+ anythingUpdated = true
+ }
+ // Ensure all videos has `playlistName` property
+ if (playlist.playlistName == null) {
+ // Time now in unix time, in ms
+ playlist.playlistName = generateRandomPlaylistName()
+ anythingUpdated = true
+ }
+ // Assign current time as created time in case DB data corrupted
+ if (playlist.createdAt == null) {
+ // Time now in unix time, in ms
+ playlist.createdAt = Date.now()
+ anythingUpdated = true
+ }
+ // Assign current time as last updated time in case DB data corrupted
+ if (playlist.lastUpdatedAt == null) {
+ // Time now in unix time, in ms
+ playlist.lastUpdatedAt = Date.now()
+ anythingUpdated = true
+ }
+ playlist.videos.forEach((v) => {
+ // Ensure all videos has `timeAdded` property
+ if (v.timeAdded == null) {
+ v.timeAdded = new Date().getTime()
+ anythingUpdated = true
+ }
+
+ // Ensure all videos has `playlistItemId` property
+ if (v.playlistItemId == null) {
+ v.playlistItemId = generateRandomUniqueId()
+ anythingUpdated = true
+ }
+
+ // For backward compatibility
+ if (v.type == null) {
+ v.type = 'video'
+ anythingUpdated = true
+ }
+
+ // Undesired attributes, even with `null` values
+ [
+ 'description',
+ 'viewCount',
+ ].forEach(attrName => {
+ if (typeof v[attrName] !== 'undefined') {
+ delete v[attrName]
+ anythingUpdated = true
+ }
+ })
+ })
+ // Save updated playlist object
+ if (anythingUpdated) {
+ DBPlaylistHandlers.upsert(playlist)
+ }
+ })
+
+ const favoritesPlaylist = payload.find((playlist) => {
+ return playlist.playlistName === 'Favorites' || playlist._id === 'favorites'
+ })
+ const watchLaterPlaylist = payload.find((playlist) => {
+ return playlist.playlistName === 'Watch Later' || playlist._id === 'watchLater'
+ })
+
+ const defaultFavoritesPlaylist = state.defaultPlaylists.find((e) => e._id === 'favorites')
+ if (favoritesPlaylist != null) {
+ // Update existing matching playlist only if it exists
+ if (favoritesPlaylist._id !== defaultFavoritesPlaylist._id || favoritesPlaylist.protected !== defaultFavoritesPlaylist.protected) {
+ const oldId = favoritesPlaylist._id
+ favoritesPlaylist._id = defaultFavoritesPlaylist._id
+ favoritesPlaylist.protected = defaultFavoritesPlaylist.protected
+ if (oldId === defaultFavoritesPlaylist._id) {
+ // Update playlist if ID already the same
+ DBPlaylistHandlers.upsert(favoritesPlaylist)
+ } else {
+ dispatch('removePlaylist', oldId)
+ // DO NOT use dispatch('addPlaylist', ...)
+ // Which causes duplicate displayed playlist in window (But DB is fine)
+ // Due to the object is already in `payload`
+ DBPlaylistHandlers.create(favoritesPlaylist)
+ }
+ }
+ }
+
+ const defaultWatchLaterPlaylist = state.defaultPlaylists.find((e) => e._id === 'watchLater')
+ if (watchLaterPlaylist != null) {
+ // Update existing matching playlist only if it exists
+ if (watchLaterPlaylist._id !== defaultWatchLaterPlaylist._id || watchLaterPlaylist.protected !== defaultWatchLaterPlaylist.protected) {
+ const oldId = watchLaterPlaylist._id
+ watchLaterPlaylist._id = defaultWatchLaterPlaylist._id
+ watchLaterPlaylist.protected = defaultWatchLaterPlaylist.protected
+ if (oldId === defaultWatchLaterPlaylist._id) {
+ // Update playlist if ID already the same
+ DBPlaylistHandlers.upsert(watchLaterPlaylist)
+ } else {
+ dispatch('removePlaylist', oldId)
+ // DO NOT use dispatch('addPlaylist', ...)
+ // Which causes duplicate displayed playlist in window (But DB is fine)
+ // Due to the object is already in `payload`
+ DBPlaylistHandlers.create(watchLaterPlaylist)
+ }
+ }
+ }
+
commit('setAllPlaylists', payload)
}
+ commit('setPlaylistsReady', true)
} catch (errMessage) {
console.error(errMessage)
}
@@ -85,10 +314,10 @@ const actions = {
}
},
- async removeAllVideos({ commit }, playlistName) {
+ async removeAllVideos({ commit }, _id) {
try {
- await DBPlaylistHandlers.deleteAllVideosByPlaylistName(playlistName)
- commit('removeAllVideos', playlistName)
+ await DBPlaylistHandlers.deleteAllVideosByPlaylistId(_id)
+ commit('removeAllVideos', _id)
} catch (errMessage) {
console.error(errMessage)
}
@@ -114,8 +343,8 @@ const actions = {
async removeVideo({ commit }, payload) {
try {
- const { playlistName, videoId } = payload
- await DBPlaylistHandlers.deleteVideoIdByPlaylistName(playlistName, videoId)
+ const { _id, playlistItemId } = payload
+ await DBPlaylistHandlers.deleteVideoIdByPlaylistId(_id, playlistItemId)
commit('removeVideo', payload)
} catch (errMessage) {
console.error(errMessage)
@@ -124,8 +353,8 @@ const actions = {
async removeVideos({ commit }, payload) {
try {
- const { playlistName, videoIds } = payload
- await DBPlaylistHandlers.deleteVideoIdsByPlaylistName(playlistName, videoIds)
+ const { _id, videoIds } = payload
+ await DBPlaylistHandlers.deleteVideoIdsByPlaylistId(_id, videoIds)
commit('removeVideos', payload)
} catch (errMessage) {
console.error(errMessage)
@@ -142,41 +371,54 @@ const mutations = {
state.playlists = state.playlists.concat(payload)
},
+ upsertPlaylistToList(state, updatedPlaylist) {
+ const i = state.playlists.findIndex((p) => {
+ return p._id === updatedPlaylist._id
+ })
+
+ if (i === -1) {
+ state.playlists.push(updatedPlaylist)
+ } else {
+ const foundPlaylist = state.playlists[i]
+ state.playlists.splice(i, 1, Object.assign(foundPlaylist, updatedPlaylist))
+ }
+ },
+
addVideo(state, payload) {
- const playlist = state.playlists.find(playlist => playlist.playlistName === payload.playlistName)
+ const playlist = state.playlists.find(playlist => playlist._id === payload._id)
if (playlist) {
playlist.videos.push(payload.videoData)
}
},
addVideos(state, payload) {
- const playlist = state.playlists.find(playlist => playlist._id === payload.playlistId)
+ const playlist = state.playlists.find(playlist => playlist._id === payload._id)
if (playlist) {
- playlist.videos = playlist.videos.concat(payload.playlistIds)
+ playlist.videos = [].concat(playlist.videos).concat(payload.videos)
}
},
removeAllPlaylists(state) {
- state.playlists = state.playlists.filter(playlist => playlist.protected !== true)
+ state.playlists = []
},
- removeAllVideos(state, playlistName) {
- const playlist = state.playlists.find(playlist => playlist.playlistName === playlistName)
+ removeAllVideos(state, playlistId) {
+ const playlist = state.playlists.find(playlist => playlist._id === playlistId)
if (playlist) {
playlist.videos = []
}
},
removeVideo(state, payload) {
- const playlist = state.playlists.findIndex(playlist => playlist.playlistName === payload.playlistName)
- if (playlist !== -1) {
- state.playlists[playlist].videos = state.playlists[playlist].videos.filter(video => video.videoId !== payload.videoId)
+ const playlist = state.playlists.find(playlist => playlist._id === payload._id)
+ if (playlist) {
+ playlist.videos = playlist.videos.filter(video => video.playlistItemId !== payload.playlistItemId)
}
},
removeVideos(state, payload) {
- const playlist = state.playlists.findIndex(playlist => playlist._id === payload.playlistId)
- if (playlist !== -1) {
+ const playlist = state.playlists.find(playlist => playlist._id === payload.playlistId)
+ if (playlist) {
playlist.videos = playlist.videos.filter(video => payload.videoId.indexOf(video) === -1)
}
},
@@ -187,7 +429,11 @@ const mutations = {
setAllPlaylists(state, payload) {
state.playlists = payload
- }
+ },
+
+ setPlaylistsReady(state, payload) {
+ state.playlistsReady = payload
+ },
}
export default {
diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js
index 061a97d378c58..4bbf28e0a1359 100644
--- a/src/renderer/store/modules/settings.js
+++ b/src/renderer/store/modules/settings.js
@@ -504,10 +504,26 @@ const customActions = {
ipcRenderer.on(IpcChannels.SYNC_PLAYLISTS, (_, { event, data }) => {
switch (event) {
+ case SyncEvents.GENERAL.CREATE:
+ commit('addPlaylists', data)
+ break
+
+ case SyncEvents.GENERAL.DELETE:
+ commit('removePlaylist', data)
+ break
+
+ case SyncEvents.GENERAL.UPSERT:
+ commit('upsertPlaylistToList', data)
+ break
+
case SyncEvents.PLAYLISTS.UPSERT_VIDEO:
commit('addVideo', data)
break
+ case SyncEvents.PLAYLISTS.UPSERT_VIDEOS:
+ commit('addVideos', data)
+ break
+
case SyncEvents.PLAYLISTS.DELETE_VIDEO:
commit('removeVideo', data)
break
diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js
index 4ec7a5df6bbad..d80f59c11c81a 100644
--- a/src/renderer/store/modules/utils.js
+++ b/src/renderer/store/modules/utils.js
@@ -31,7 +31,12 @@ const state = {
cachedPlaylist: null,
deArrowCache: {},
showProgressBar: false,
+ showAddToPlaylistPrompt: false,
+ showCreatePlaylistPrompt: false,
progressBarPercentage: 0,
+ toBeAddedToPlaylistVideoList: [],
+ newPlaylistDefaultProperties: {},
+ newPlaylistVideoObject: [],
regionNames: [],
regionValues: [],
recentBlogPosts: [],
@@ -84,6 +89,26 @@ const getters = {
return state.searchSettings
},
+ getShowAddToPlaylistPrompt () {
+ return state.showAddToPlaylistPrompt
+ },
+
+ getShowCreatePlaylistPrompt () {
+ return state.showCreatePlaylistPrompt
+ },
+
+ getToBeAddedToPlaylistVideoList () {
+ return state.toBeAddedToPlaylistVideoList
+ },
+
+ getNewPlaylistDefaultProperties () {
+ return state.newPlaylistDefaultProperties
+ },
+
+ getNewPlaylistVideoObject () {
+ return state.newPlaylistVideoObject
+ },
+
getShowProgressBar () {
return state.showProgressBar
},
@@ -256,6 +281,78 @@ const actions = {
})
},
+ showAddToPlaylistPromptForManyVideos ({ commit }, { videos: videoObjectArray, newPlaylistDefaultProperties }) {
+ let videoDataValid = true
+ if (!Array.isArray(videoObjectArray)) {
+ videoDataValid = false
+ }
+ let missingKeys = []
+
+ if (videoDataValid) {
+ const requiredVideoKeys = [
+ 'videoId',
+ 'title',
+ 'author',
+ 'authorId',
+ 'lengthSeconds',
+
+ // `timeAdded` should be generated when videos are added
+ // Not when a prompt is displayed
+ // 'timeAdded',
+
+ // `playlistItemId` should be generated anyway
+ // 'playlistItemId',
+
+ // `type` should be added in action anyway
+ // 'type',
+ ]
+ // Using `every` to loop and `return false` to break
+ videoObjectArray.every((video) => {
+ const videoPropertyKeys = Object.keys(video)
+ const missingKeysHere = requiredVideoKeys.filter(x => !videoPropertyKeys.includes(x))
+ if (missingKeysHere.length > 0) {
+ videoDataValid = false
+ missingKeys = missingKeysHere
+ return false
+ }
+ // Return true to continue loop
+ return true
+ })
+ }
+
+ if (!videoDataValid) {
+ // Print error and abort
+ const errorMsgText = 'Incorrect videos data passed when opening playlist prompt'
+ console.error(errorMsgText)
+ console.error({
+ videoObjectArray,
+ missingKeys,
+ })
+ throw new Error(errorMsgText)
+ }
+
+ commit('setShowAddToPlaylistPrompt', true)
+ commit('setToBeAddedToPlaylistVideoList', videoObjectArray)
+ if (newPlaylistDefaultProperties != null) {
+ commit('setNewPlaylistDefaultProperties', newPlaylistDefaultProperties)
+ }
+ },
+
+ hideAddToPlaylistPrompt ({ commit }) {
+ commit('setShowAddToPlaylistPrompt', false)
+ // The default value properties are only valid until prompt is closed
+ commit('resetNewPlaylistDefaultProperties')
+ },
+
+ showCreatePlaylistPrompt ({ commit }, data) {
+ commit('setShowCreatePlaylistPrompt', true)
+ commit('setNewPlaylistVideoObject', data)
+ },
+
+ hideCreatePlaylistPrompt ({ commit }) {
+ commit('setShowCreatePlaylistPrompt', false)
+ },
+
updateShowProgressBar ({ commit }, value) {
commit('setShowProgressBar', value)
},
@@ -695,6 +792,29 @@ const mutations = {
}
},
+ setShowAddToPlaylistPrompt (state, payload) {
+ state.showAddToPlaylistPrompt = payload
+ },
+
+ setShowCreatePlaylistPrompt (state, payload) {
+ state.showCreatePlaylistPrompt = payload
+ },
+
+ setToBeAddedToPlaylistVideoList (state, payload) {
+ state.toBeAddedToPlaylistVideoList = payload
+ },
+
+ setNewPlaylistDefaultProperties (state, payload) {
+ state.newPlaylistDefaultProperties = payload
+ },
+ resetNewPlaylistDefaultProperties (state) {
+ state.newPlaylistDefaultProperties = {}
+ },
+
+ setNewPlaylistVideoObject (state, payload) {
+ state.newPlaylistVideoObject = payload
+ },
+
setPopularCache (state, value) {
state.popularCache = value
},
diff --git a/src/renderer/views/Playlist/Playlist.js b/src/renderer/views/Playlist/Playlist.js
index 0c668d2b683c6..53f7e82611da5 100644
--- a/src/renderer/views/Playlist/Playlist.js
+++ b/src/renderer/views/Playlist/Playlist.js
@@ -1,5 +1,6 @@
import { defineComponent } from 'vue'
import { mapActions, mapMutations } from 'vuex'
+import debounce from 'lodash.debounce'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import PlaylistInfo from '../../components/playlist-info/playlist-info.vue'
@@ -11,7 +12,7 @@ import {
getLocalPlaylistContinuation,
parseLocalPlaylistVideo,
} from '../../helpers/api/local'
-import { extractNumberFromString } from '../../helpers/utils'
+import { extractNumberFromString, showToast } from '../../helpers/utils'
import { invidiousGetPlaylistInfo, youtubeImageUrlToInvidious } from '../../helpers/api/invidious'
export default defineComponent({
@@ -28,23 +29,37 @@ export default defineComponent({
if (!this.isLoading && to.path.startsWith('/watch') && to.query.playlistId === this.playlistId) {
this.setCachedPlaylist({
id: this.playlistId,
- title: this.infoData.title,
- channelName: this.infoData.channelName,
- channelId: this.infoData.channelId,
+ title: this.playlistTitle,
+ channelName: this.channelName,
+ channelId: this.channelId,
items: this.playlistItems,
- continuationData: this.continuationData
+ continuationData: this.continuationData,
})
}
next()
},
data: function () {
return {
- isLoading: false,
- playlistId: null,
- infoData: {},
+ isLoading: true,
+ playlistTitle: '',
+ playlistDescription: '',
+ firstVideoId: '',
+ firstVideoPlaylistItemId: '',
+ playlistThumbnail: '',
+ viewCount: 0,
+ videoCount: 0,
+ lastUpdated: undefined,
+ channelName: '',
+ channelThumbnail: '',
+ channelId: '',
+ infoSource: 'local',
playlistItems: [],
continuationData: null,
- isLoadingMore: false
+ isLoadingMore: false,
+ getPlaylistInfoDebounce: function() {},
+ playlistInEditMode: false,
+
+ promptOpen: false,
}
},
computed: {
@@ -59,20 +74,88 @@ export default defineComponent({
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
- }
+ },
+ playlistId: function() {
+ return this.$route.params.id
+ },
+ userPlaylistsReady: function () {
+ return this.$store.getters.getPlaylistsReady
+ },
+ selectedUserPlaylist: function () {
+ if (!this.isUserPlaylistRequested) { return null }
+ if (this.playlistId == null || this.playlistId === '') { return null }
+
+ return this.$store.getters.getPlaylist(this.playlistId)
+ },
+ selectedUserPlaylistLastUpdatedAt: function () {
+ return this.selectedUserPlaylist?.lastUpdatedAt
+ },
+ selectedUserPlaylistVideos: function () {
+ if (this.selectedUserPlaylist != null) {
+ return this.selectedUserPlaylist.videos
+ } else {
+ return []
+ }
+ },
+ selectedUserPlaylistVideoCount: function() {
+ return this.selectedUserPlaylistVideos.length
+ },
+
+ moreVideoDataAvailable() {
+ return this.continuationData !== null
+ },
+
+ isUserPlaylistRequested: function () {
+ return this.$route.query.playlistType === 'user'
+ },
},
watch: {
$route () {
// react to route changes...
- this.getPlaylist()
- }
+ this.getPlaylistInfoDebounce()
+ },
+ userPlaylistsReady () {
+ // Fetch from local store when playlist data ready
+ if (!this.isUserPlaylistRequested) { return }
+
+ this.getPlaylistInfoDebounce()
+ },
+ selectedUserPlaylist () {
+ // Fetch from local store when current user playlist changed
+ this.getPlaylistInfoDebounce()
+ },
+ selectedUserPlaylistLastUpdatedAt () {
+ // Re-fetch from local store when current user playlist updated
+ this.getPlaylistInfoDebounce()
+ },
+ selectedUserPlaylistVideoCount () {
+ // Monitoring `selectedUserPlaylistVideos` makes this function called
+ // Even when the same array object is returned
+ // So length is monitored instead
+ // Assuming in user playlist video cannot be swapped without length change
+
+ // Re-fetch from local store when current user playlist videos updated
+ this.getPlaylistInfoDebounce()
+ },
},
mounted: function () {
- this.getPlaylist()
+ this.getPlaylistInfoDebounce = debounce(this.getPlaylistInfo, 100)
+ this.getPlaylistInfoDebounce()
},
methods: {
- getPlaylist: function () {
- this.playlistId = this.$route.params.id
+ getPlaylistInfo: function () {
+ this.isLoading = true
+ // `selectedUserPlaylist` result accuracy relies on data being ready
+ if (this.isUserPlaylistRequested && !this.userPlaylistsReady) { return }
+
+ if (this.isUserPlaylistRequested) {
+ if (this.selectedUserPlaylist != null) {
+ this.parseUserPlaylist(this.selectedUserPlaylist)
+ } else {
+ this.showUserPlaylistNotFound()
+ }
+ return
+ }
switch (this.backendPreference) {
case 'local':
@@ -84,8 +167,6 @@ export default defineComponent({
}
},
getPlaylistLocal: function () {
- this.isLoading = true
-
getLocalPlaylist(this.playlistId).then((result) => {
let channelName
@@ -98,25 +179,22 @@ export default defineComponent({
channelName = subtitle.substring(0, index).trim()
}
- this.infoData = {
- id: this.playlistId,
- title: result.info.title,
- description: result.info.description ?? '',
- firstVideoId: result.items[0].id,
- playlistThumbnail: result.info.thumbnails[0].url,
- viewCount: extractNumberFromString(result.info.views),
- videoCount: extractNumberFromString(result.info.total_items),
- lastUpdated: result.info.last_updated ?? '',
- channelName,
- channelThumbnail: result.info.author?.best_thumbnail?.url ?? '',
- channelId: result.info.author?.id,
- infoSource: 'local'
- }
+ this.playlistTitle = result.info.title
+ this.playlistDescription = result.info.description ?? ''
+ this.firstVideoId = result.items[0].id
+ this.playlistThumbnail = result.info.thumbnails[0].url
+ this.viewCount = extractNumberFromString(result.info.views)
+ this.videoCount = extractNumberFromString(result.info.total_items)
+ this.lastUpdated = result.info.last_updated ?? ''
+ this.channelName = channelName ?? ''
+ this.channelThumbnail = result.info.author?.best_thumbnail?.url ?? ''
+ this.channelId = result.info.author?.id
+ this.infoSource = 'local'
this.updateSubscriptionDetails({
- channelThumbnailUrl: this.infoData.channelThumbnail,
- channelName: this.infoData.channelName,
- channelId: this.infoData.channelId
+ channelThumbnailUrl: this.channelThumbnail,
+ channelName: this.channelName,
+ channelId: this.channelId
})
this.playlistItems = result.items.map(parseLocalPlaylistVideo)
@@ -138,30 +216,25 @@ export default defineComponent({
},
getPlaylistInvidious: function () {
- this.isLoading = true
-
invidiousGetPlaylistInfo(this.playlistId).then((result) => {
- this.infoData = {
- id: result.playlistId,
- title: result.title,
- description: result.description,
- firstVideoId: result.videos[0].videoId,
- viewCount: result.viewCount,
- videoCount: result.videoCount,
- channelName: result.author,
- channelThumbnail: youtubeImageUrlToInvidious(result.authorThumbnails[2].url, this.currentInvidiousInstance),
- channelId: result.authorId,
- infoSource: 'invidious'
- }
+ this.playlistTitle = result.title
+ this.playlistDescription = result.description
+ this.firstVideoId = result.videos[0].videoId
+ this.viewCount = result.viewCount
+ this.videoCount = result.videoCount
+ this.channelName = result.author
+ this.channelThumbnail = youtubeImageUrlToInvidious(result.authorThumbnails[2].url, this.currentInvidiousInstance)
+ this.channelId = result.authorId
+ this.infoSource = 'invidious'
this.updateSubscriptionDetails({
channelThumbnailUrl: result.authorThumbnails[2].url,
- channelName: this.infoData.channelName,
- channelId: this.infoData.channelId
+ channelName: this.channelName,
+ channelId: this.channelId
})
const dateString = new Date(result.updated * 1000)
- this.infoData.lastUpdated = dateString.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' })
+ this.lastUpdated = dateString.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' })
this.playlistItems = this.playlistItems.concat(result.videos)
@@ -178,8 +251,36 @@ export default defineComponent({
})
},
+ parseUserPlaylist: function (playlist) {
+ this.playlistTitle = playlist.playlistName
+ this.playlistDescription = playlist.description ?? ''
+
+ if (playlist.videos.length > 0) {
+ this.firstVideoId = playlist.videos[0].videoId
+ this.firstVideoPlaylistItemId = playlist.videos[0].playlistItemId
+ } else {
+ this.firstVideoId = ''
+ this.firstVideoPlaylistItemId = ''
+ }
+ this.viewCount = 0
+ this.videoCount = playlist.videos.length
+ const dateString = new Date(playlist.lastUpdatedAt)
+ this.lastUpdated = dateString.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' })
+ this.channelName = ''
+ this.channelThumbnail = ''
+ this.channelId = ''
+ this.infoSource = 'user'
+
+ this.playlistItems = playlist.videos
+
+ this.isLoading = false
+ },
+ showUserPlaylistNotFound() {
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast.This playlist does not exist'))
+ },
+
getNextPage: function () {
- switch (this.infoData.infoSource) {
+ switch (this.infoSource) {
case 'local':
this.getNextPageLocal()
break
@@ -210,8 +311,90 @@ export default defineComponent({
})
},
+ moveVideoUp: function (videoId, playlistItemId) {
+ const playlistItems = [].concat(this.playlistItems)
+ const videoIndex = playlistItems.findIndex((video) => {
+ return video.videoId === videoId && video.playlistItemId === playlistItemId
+ })
+
+ if (videoIndex === 0) {
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast["This video cannot be moved up."]'))
+ return
+ }
+
+ const videoObject = playlistItems[videoIndex]
+
+ playlistItems.splice(videoIndex, 1)
+ playlistItems.splice(videoIndex - 1, 0, videoObject)
+
+ const playlist = {
+ playlistName: this.playlistTitle,
+ protected: this.selectedUserPlaylist.protected,
+ description: this.playlistDescription,
+ videos: playlistItems,
+ _id: this.playlistId
+ }
+ try {
+ this.updatePlaylist(playlist)
+ this.playlistItems = playlistItems
+ } catch (e) {
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast["There was an issue with updating this playlist."]'))
+ console.error(e)
+ }
+ },
+
+ moveVideoDown: function (videoId, playlistItemId) {
+ const playlistItems = [].concat(this.playlistItems)
+ const videoIndex = playlistItems.findIndex((video) => {
+ return video.videoId === videoId && video.playlistItemId === playlistItemId
+ })
+
+ if (videoIndex + 1 === playlistItems.length || videoIndex + 1 > playlistItems.length) {
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast["This video cannot be moved down."]'))
+ return
+ }
+
+ const videoObject = playlistItems[videoIndex]
+
+ playlistItems.splice(videoIndex, 1)
+ playlistItems.splice(videoIndex + 1, 0, videoObject)
+
+ const playlist = {
+ playlistName: this.playlistTitle,
+ protected: this.selectedUserPlaylist.protected,
+ description: this.playlistDescription,
+ videos: playlistItems,
+ _id: this.playlistId
+ }
+ try {
+ this.updatePlaylist(playlist)
+ this.playlistItems = playlistItems
+ } catch (e) {
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast["There was an issue with updating this playlist."]'))
+ console.error(e)
+ }
+ },
+
+ removeVideoFromPlaylist: function (videoId, playlistItemId) {
+ try {
+ this.removeVideo({
+ _id: this.playlistId,
+ videoId: videoId,
+ playlistItemId: playlistItemId,
+ })
+ // Update playlist's `lastUpdatedAt`
+ this.updatePlaylist({ _id: this.playlistId })
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast.Video has been removed'))
+ } catch (e) {
+ showToast(this.$t('User Playlists.SinglePlaylistView.Toast.There was a problem with removing this video'))
+ console.error(e)
+ }
+ },
+
...mapActions([
- 'updateSubscriptionDetails'
+ 'updateSubscriptionDetails',
+ 'updatePlaylist',
+ 'removeVideo',
]),
...mapMutations([
diff --git a/src/renderer/views/Playlist/Playlist.css b/src/renderer/views/Playlist/Playlist.scss
similarity index 51%
rename from src/renderer/views/Playlist/Playlist.css
rename to src/renderer/views/Playlist/Playlist.scss
index 37b5a2ee4a87b..13fd09464488b 100644
--- a/src/renderer/views/Playlist/Playlist.css
+++ b/src/renderer/views/Playlist/Playlist.scss
@@ -11,7 +11,15 @@
padding: 10px;
position: sticky;
inset-block-start: 96px;
+ /* This is needed to make prompt always above video entries */
+ /* Value being too high would block search suggestions */
+ z-index: 1;
inline-size: 30%;
+
+ &.promptOpen {
+ // Otherwise sidebar would be above the prompt
+ z-index: 200;
+ }
}
.playlistItems {
@@ -30,6 +38,30 @@
align-items: center;
}
+.playlistItem-move ,
+.playlistItem-enter-active,
+.playlistItem-leave-active {
+ transition: all 0.2s ease;
+
+ // Hide action buttons during transitions
+ //
+ // The class for icon container is mainly styled in `_ft-list-item.scss`
+ // But the transition related classes are all on container elements
+ // So `:deep` is used
+ :deep(.ft-list-item .videoThumbnail .playlistIcons) {
+ display: none;
+ }
+ // Prevent link click
+ :deep(.ft-list-item .videoThumbnail .thumbnailLink) {
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events
+ pointer-events: none;
+ }
+}
+.playlistItem-enter, .playlistItem-leave-to {
+ opacity: 0;
+ transform: translate(calc(10% * var(--horizontal-directionality-coefficient)));
+}
+
.videoIndex {
color: var(--tertiary-text-color);
text-align: center;
@@ -54,6 +86,7 @@
box-sizing: border-box;
position: relative;
inset-block-start: 0;
+ z-index: 1;
block-size: auto;
inline-size: 100%;
}
@@ -63,3 +96,7 @@
inline-size: 100%;
}
}
+
+.message {
+ color: var(--tertiary-text-color);
+}
diff --git a/src/renderer/views/Playlist/Playlist.vue b/src/renderer/views/Playlist/Playlist.vue
index b7d60a8c774d7..7b4e995aea300 100644
--- a/src/renderer/views/Playlist/Playlist.vue
+++ b/src/renderer/views/Playlist/Playlist.vue
@@ -7,51 +7,97 @@
-
-
- {{ index + 1 }}
-
-
-
+
+
+ {{ index + 1 }}
+
+
+
+
+
+
+
+
+
+
+
-
+
+ {{ $t("User Playlists['This playlist currently has no videos.']") }}
+
-
-
-
-
+
diff --git a/src/renderer/views/UserPlaylists/UserPlaylists.css b/src/renderer/views/UserPlaylists/UserPlaylists.css
index 8885b1123ddfb..c7cea712b6be2 100644
--- a/src/renderer/views/UserPlaylists/UserPlaylists.css
+++ b/src/renderer/views/UserPlaylists/UserPlaylists.css
@@ -1,9 +1,26 @@
.card {
+ position: relative;
+
inline-size: 85%;
margin-block: 0 60px;
margin-inline: auto;
}
+.headingText {
+ display: inline-block;
+}
+
+.newPlaylistButton {
+ margin-inline-start: 0.5em;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.sortSelect {
+ /* Put it on the right */
+ margin-inline-start: auto;
+}
+
.message {
color: var(--tertiary-text-color);
}
diff --git a/src/renderer/views/UserPlaylists/UserPlaylists.js b/src/renderer/views/UserPlaylists/UserPlaylists.js
index b3477dbc13657..95e2d01c862cc 100644
--- a/src/renderer/views/UserPlaylists/UserPlaylists.js
+++ b/src/renderer/views/UserPlaylists/UserPlaylists.js
@@ -1,12 +1,29 @@
import { defineComponent } from 'vue'
+import { mapActions } from 'vuex'
import debounce from 'lodash.debounce'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtTooltip from '../../components/ft-tooltip/ft-tooltip.vue'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
+import FtSelect from '../../components/ft-select/ft-select.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtInput from '../../components/ft-input/ft-input.vue'
+import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
+
+const SORT_BY_VALUES = {
+ NameAscending: 'name_ascending',
+ NameDescending: 'name_descending',
+
+ LatestCreatedFirst: 'latest_created_first',
+ EarliestCreatedFirst: 'earliest_created_first',
+
+ LatestUpdatedFirst: 'latest_updated_first',
+ EarliestUpdatedFirst: 'earliest_updated_first',
+
+ LatestPlayedFirst: 'latest_played_first',
+ EarliestPlayedFirst: 'earliest_played_first',
+}
export default defineComponent({
name: 'UserPlaylists',
@@ -16,8 +33,10 @@ export default defineComponent({
'ft-tooltip': FtTooltip,
'ft-loader': FtLoader,
'ft-button': FtButton,
+ 'ft-select': FtSelect,
'ft-element-list': FtElementList,
- 'ft-input': FtInput
+ 'ft-icon-button': FtIconButton,
+ 'ft-input': FtInput,
},
data: function () {
return {
@@ -27,46 +46,147 @@ export default defineComponent({
showLoadMoreButton: false,
query: '',
activeData: [],
+ sortBy: SORT_BY_VALUES.LatestPlayedFirst,
}
},
computed: {
- favoritesPlaylist: function () {
- return this.$store.getters.getFavorites
+ locale: function () {
+ return this.$i18n.locale.replace('_', '-')
+ },
+
+ allPlaylists: function () {
+ const playlists = this.$store.getters.getAllPlaylists
+ return [].concat(playlists).sort((a, b) => {
+ switch (this.sortBy) {
+ case SORT_BY_VALUES.NameAscending:
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ case SORT_BY_VALUES.NameDescending:
+ return b.playlistName.localeCompare(a.playlistName, this.locale)
+ case SORT_BY_VALUES.LatestCreatedFirst: {
+ if (a.createdAt > b.createdAt) { return -1 }
+ if (a.createdAt < b.createdAt) { return 1 }
+
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ case SORT_BY_VALUES.EarliestCreatedFirst: {
+ if (a.createdAt < b.createdAt) { return -1 }
+ if (a.createdAt > b.createdAt) { return 1 }
+
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ case SORT_BY_VALUES.LatestUpdatedFirst: {
+ if (a.lastUpdatedAt > b.lastUpdatedAt) { return -1 }
+ if (a.lastUpdatedAt < b.lastUpdatedAt) { return 1 }
+
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ case SORT_BY_VALUES.EarliestUpdatedFirst: {
+ if (a.lastUpdatedAt < b.lastUpdatedAt) { return -1 }
+ if (a.lastUpdatedAt > b.lastUpdatedAt) { return 1 }
+
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ case SORT_BY_VALUES.LatestPlayedFirst: {
+ if (a.lastPlayedAt == null && b.lastPlayedAt == null) {
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ // Never played playlist = move to last
+ if (a.lastPlayedAt == null) { return 1 }
+ if (b.lastPlayedAt == null) { return -1 }
+ if (a.lastPlayedAt > b.lastPlayedAt) { return -1 }
+ if (a.lastPlayedAt < b.lastPlayedAt) { return 1 }
+
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ case SORT_BY_VALUES.EarliestPlayedFirst: {
+ // Never played playlist = first
+ if (a.lastPlayedAt == null && b.lastPlayedAt == null) {
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ // Never played playlist = move to first
+ if (a.lastPlayedAt == null) { return -1 }
+ if (b.lastPlayedAt == null) { return 1 }
+ if (a.lastPlayedAt < b.lastPlayedAt) { return -1 }
+ if (a.lastPlayedAt > b.lastPlayedAt) { return 1 }
+
+ return a.playlistName.localeCompare(b.playlistName, this.locale)
+ }
+ default:
+ console.error(`Unknown sortBy: ${this.sortBy}`)
+ return 0
+ }
+ })
},
fullData: function () {
- const data = [].concat(this.favoritesPlaylist.videos).reverse()
- if (this.favoritesPlaylist.videos.length < this.dataLimit) {
+ const data = this.allPlaylists
+ if (this.allPlaylists.length < this.dataLimit) {
return data
} else {
return data.slice(0, this.dataLimit)
}
- }
+ },
+
+ lowerCaseQuery: function() {
+ return this.query.toLowerCase()
+ },
+
+ sortBySelectNames() {
+ return Object.values(SORT_BY_VALUES).map((k) => {
+ switch (k) {
+ case SORT_BY_VALUES.NameAscending:
+ return this.$t('User Playlists.Sort By.NameAscending')
+ case SORT_BY_VALUES.NameDescending:
+ return this.$t('User Playlists.Sort By.NameDescending')
+ case SORT_BY_VALUES.LatestCreatedFirst:
+ return this.$t('User Playlists.Sort By.LatestCreatedFirst')
+ case SORT_BY_VALUES.EarliestCreatedFirst:
+ return this.$t('User Playlists.Sort By.EarliestCreatedFirst')
+ case SORT_BY_VALUES.LatestUpdatedFirst:
+ return this.$t('User Playlists.Sort By.LatestUpdatedFirst')
+ case SORT_BY_VALUES.EarliestUpdatedFirst:
+ return this.$t('User Playlists.Sort By.EarliestUpdatedFirst')
+ case SORT_BY_VALUES.LatestPlayedFirst:
+ return this.$t('User Playlists.Sort By.LatestPlayedFirst')
+ case SORT_BY_VALUES.EarliestPlayedFirst:
+ return this.$t('User Playlists.Sort By.EarliestPlayedFirst')
+ default:
+ console.error(`Unknown sortBy: ${k}`)
+ return k
+ }
+ })
+ },
+ sortBySelectValues() {
+ return Object.values(SORT_BY_VALUES)
+ },
},
watch: {
- query() {
+ lowerCaseQuery() {
this.searchDataLimit = 100
this.filterPlaylistAsync()
},
fullData() {
this.activeData = this.fullData
this.filterPlaylist()
- }
+ },
+ sortBy() {
+ sessionStorage.setItem('UserPlaylists/sortBy', this.sortBy)
+ },
},
mounted: function () {
const limit = sessionStorage.getItem('favoritesLimit')
-
if (limit !== null) {
this.dataLimit = limit
}
+ const sortBy = sessionStorage.getItem('UserPlaylists/sortBy')
+ if (sortBy != null) {
+ this.sortBy = sortBy
+ }
+
this.activeData = this.fullData
- if (this.activeData.length < this.favoritesPlaylist.videos.length) {
- this.showLoadMoreButton = true
- } else {
- this.showLoadMoreButton = false
- }
+ this.showLoadMoreButton = this.activeData.length < this.allPlaylists.length
this.filterPlaylistDebounce = debounce(this.filterPlaylist, 500)
},
@@ -86,31 +206,28 @@ export default defineComponent({
this.filterPlaylistDebounce()
},
filterPlaylist: function() {
- if (this.query === '') {
+ if (this.lowerCaseQuery === '') {
this.activeData = this.fullData
- if (this.activeData.length < this.favoritesPlaylist.videos.length) {
- this.showLoadMoreButton = true
- } else {
- this.showLoadMoreButton = false
- }
+ this.showLoadMoreButton = this.allPlaylists.length > this.activeData.length
} else {
- const lowerCaseQuery = this.query.toLowerCase()
- const filteredQuery = this.favoritesPlaylist.videos.filter((video) => {
- if (typeof (video.title) !== 'string' || typeof (video.author) !== 'string') {
- return false
- } else {
- return video.title.toLowerCase().includes(lowerCaseQuery) || video.author.toLowerCase().includes(lowerCaseQuery)
- }
- }).sort((a, b) => {
- return b.timeAdded - a.timeAdded
+ const filteredPlaylists = this.allPlaylists.filter((playlist) => {
+ if (typeof (playlist.playlistName) !== 'string') { return false }
+
+ return playlist.playlistName.toLowerCase().includes(this.lowerCaseQuery)
})
- if (filteredQuery.length <= this.searchDataLimit) {
- this.showLoadMoreButton = false
- } else {
- this.showLoadMoreButton = true
- }
- this.activeData = filteredQuery.length < this.searchDataLimit ? filteredQuery : filteredQuery.slice(0, this.searchDataLimit)
+ this.showLoadMoreButton = filteredPlaylists.length > this.searchDataLimit
+ this.activeData = filteredPlaylists.length < this.searchDataLimit ? filteredPlaylists : filteredPlaylists.slice(0, this.searchDataLimit)
}
},
+
+ createNewPlaylist: function () {
+ this.showCreatePlaylistPrompt({
+ title: '',
+ })
+ },
+
+ ...mapActions([
+ 'showCreatePlaylistPrompt'
+ ])
}
})
diff --git a/src/renderer/views/UserPlaylists/UserPlaylists.vue b/src/renderer/views/UserPlaylists/UserPlaylists.vue
index 1fb061fbf6c7c..b4dedbb6c63c3 100644
--- a/src/renderer/views/UserPlaylists/UserPlaylists.vue
+++ b/src/renderer/views/UserPlaylists/UserPlaylists.vue
@@ -1,47 +1,61 @@