Skip to content

Commit

Permalink
feat(project): added support for playlists with UI navigation and aut…
Browse files Browse the repository at this point in the history
…o-discovery on the server

re #10
  • Loading branch information
will-moss committed Oct 25, 2024
1 parent 69c50db commit 09daea9
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 11 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ for a self-hostable app that can show filtered videos using TikTok's interface,
Erin has all these features implemented :
- Display your own videos using TikTok's swipe feed
- Mask the videos you don't want to see in your feed\*
- Choose which feed you want to play\*\*
- Choose which feed (playlist) you want to play\*\*
- Autoplay your feed without even swiping
- Seek forward and backward using your keyboard, or using double taps
- Enter / Exit fullscreen using a double tap in the center
- Show video metadata using TikTok's UI\*\*\*
- Simple lazy-loading mechanism for your videos
- Automatic clip naming based on file name
Expand All @@ -37,7 +38,7 @@ Caddy takes care of authentication, serving static files, and serving the React

> \*: You can mask videos to hide them from your feed. Should you want to see which videos were masked, and even unmask them, you can long-press the `Mask` button, and the manager will open.
> \*\*: By default, Erin will create a random feed from all the videos in your folder and its subdirectories. However, if you would like to create custom feeds, you can set your URL to any of the subdirectories path. For example: `https://my-server.tld/directory-a` will create a feed from the videos located in the `/directory-a` directory, and it works with any path (so, nested folders are supported).
> \*\*: By default, Erin will create a random feed from all the videos in your folder and its subdirectories. However, if you would like to create custom feeds (playlists), you can create subdirectories and organize your videos accordingly. For example: `https://my-server.tld/directory-a` will create a feed from the videos located in the `/directory-a` directory, and it works with any path (so, nested folders are supported).
> \*\*\*: You can show a channel (with an avatar and name), a caption and a link for all your videos using a metadata file. The metadata file can be located anywhere inside your videos folder, and it must match its associated video's filename, while replacing the extension with JSON. For example: `my-video.mp4` can have its metadata in `my-video.json`. The metadata format [is shown here](/examples/video-metadata.json), and note that you can use raw HTML in the caption for custom styling and effects.
Expand Down
41 changes: 33 additions & 8 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { usePrevious } from "@uidotdev/usehooks";
import "./App.css";
import BlacklistManager from "./components/BlacklistManager";
import BottomMetadata from "./components/BottomMetadata";
import PlaylistsViewer from "./components/PlaylistViewer";

const App = () => {
// Misc - General niceties
Expand Down Expand Up @@ -119,7 +120,7 @@ const App = () => {
setMuted(!muted);
};

// Member - Saves a { url , title } dictionary for every video discovered
// Member - Saves a { url , title , extension , filename , metadataURL } dictionary for every video discovered
const [videos, setVideos] = useState([]);
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const [currentVideoMetadata, setCurrentVideoMetadata] = useState(false);
Expand Down Expand Up @@ -152,6 +153,9 @@ const App = () => {
document.querySelector(".feed").scrollBy({ top: 1, left: 0, behavior: "smooth" });
};

// Member - Save playlists
const [playlists, setPlaylists] = useState([]);

// Member - Trick to trigger state updates on localStorage updates
const [blackListUpdater, setBlacklistUpdater] = useState(0);

Expand Down Expand Up @@ -230,6 +234,15 @@ const App = () => {
setBlacklistUpdater(blackListUpdater + 1);
};

// Member - Manage playlist viewer UI
const [playlistsViewerOpen, setPlaylistsViewerOpen] = useState(false);
const openPlaylistsViewer = () => {
setPlaylistsViewerOpen(true);
};
const hidePlaylistsViewer = () => {
setPlaylistsViewerOpen(false);
};

// Method - Test connectivity with the remote server
const attemptToReachRemoteServer = (evt) => {
if (evt) evt.preventDefault();
Expand Down Expand Up @@ -290,14 +303,11 @@ const App = () => {
.catch((_) => new Promise((res, rej) => res([])));
};

// Method - Retrieve all the video links
// Method - Retrieve all the video links (and generate the associated playlists based on folder structure)
const retrieveVideos = () => {
if (!hasCache) setLoading(true);

let startingPath = window.location.pathname.slice(1);
if (startingPath.length > 1 && !startingPath.endsWith("/")) startingPath += "/";

_retrieveVideosRecursively(startingPath).then((files) => {
_retrieveVideosRecursively().then((files) => {
if (!files) setLoading(false);

// Put the metadata (JSON) files at the end, to optimize looping order and prevent misses
Expand All @@ -323,6 +333,7 @@ const App = () => {
.slice(0, -1)
.join(""),
extension: current.url.split(".").at(-1).toLowerCase(),
playlist: current.url.replace(current.name, ""),
metadataURL: false,
};

Expand All @@ -337,16 +348,24 @@ const App = () => {
}

_videoFiles = Object.values(_videoFiles);
_storeVideos(_videoFiles);

// Playlist extraction
setPlaylists([...new Set(_videoFiles.map((v) => v.playlist).filter((p) => p))].sort());

// Filter video files retrieved according to the current url-defined playlist
let currentPlaylist = window.location.pathname.substring(1);
if (currentPlaylist && currentPlaylist.substr(-1) !== "/") currentPlaylist += "/";
_videoFiles = _videoFiles.filter((v) => v.playlist === currentPlaylist);

setVideos((freshVideos) => {
if (!hasCache) return _shuffleArray(_videoFiles);
if (!hasCache || currentPlaylist) return _shuffleArray(_videoFiles);
else if (hasCache && !_arraysAreEqual(_videoFiles, freshVideos))
return _shuffleArray([
...freshVideos,
..._videoFiles.filter((f) => !freshVideos.some((v) => v.url === f.url)),
]);
});
_storeVideos(_videoFiles);

if (!hasCache) {
setLoading(false);
Expand Down Expand Up @@ -515,13 +534,19 @@ const App = () => {
isMuted={muted}
onBlacklist={blacklist}
onOpenBlacklist={openBlacklist}
onOpenPlaylistsViewer={openPlaylistsViewer}
/>
<BlacklistManager
visible={blacklistOpen}
videos={_getBlacklist()}
onClose={hideBlacklist}
onUnmask={removeFromBlacklist}
/>
<PlaylistsViewer
visible={playlistsViewerOpen}
playlists={playlists}
onClose={hidePlaylistsViewer}
/>
</>
)}
</>
Expand Down
13 changes: 12 additions & 1 deletion src/components/BottomNavbar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Assets
import {
faCompactDisc,
faDownload,
faEyeSlash,
faVolumeMute,
Expand All @@ -9,9 +10,19 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import "./index.css";
import { useLongPress } from "@uidotdev/usehooks";

const BottomNavbar = ({ isMuted, onToggleMute, onDownload, onBlacklist, onOpenBlacklist }) => {
const BottomNavbar = ({
isMuted,
onToggleMute,
onDownload,
onBlacklist,
onOpenBlacklist,
onOpenPlaylistsViewer,
}) => {
return (
<div className="bottom-navbar">
<button type="button" className="nav-item" onClick={onOpenPlaylistsViewer}>
<FontAwesomeIcon icon={faCompactDisc} className="icon" />
</button>
<button
type="button"
className="nav-item"
Expand Down
97 changes: 97 additions & 0 deletions src/components/PlaylistViewer/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
.playlists-viewer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 200;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
transition: transform 0.3s;
transform: translateY(-100vh);
}
.playlists-viewer .playlists-viewer-inner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.playlists-viewer-inner .playlists-viewer-header {
height: 60px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: blue;
flex-shrink: 0;
}
.playlists-viewer-header span {
color: white;
font-family: "Inter", sans-serif;
font-size: 18px;
font-weight: 600;
}
.playlists-viewer-content {
height: 100%;
width: 100%;
background: black;
overflow: auto;
}
.playlists-viewer-controls {
background: #161616;
height: 56px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
}
.playlists-viewer-controls button {
outline: 0;
border: 0;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
height: 100%;
cursor: pointer;
}
.playlists-viewer-controls button svg {
font-size: 18px;
color: #d4d3d3;
width: 24px;
height: 24px;
}
.playlists-viewer.visible {
transform: translateY(0);
}
.playlists-viewer-entry {
display: flex;
align-items: center;
width: 100%;
height: 72px;
background: white;
border-bottom: 1px solid #161616;
padding-left: 12px;
padding-right: 12px;
text-decoration: unset;
}
.playlists-viewer-entry .left {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 90%;
}
.playlists-viewer-entry .left p {
max-width: 240px;
text-overflow: ellipsis;
overflow: hidden;
text-align: left;
white-space: nowrap;
font-family: "Inter", sans-serif;
color: black;
font-weight: 600;
height: 18px;
}
39 changes: 39 additions & 0 deletions src/components/PlaylistViewer/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Assets
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import "./index.css";
import { faClose, faEye } from "@fortawesome/free-solid-svg-icons";

const PlaylistsViewer = ({ visible, playlists, onClose }) => {
return (
<div className={`playlists-viewer ${visible ? "visible" : ""}`}>
<div className="playlists-viewer-inner">
<div className="playlists-viewer-header">
<span>Playlists</span>
</div>
<div className="playlists-viewer-content">
<a className="playlists-viewer-entry" href={`/`}>
<div className="left">
<p>All media</p>
</div>
</a>
{/* FULL STATE */}
{playlists &&
playlists.map((p, idx) => (
<a key={idx} className="playlists-viewer-entry" href={`/${p}`}>
<div className="left">
<p>{p}</p>
</div>
</a>
))}
</div>
<div className="playlists-viewer-controls">
<button type="button" onClick={onClose}>
<FontAwesomeIcon icon={faClose} />
</button>
</div>
</div>
</div>
);
};

export default PlaylistsViewer;

0 comments on commit 09daea9

Please sign in to comment.