diff --git a/comty.js b/comty.js index 0face5f0..511a81e3 160000 --- a/comty.js +++ b/comty.js @@ -1 +1 @@ -Subproject commit 0face5f004c2b1484751ea61228ec4ee226f49d4 +Subproject commit 511a81e313d0723a2d4f9887c1632ff5fc19658d diff --git a/package.json b/package.json index 7fa9e829..d8fa00bb 100755 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "dev": "concurrently -k \"yarn dev:client\" \"yarn dev:server\"", - "dev:server": "cd packages/server && yarn dev", - "dev:client": "cd packages/app && yarn dev", + "dev:server": "cd packages/server && npm run dev", + "dev:client": "cd packages/app && npm run dev", "postinstall": "node ./scripts/post-install.js" }, "dependencies": { diff --git a/packages/app/package.json b/packages/app/package.json index 021a7213..1027bbc4 100755 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -34,6 +34,7 @@ "bear-react-carousel": "^4.0.10-alpha.0", "classnames": "2.3.1", "comty.js": "^0.64.0", + "d3": "^7.9.0", "dashjs": "^5.0.0", "dompurify": "^3.0.0", "fast-average-color": "^9.2.0", diff --git a/packages/app/src/classes/AuthManager/index.js b/packages/app/src/classes/AuthManager/index.js index eb95b4fa..6203eeed 100644 --- a/packages/app/src/classes/AuthManager/index.js +++ b/packages/app/src/classes/AuthManager/index.js @@ -17,23 +17,28 @@ export default class AuthManager { } } + state = { + user: null, + } + public = { login: () => { app.layout.draggable.open("login", Login, { - props: { - onDone: () => { - app.layout.draggable.destroy("login") - this._emitBehavior("onLogin") - }, + componentProps: { + onDone: this.onLoginCallback, }, }) }, - logout: () => { + logout: (bypass) => { + if (bypass === true) { + AuthModel.logout() + return this._emitBehavior("onLogout") + } + app.layout.modal.confirm({ headerText: "Logout", descriptionText: "Are you sure you want to logout?", onConfirm: () => { - console.log("Logout confirmed") AuthModel.logout() this._emitBehavior("onLogout") }, @@ -65,10 +70,6 @@ export default class AuthManager { }, } - state = { - user: null, - } - initialize = async () => { const token = await SessionModel.token @@ -103,4 +104,6 @@ export default class AuthManager { await this.behaviors[behavior](...args) } } + + //onLoginCallback = async (state, result) => {} } diff --git a/packages/app/src/components/LikeButton/index.jsx b/packages/app/src/components/LikeButton/index.jsx index 1fc6f4bd..0f4a24e7 100755 --- a/packages/app/src/components/LikeButton/index.jsx +++ b/packages/app/src/components/LikeButton/index.jsx @@ -3,23 +3,12 @@ import classnames from "classnames" import "./index.less" -export default (props) => { +const LikeButton = (props) => { const [liked, setLiked] = React.useState( typeof props.liked === "function" ? false : props.liked, ) const [clicked, setClicked] = React.useState(false) - // TODO: Support handle like change on websocket event - if (typeof props.watchWs === "object") { - // useWsEvents({ - // [props.watchWs.event]: (data) => { - // handleUpdateTrackLike(data.track_id, data.action === "liked") - // } - // }, { - // socketName: props.watchWs.socket, - // }) - } - async function computeLikedState() { if (props.disabled) { return false @@ -48,7 +37,7 @@ export default (props) => { }, 500) if (typeof props.onClick === "function") { - props.onClick() + props.onClick(!liked) } setLiked(!liked) @@ -74,3 +63,5 @@ export default (props) => { ) } + +export default LikeButton diff --git a/packages/app/src/components/Login/index.jsx b/packages/app/src/components/Login/index.jsx index 0c954fa4..48a6fcc0 100755 --- a/packages/app/src/components/Login/index.jsx +++ b/packages/app/src/components/Login/index.jsx @@ -91,11 +91,11 @@ class Login extends React.Component { }) } - onDone = async ({ mfa_required } = {}) => { - if (mfa_required) { + onDone = async (result = {}) => { + if (result.mfa_required) { this.setState({ loading: false, - mfa_required: mfa_required, + mfa_required: result.mfa_required, }) return false @@ -108,7 +108,7 @@ class Login extends React.Component { } if (typeof this.props.onDone === "function") { - await this.props.onDone() + await this.props.onDone(this.state, result) } return true diff --git a/packages/app/src/components/Music/Playlist/index.jsx b/packages/app/src/components/Music/Playlist/index.jsx index 2f8bef88..5505ae86 100755 --- a/packages/app/src/components/Music/Playlist/index.jsx +++ b/packages/app/src/components/Music/Playlist/index.jsx @@ -7,93 +7,98 @@ import { Icons } from "@components/Icons" import "./index.less" const typeToNavigationType = { - playlist: "playlist", - album: "album", - track: "track", - single: "track", - ep: "album" + playlist: "playlist", + album: "album", + track: "track", + single: "track", + ep: "album", } const Playlist = (props) => { - const [coverHover, setCoverHover] = React.useState(false) + const [coverHover, setCoverHover] = React.useState(false) - let { playlist } = props + let { playlist } = props - if (!playlist) { - return null - } + if (!playlist) { + return null + } - const onClick = () => { - if (typeof props.onClick === "function") { - return props.onClick(playlist) - } + const onClick = () => { + if (typeof props.onClick === "function") { + return props.onClick(playlist) + } - return app.location.push(`/music/${typeToNavigationType[playlist.type.toLowerCase()]}/${playlist._id}`) - } + return app.location.push(`/music/list/${playlist._id}`) + } - const onClickPlay = (e) => { - e.stopPropagation() + const onClickPlay = (e) => { + e.stopPropagation() - app.cores.player.start(playlist.list) - } + app.cores.player.start(playlist.items) + } + return ( +
+
setCoverHover(true)} + onMouseLeave={() => setCoverHover(false)} + onClick={onClickPlay} + > +
+ +
- const subtitle = playlist.type === "playlist" ? `By ${playlist.user_id}` : (playlist.description ?? (playlist.publisher && `Release from ${playlist.publisher?.fullName}`)) + +
- return
-
setCoverHover(true)} - onMouseLeave={() => setCoverHover(false)} - onClick={onClickPlay} - > -
- -
+
+
+

{playlist.title}

+
+ {props.row && ( +
+

+ + {playlist.type ?? "playlist"} +

+
+ )} +
- -
+ {!props.row && ( +
+ {props.length && ( +

+ {" "} + {props.length ?? + playlist.total_length ?? + playlist.list.length} +

+ )} -
-
-

{playlist.title}

-
- - { - subtitle &&
-

- {subtitle} -

-
- } -
- -
- { - props.length &&

- {props.length ?? playlist.total_length ?? playlist.list.length} -

- } - - { - playlist.type &&

- - {playlist.type ?? "playlist"} -

- } -
-
+ {playlist.type && ( +

+ + {playlist.type ?? "playlist"} +

+ )} +
+ )} +
+ ) } -export default Playlist \ No newline at end of file +export default Playlist diff --git a/packages/app/src/components/Music/Playlist/index.less b/packages/app/src/components/Music/Playlist/index.less index e651cdbe..6ebf2a82 100755 --- a/packages/app/src/components/Music/Playlist/index.less +++ b/packages/app/src/components/Music/Playlist/index.less @@ -14,6 +14,45 @@ transition: all 150ms ease-in-out; + &.row-mode { + flex-direction: row; + + align-items: center; + + width: fit-content; + max-width: unset; + min-width: 100px; + + height: fit-content; + + gap: 10px; + + .playlist_cover { + width: 50px; + height: 50px; + + min-width: 50px; + max-height: 50px; + } + + .playlist_cover { + .playlist_cover_mask { + font-size: 2rem; + } + } + + .playlist_info { + justify-content: center; + + .playlist_info_title { + h1 { + word-break: break-all; + white-space: wrap; + } + } + } + } + &.cover-hovering { .playlist_cover { .playlist_cover_mask { @@ -29,18 +68,27 @@ align-items: center; justify-content: center; - width: 100%; - //max-height: 150px; + width: @playlist_cover_maxSize; + height: @playlist_cover_maxSize; transition: all 0.2s ease-in-out; z-index: 50; - img { - width: @playlist_cover_maxSize; - height: @playlist_cover_maxSize; + border-radius: 12px; + overflow: hidden; - object-fit: cover; - border-radius: 12px; + background-color: var(--background-color-accent); + + .image-wrapper { + width: 100%; + height: 100%; + + img { + width: 100%; + height: 100%; + + object-fit: cover; + } } .playlist_cover_mask { @@ -116,28 +164,7 @@ } } - .playlist_actions { - display: flex; - flex-direction: column; - - align-self: flex-end; - justify-self: flex-end; - - justify-content: center; - align-items: center; - - height: 100%; - - padding: 10px; - - .ant-btn { - svg { - margin: 0 !important; - } - } - } - - .playlist_bottom { + .playlist_details { display: flex; flex-direction: row; diff --git a/packages/app/src/components/Music/PlaylistView/decorators.jsx b/packages/app/src/components/Music/PlaylistView/decorators.jsx new file mode 100644 index 00000000..2044e05b --- /dev/null +++ b/packages/app/src/components/Music/PlaylistView/decorators.jsx @@ -0,0 +1,26 @@ +import { Icons } from "@components/Icons" + +const PlaylistTypeDecorators = { + single: () => ( + + Single + + ), + album: () => ( + + Album + + ), + ep: () => ( + + EP + + ), + mix: () => ( + + Mix + + ), +} + +export default PlaylistTypeDecorators diff --git a/packages/app/src/components/Music/PlaylistView/header.jsx b/packages/app/src/components/Music/PlaylistView/header.jsx new file mode 100644 index 00000000..b24893c4 --- /dev/null +++ b/packages/app/src/components/Music/PlaylistView/header.jsx @@ -0,0 +1,158 @@ +import React from "react" +import * as antd from "antd" +import classnames from "classnames" + +import { Icons } from "@components/Icons" +import ImageViewer from "@components/ImageViewer" +import LikeButton from "@components/LikeButton" +import seekToTimeLabel from "@utils/seekToTimeLabel" + +import MusicModel from "@models/music" + +import PlaylistTypeDecorators from "./decorators" + +const typeToKind = { + album: "releases", + ep: "releases", + compilation: "releases", + playlist: "playlists", +} + +const PlaylistHeader = ({ + playlist, + owningPlaylist, + onPlayAll, + onViewDetails, + onMoreMenuClick, +}) => { + const playlistType = playlist.type?.toLowerCase() ?? "playlist" + + const moreMenuItems = React.useMemo(() => { + const items = [] + // Only allow editing/deleting standard playlists owned by the user + if ( + owningPlaylist && + (!playlist.type || playlist.type === "playlist") + ) { + items.push({ key: "edit", label: "Edit" }) + items.push({ key: "delete", label: "Delete" }) + } + return items + }, [playlist.type, owningPlaylist]) + + const handlePublisherClick = () => { + if (playlist.publisher?.username) { + app.navigation.goToAccount(playlist.publisher.username) + } + } + + const handleOnClickLike = async (to) => { + await MusicModel.toggleItemFavorite( + typeToKind[playlistType], + playlist._id, + to, + ) + } + + const fetchItemIsFavorite = async () => { + const isFavorite = await MusicModel.isItemFavorited( + typeToKind[playlistType], + playlist._id, + ) + return isFavorite + } + + return ( +
+
+
+ +
+ +
+
+ {playlist.service === "tidal" && }{" "} + {typeof playlist.title === "function" ? ( + playlist.title() + ) : ( +

{playlist.title}

+ )} +
+ +
+ {PlaylistTypeDecorators[playlistType] && ( +
+ {PlaylistTypeDecorators[playlistType]()} +
+ )} + +
+

+ {playlist.total_items}{" "} + Items +

+
+ + {playlist.total_duration > 0 && ( +
+

+ {" "} + {seekToTimeLabel(playlist.total_duration)} +

+
+ )} + + {playlist.publisher && ( +
+

+ Publised by{" "} + {playlist.publisher.username} +

+
+ )} +
+ +
+ + Play + + +
+ +
+ + {moreMenuItems.length > 0 && ( + + } /> + + )} +
+
+
+
+ ) +} + +export default PlaylistHeader diff --git a/packages/app/src/components/Music/PlaylistView/index.jsx b/packages/app/src/components/Music/PlaylistView/index.jsx index a6cd9b20..c44a43a5 100755 --- a/packages/app/src/components/Music/PlaylistView/index.jsx +++ b/packages/app/src/components/Music/PlaylistView/index.jsx @@ -1,437 +1,213 @@ import React from "react" import * as antd from "antd" import classnames from "classnames" -import ReactMarkdown from "react-markdown" -import remarkGfm from "remark-gfm" -import fuse from "fuse.js" import { WithPlayerContext } from "@contexts/WithPlayerContext" import { Context as PlaylistContext } from "@contexts/WithPlaylistContext" -import useWsEvents from "@hooks/useWsEvents" import checkUserIdIsSelf from "@utils/checkUserIdIsSelf" -import LoadMore from "@components/LoadMore" -import { Icons } from "@components/Icons" -import MusicTrack from "@components/Music/Track" -import SearchButton from "@components/SearchButton" -import ImageViewer from "@components/ImageViewer" - import MusicModel from "@models/music" +import PlaylistHeader from "./header" +import TrackList from "./list" + import "./index.less" -const PlaylistTypeDecorators = { - single: () => ( - - - Single - - ), - album: () => ( - - - Album - - ), - ep: () => ( - - - EP - - ), - mix: () => ( - - - Mix - - ), -} - -const PlaylistInfo = (props) => { - return ( -
- -
- ) -} - -const MoreMenuHandlers = { - edit: async (playlist) => {}, - delete: async (playlist) => { - return antd.Modal.confirm({ - title: "Are you sure you want to delete this playlist?", - onOk: async () => { - const result = await MusicModel.deletePlaylist( - playlist._id, - ).catch((err) => { - console.log(err) - - app.message.error("Failed to delete playlist") - - return null - }) - - if (result) { - app.navigation.goToMusic() - } - }, - }) - }, -} - -const PlaylistView = (props) => { - const [playlist, setPlaylist] = React.useState(props.playlist) +const PlaylistView = ({ + playlist: initialPlaylist, + noHeader = false, + onLoadMore, + hasMore, +}) => { + const [playlist, setPlaylist] = React.useState(initialPlaylist) const [searchResults, setSearchResults] = React.useState(null) - const [owningPlaylist, setOwningPlaylist] = React.useState( - checkUserIdIsSelf(props.playlist?.user_id), + const searchTimeoutRef = React.useRef(null) // Ref for debounce timeout + + // Derive ownership directly instead of using state + const isOwner = React.useMemo( + () => checkUserIdIsSelf(playlist?.user_id), + [playlist], ) - const moreMenuItems = React.useMemo(() => { - const items = [ - { - key: "edit", - label: "Edit", + const playlistContextValue = React.useMemo( + () => ({ + playlist_data: playlist, + owning_playlist: isOwner, + add_track: (track) => { + /* TODO: Implement */ }, - ] + remove_track: (track) => { + /* TODO: Implement */ + }, + }), + [playlist, isOwner], + ) - if (!playlist.type || playlist.type === "playlist") { - if (checkUserIdIsSelf(playlist.user_id)) { - items.push({ - key: "delete", - label: "Delete", + // Define handlers for playlist actions (Edit, Delete) + const MoreMenuHandlers = React.useMemo( + () => ({ + edit: async (pl) => { + // TODO: Implement Edit Playlist logic + console.log("Edit playlist:", pl._id) + app.message.info("Edit not implemented yet.") + }, + delete: async (pl) => { + antd.Modal.confirm({ + title: "Are you sure you want to delete this playlist?", + content: `Playlist: ${pl.title}`, + okText: "Delete", + okType: "danger", + cancelText: "Cancel", + onOk: async () => { + try { + await MusicModel.deletePlaylist(pl._id) + app.message.success("Playlist deleted successfully") + app.navigation.goToMusic() // Navigate away after deletion + } catch (err) { + console.error("Failed to delete playlist:", err) + app.message.error( + err.message || "Failed to delete playlist", + ) + } + }, }) - } - } - - return items - }) - - const contextValues = { - playlist_data: playlist, - owning_playlist: owningPlaylist, - add_track: (track) => {}, - remove_track: (track) => {}, - } - - let debounceSearch = null + }, + }), + [], + ) const makeSearch = (value) => { - //TODO: Implement me using API - return app.message.info("Not implemented yet...") + // TODO: Implement API call for search + console.log("Searching for:", value) + setSearchResults([]) // Placeholder: clear results or set loading state + return app.message.info("Search not implemented yet...") } - const handleOnSearchChange = (value) => { - debounceSearch = setTimeout(() => { + const handleSearchChange = (value) => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + searchTimeoutRef.current = setTimeout(() => { makeSearch(value) - }, 500) + }, 500) // 500ms debounce } - const handleOnSearchEmpty = () => { - if (debounceSearch) { - clearTimeout(debounceSearch) + const handleSearchEmpty = () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) } - - setSearchResults(null) + setSearchResults(null) // Clear search results when input is cleared } - const handleOnClickPlaylistPlay = () => { - app.cores.player.start(playlist.items) + const handlePlayAll = () => { + if (playlist?.items?.length > 0) { + app.cores.player.start(playlist.items) + } } - const handleOnClickViewDetails = () => { - app.layout.modal.open("playlist_info", PlaylistInfo, { - props: { - data: playlist, - }, - }) + const handleViewDetails = () => { + if (playlist?.description) { + app.layout.modal.open( + "playlist_info", + () => ( + + ), + { title: playlist.title || "Playlist Info" }, // Add title to modal + ) + } } - const handleOnClickTrack = (track) => { - // search index of track - const index = playlist.items.findIndex((item) => { - return item._id === track._id - }) + const handleTrackClick = (track) => { + const index = playlist.items.findIndex((item) => item._id === track._id) + // Track not found in current playlist items if (index === -1) { - return + return false } - // check if clicked track is currently playing - if (app.cores.player.state.track_manifest?._id === track._id) { - app.cores.player.playback.toggle() + const playerCore = app.cores.player + // Toggle playback if the clicked track is already playing + if (playerCore.state.track_manifest?._id === track._id) { + playerCore.playback.toggle() } else { - app.cores.player.start(playlist.items, { - startIndex: index, - }) + // Start playback from the clicked track + playerCore.start(playlist.items, { startIndex: index }) } } - const handleUpdateTrackLike = (track_id, liked) => { + const handleTrackStateChange = (track_id, update) => { setPlaylist((prev) => { - const index = prev.list.findIndex((item) => { - return item._id === track_id - }) + if (!prev) return prev + const trackIndex = prev.items.findIndex( + (item) => item._id === track_id, + ) - if (index !== -1) { - const newState = { - ...prev, - } - - newState.list[index].liked = liked - - return newState - } - - return prev - }) - } - - const handleTrackChangeState = (track_id, update) => { - setPlaylist((prev) => { - const index = prev.list.findIndex((item) => { - return item._id === track_id - }) - - if (index !== -1) { - const newState = { - ...prev, - } - - newState.list[index] = { - ...newState.list[index], + if (trackIndex !== -1) { + const updatedItems = [...prev.items] + updatedItems[trackIndex] = { + ...updatedItems[trackIndex], ...update, } - - return newState + return { ...prev, items: updatedItems } } - return prev }) } const handleMoreMenuClick = async (e) => { const handler = MoreMenuHandlers[e.key] - - if (typeof handler !== "function") { - throw new Error(`Invalid menu handler [${e.key}]`) + if (typeof handler === "function") { + await handler(playlist) + } else { + console.error(`Invalid menu handler key: ${e.key}`) } - - return await handler(playlist) } - useWsEvents( - { - "music:track:toggle:like": (data) => { - handleUpdateTrackLike(data.track_id, data.action === "liked") - }, - }, - { - socketName: "music", - }, - ) + React.useEffect(() => { + setPlaylist(initialPlaylist) + setSearchResults(null) + }, [initialPlaylist]) React.useEffect(() => { - setPlaylist(props.playlist) - setOwningPlaylist(checkUserIdIsSelf(props.playlist?.user_id)) - }, [props.playlist]) + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + } + }, []) if (!playlist) { return } - const playlistType = playlist.type?.toLowerCase() ?? "playlist" - return ( - +
- {!props.noHeader && ( -
-
-
- -
- -
-
- {playlist.service === "tidal" && ( - - )} - {typeof playlist.title === - "function" ? ( - playlist.title - ) : ( -

{playlist.title}

- )} -
- -
- {playlistType && - PlaylistTypeDecorators[ - playlistType - ] && ( -
- {PlaylistTypeDecorators[ - playlistType - ]()} -
- )} -
-

- {" "} - {props.length ?? - playlist.total_length ?? - playlist.items.length}{" "} - Items -

-
- {playlist.publisher && ( -
-

{ - app.navigation.goToAccount( - playlist.publisher - .username, - ) - }} - > - - Publised by{" "} - - { - playlist.publisher - .username - } - -

-
- )} -
- -
- - - Play - - - {playlist.description && ( - } - onClick={ - handleOnClickViewDetails - } - /> - )} - - {owningPlaylist && ( - - } - /> - - )} -
-
-
-
+ {!noHeader && ( + )} -
- {!props.noHeader && playlist.items.length > 0 && ( -
-

- Tracks -

- - -
- )} - - {playlist.items.length === 0 && ( - - This playlist - its empty! - - } - /> - )} - - {searchResults && - searchResults.map((item) => { - return ( - handleOnClickTrack(item)} - changeState={(update) => - handleTrackChangeState( - item._id, - update, - ) - } - /> - ) - })} - - {!searchResults && playlist.items.length > 0 && ( - } - onBottom={props.onLoadMore} - hasMore={props.hasMore} - > - - {playlist.items.map((item, index) => { - return ( - - handleOnClickTrack(item) - } - changeState={(update) => - handleTrackChangeState( - item._id, - update, - ) - } - /> - ) - })} - - - )} -
+
diff --git a/packages/app/src/components/Music/PlaylistView/index.less b/packages/app/src/components/Music/PlaylistView/index.less index 042b223a..1678898d 100755 --- a/packages/app/src/components/Music/PlaylistView/index.less +++ b/packages/app/src/components/Music/PlaylistView/index.less @@ -207,6 +207,13 @@ html { align-items: center; gap: 10px; + + .likeButtonWrapper { + background-color: var(--background-color-primary); + + border-radius: 12px; + padding: 10px; + } } } } diff --git a/packages/app/src/components/Music/PlaylistView/list.jsx b/packages/app/src/components/Music/PlaylistView/list.jsx new file mode 100644 index 00000000..9fcfe14d --- /dev/null +++ b/packages/app/src/components/Music/PlaylistView/list.jsx @@ -0,0 +1,98 @@ +import React from "react" +import * as antd from "antd" +import classnames from "classnames" + +import { WithPlayerContext } from "@contexts/WithPlayerContext" +import { Context as PlaylistContext } from "@contexts/WithPlaylistContext" + +import LoadMore from "@components/LoadMore" +import { Icons } from "@components/Icons" +import MusicTrack from "@components/Music/Track" +import SearchButton from "@components/SearchButton" + +/** + * Renders the list of tracks in the playlist. + */ +const TrackList = ({ + tracks, + searchResults, + onTrackClick, + onTrackStateChange, + onSearchChange, + onSearchEmpty, + onLoadMore, + hasMore, + noHeader = false, +}) => { + const showListHeader = !noHeader && (tracks.length > 0 || searchResults) + + if (!searchResults && tracks.length === 0) { + return ( +
+ + This playlist is empty! + + } + /> +
+ ) + } + + return ( +
+ {showListHeader && ( +
+

+ Tracks +

+ {/* TODO: Implement Search API call */} + +
+ )} + + {searchResults ? ( // Display search results if available + searchResults.map((item) => ( + onTrackClick(item)} + changeState={(update) => + onTrackStateChange(item._id, update) + } + /> + )) + ) : ( + // Display regular track list + } + onBottom={onLoadMore} + hasMore={hasMore} + > + + {tracks.map((item, index) => ( + onTrackClick(item)} + changeState={(update) => + onTrackStateChange(item._id, update) + } + /> + ))} + + + )} +
+ ) +} + +export default TrackList diff --git a/packages/app/src/components/Music/Radio/index.jsx b/packages/app/src/components/Music/Radio/index.jsx new file mode 100644 index 00000000..42e8b4aa --- /dev/null +++ b/packages/app/src/components/Music/Radio/index.jsx @@ -0,0 +1,57 @@ +import React from "react" +import { Skeleton, Result } from "antd" +import RadioModel from "@models/radio" +import Image from "@components/Image" + +import { MdPlayCircle, MdHeadphones } from "react-icons/md" + +import "./index.less" + +const Radio = ({ item, style }) => { + const onClickItem = () => { + app.cores.player.start( + { + title: item.name, + source: item.http_src, + cover: item.background, + }, + { + radioId: item.radio_id, + }, + ) + } + + if (!item) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+ +
+

{item.name}

+

{item.description}

+ +
+
+ + {item.now_playing.song.text} +
+ +
+ + {item.listeners} +
+
+
+
+ ) +} + +export default Radio diff --git a/packages/app/src/components/Music/Radio/index.less b/packages/app/src/components/Music/Radio/index.less new file mode 100644 index 00000000..db83729e --- /dev/null +++ b/packages/app/src/components/Music/Radio/index.less @@ -0,0 +1,82 @@ +.radio-item { + position: relative; + + display: flex; + flex-direction: column; + + min-width: 250px; + min-height: 150px; + + border-radius: 16px; + background-color: var(--background-color-accent); + + overflow: hidden; + + &:hover { + cursor: pointer; + + .radio-item-content { + backdrop-filter: blur(2px); + } + } + + &.empty { + cursor: default; + } + + .lazy-load-image-background, + .radio-item-cover { + position: absolute; + + z-index: 1; + + top: 0; + left: 0; + + width: 100%; + height: 100%; + + img { + object-fit: cover; + } + } + + .radio-item-content { + position: relative; + z-index: 2; + + display: flex; + flex-direction: column; + + justify-content: space-between; + + width: 100%; + height: 100%; + + padding: 16px; + + transition: all 150ms ease-in-out; + + .radio-item-info { + display: flex; + align-items: center; + + gap: 8px; + + .radio-item-info-item { + display: flex; + + flex-direction: row; + align-items: center; + + gap: 8px; + padding: 4px; + + background-color: rgba(var(--bg_color_3), 0.7); + + border-radius: 8px; + font-size: 0.7rem; + } + } + } +} diff --git a/packages/app/src/components/Music/Track/index.less b/packages/app/src/components/Music/Track/index.less index 9bcfd7b8..482e9351 100755 --- a/packages/app/src/components/Music/Track/index.less +++ b/packages/app/src/components/Music/Track/index.less @@ -206,7 +206,7 @@ html { .music-track_title { font-size: 1rem; - font-family: "Space Grotesk", sans-serif; + //font-family: "Space Grotesk", sans-serif; } .music-track_artist { diff --git a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx b/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx index 1aa38e05..ae6e7576 100644 --- a/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx +++ b/packages/app/src/components/MusicStudio/ReleaseEditor/index.jsx @@ -286,7 +286,7 @@ const ReleaseEditor = (props) => { icon={} onClick={() => app.location.push( - `/music/release/${globalState._id}`, + `/music/list/${globalState._id}`, ) } > diff --git a/packages/app/src/components/PagePanels/index.jsx b/packages/app/src/components/PagePanels/index.jsx index 77239280..bae7f290 100755 --- a/packages/app/src/components/PagePanels/index.jsx +++ b/packages/app/src/components/PagePanels/index.jsx @@ -13,7 +13,6 @@ export class Tab extends React.Component { error: null, } - // handle on error componentDidCatch(err) { this.setState({ error: err }) } @@ -28,7 +27,6 @@ export class Tab extends React.Component { /> ) } - return <>{this.props.children} } } @@ -49,7 +47,7 @@ export class PagePanelWithNavMenu extends React.Component { activeTab: new URLSearchParams(window.location.search).get("type") ?? this.props.defaultTab ?? - this.props.tabs[0].key, + this.props.tabs[0]?.key, renders: [], } @@ -57,41 +55,98 @@ export class PagePanelWithNavMenu extends React.Component { interface = { attachComponent: (id, component, options) => { - const renders = this.state.renders - - renders.push({ - id: id, - component: component, - options: options, - ref: React.createRef(), - }) - - this.setState({ - renders: renders, - }) + this.setState((prevState) => ({ + renders: [ + ...prevState.renders, + { + id: id, + component: component, + options: options, + ref: React.createRef(), + }, + ], + })) }, detachComponent: (id) => { - const renders = this.state.renders - - const index = renders.findIndex((render) => render.id === id) - - renders.splice(index, 1) - - this.setState({ - renders: renders, - }) + this.setState((prevState) => ({ + renders: prevState.renders.filter((render) => render.id !== id), + })) }, } + updateLayoutHeaderAndTopBar = () => { + const navMenuItems = this.getItems([ + ...(this.props.tabs ?? []), + ...(this.props.extraItems ?? []), + ]) + + const mobileNavMenuItems = this.getItems(this.props.tabs ?? []) + + if (app.isMobile) { + if (mobileNavMenuItems.length > 0) { + app.layout.top_bar.render( + this.handleTabChange(key)} + />, + ) + } else { + app.layout.top_bar.renderDefault() + } + } else { + if ( + navMenuItems.length > 0 || + this.state.renders.length > 0 || + this.props.navMenuHeader + ) { + app.layout.header.render( + this.handleTabChange(key)} + renderNames + > + {this.state.renders.map((renderItem) => + React.createElement(renderItem.component, { + ...(renderItem.options.props ?? {}), + ref: renderItem.ref, + key: renderItem.id, + }), + )} + , + ) + } else { + app.layout.header.render(null) + } + } + } + componentDidMount() { app.layout.page_panels = this.interface if (app.isMobile) { app.layout.top_bar.shouldUseTopBarSpacer(true) app.layout.toggleCenteredContent(false) + } else { + app.layout.toggleCenteredContent(true) } - app.layout.toggleCenteredContent(true) + this.updateLayoutHeaderAndTopBar() + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevState.activeTab !== this.state.activeTab || + prevProps.tabs !== this.props.tabs || + prevProps.extraItems !== this.props.extraItems || + prevState.renders !== this.state.renders || + prevProps.navMenuHeader !== this.props.navMenuHeader || + prevProps.defaultTab !== this.props.defaultTab + ) { + this.updateLayoutHeaderAndTopBar() + } } componentWillUnmount() { @@ -102,7 +157,9 @@ export class PagePanelWithNavMenu extends React.Component { app.layout.header.render(null) } } else { - app.layout.top_bar.renderDefault() + if (app.layout.top_bar) { + app.layout.top_bar.renderDefault() + } } } @@ -112,13 +169,23 @@ export class PagePanelWithNavMenu extends React.Component { return <> } - if (this.props.tabs.length === 0) { + if (this.props.tabs.length === 0 && !this.state.activeTab) { return <> } - // slip the active tab by splitting on "." if (!this.state.activeTab) { - console.error("PagePanelWithNavMenu: activeTab is not defined") + const firstTabKey = this.props.tabs[0]?.key + + if (firstTabKey) { + console.error("PagePanelWithNavMenu: activeTab is not defined") + return ( + + ) + } return <> } @@ -134,14 +201,12 @@ export class PagePanelWithNavMenu extends React.Component { console.error( "PagePanelWithNavMenu: tab.children is not defined", ) - return (tab = null) } - tab = tab.children.find( (children) => children.key === - `${activeTabDirectory[index - 1]}.${key}`, + `${activeTabDirectory.slice(0, index).join(".")}.${key}`, ) } }) @@ -150,7 +215,6 @@ export class PagePanelWithNavMenu extends React.Component { if (this.props.onNotFound) { return this.props.onNotFound() } - return ( { + document.startViewTransition(() => { this.tabChange(key) }) + + return } console.warn( @@ -205,20 +270,17 @@ export class PagePanelWithNavMenu extends React.Component { this.primaryPanelRef.current && this.primaryPanelRef.current?.classList ) { - // set to primary panel fade-opacity-leave class this.primaryPanelRef.current.classList.add("fade-opacity-leave") - - // remove fade-opacity-leave class after animation setTimeout(() => { - this.primaryPanelRef.current.classList.remove( - "fade-opacity-leave", - ) + if (this.primaryPanelRef.current) { + this.primaryPanelRef.current.classList.remove( + "fade-opacity-leave", + ) + } }, 300) } - await new Promise((resolve) => setTimeout(resolve, 200)) } - return this.tabChange(key) } @@ -229,59 +291,19 @@ export class PagePanelWithNavMenu extends React.Component { ) return [] } - - items = items.map((item) => { - return { - key: item.key, - icon: createIconRender(item.icon), - label: item.label, - children: item.children && this.getItems(item.children), - disabled: item.disabled, - props: item.props ?? {}, - } - }) - - return items + return items.map((item) => ({ + key: item.key, + icon: createIconRender(item.icon), + label: item.label, + children: item.children && this.getItems(item.children), + disabled: item.disabled, + props: item.props ?? {}, + })) } render() { return ( <> - {app.isMobile && - app.layout.top_bar.render( - this.handleTabChange(key)} - />, - )} - - {!app.isMobile && - app.layout.header.render( - this.handleTabChange(key)} - renderNames - > - {Array.isArray(this.state.renders) && [ - this.state.renders.map((render, index) => { - return React.createElement( - render.component, - { - ...render.options.props, - ref: render.ref, - }, - ) - }), - ]} - , - )} -
{this.renderActiveTab()} diff --git a/packages/app/src/components/Player/Actions/index.jsx b/packages/app/src/components/Player/Actions/index.jsx index 9b873395..a70b2976 100755 --- a/packages/app/src/components/Player/Actions/index.jsx +++ b/packages/app/src/components/Player/Actions/index.jsx @@ -27,8 +27,8 @@ const ExtraActions = (props) => { return false } - await trackInstance.manifest.serviceOperations.toggleItemFavourite( - "track", + await trackInstance.manifest.serviceOperations.toggleItemFavorite( + "tracks", trackInstance.manifest._id, ) } @@ -47,7 +47,7 @@ const ExtraActions = (props) => { { - return
- { - return `${Math.round(value * 100)}%` - } - }} - vertical - /> -
+ return ( +
+ { + return `${Math.round(value * 100)}%` + }, + }} + vertical + /> +
+ ) } diff --git a/packages/app/src/components/Player/Controls/index.jsx b/packages/app/src/components/Player/Controls/index.jsx index 8d520107..044d45ad 100755 --- a/packages/app/src/components/Player/Controls/index.jsx +++ b/packages/app/src/components/Player/Controls/index.jsx @@ -39,7 +39,7 @@ const EventsHandlers = { const track = app.cores.player.track() - return await track.manifest.serviceOperations.toggleItemFavourite( + return await track.manifest.serviceOperations.toggleItemFavorite( "track", ctx.track_manifest._id, ) @@ -133,7 +133,7 @@ const Controls = (props) => { handleAction("like")} disabled={!trackInstance?.manifest?._id} diff --git a/packages/app/src/components/Player/SeekBar/index.jsx b/packages/app/src/components/Player/SeekBar/index.jsx index b2e78287..21876036 100755 --- a/packages/app/src/components/Player/SeekBar/index.jsx +++ b/packages/app/src/components/Player/SeekBar/index.jsx @@ -1,6 +1,5 @@ import React from "react" -import * as antd from "antd" -import Slider from "@mui/material/Slider" +import Slider from "./slider" import classnames from "classnames" import seekToTimeLabel from "@utils/seekToTimeLabel" @@ -8,6 +7,8 @@ import seekToTimeLabel from "@utils/seekToTimeLabel" import "./index.less" export default class SeekBar extends React.Component { + static updateInterval = 1000 + state = { playing: app.cores.player.state["playback_status"] === "playing", timeText: "00:00", @@ -63,10 +64,16 @@ export default class SeekBar extends React.Component { const seek = app.cores.player.controls.seek() const duration = app.cores.player.controls.duration() - const percent = (seek / duration) * 100 + let percent = 0 // Default to 0 + // Ensure duration is a positive number to prevent division by zero or NaN results + if (typeof duration === "number" && duration > 0) { + percent = (seek / duration) * 100 + } + // Ensure percent is a finite number; otherwise, default to 0. + // This handles cases like NaN (e.g., 0/0) or Infinity. this.setState({ - sliderTime: percent, + sliderTime: Number.isFinite(percent) ? percent : 0, }) } @@ -130,7 +137,7 @@ export default class SeekBar extends React.Component { if (this.state.playing) { this.interval = setInterval(() => { this.updateAll() - }, 1000) + }, SeekBar.updateInterval) } else { if (this.interval) { clearInterval(this.interval) @@ -173,7 +180,6 @@ export default class SeekBar extends React.Component { })} > { + onChangeCommitted={(_, value) => { this.setState({ sliderLock: false, }) - this.handleSeek(this.state.sliderTime) + this.handleSeek(value) if (!this.props.playing) { app.cores.player.playback.play() } }} - valueLabelDisplay="auto" valueLabelFormat={(value) => { return seekToTimeLabel( (value / 100) * diff --git a/packages/app/src/components/Player/SeekBar/index.less b/packages/app/src/components/Player/SeekBar/index.less index d9acfed1..458e5317 100755 --- a/packages/app/src/components/Player/SeekBar/index.less +++ b/packages/app/src/components/Player/SeekBar/index.less @@ -1,75 +1,154 @@ .player-seek_bar { - z-index: 330; + z-index: 330; - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - align-items: center; - justify-content: center; + align-items: center; + justify-content: center; - align-self: center; + align-self: center; - width: 90%; - height: 100%; + width: 90%; + height: 100%; - margin: 0 0 10px 0; + gap: 6px; - border-radius: 8px; + //margin: 0 0 10px 0; - transition: all 150ms ease-in-out; + border-radius: 8px; - &.hidden { - height: 0px; - opacity: 0; - pointer-events: none; - } + transition: all 150ms ease-in-out; - .progress { - width: 100%; - height: 100%; + &.hidden { + height: 0px; + opacity: 0; + pointer-events: none; + } - transition: all 150ms ease-in-out; + .progress { + width: 100%; + height: 100%; - &.hidden { - opacity: 0; - height: 0; - pointer-events: none; - } - } + transition: all 150ms ease-in-out; - .timers { - display: inline-flex; - flex-direction: row; + &.hidden { + opacity: 0; + height: 0; + pointer-events: none; + } + } - width: 100%; - height: fit-content; + .timers { + display: inline-flex; + flex-direction: row; - justify-content: space-between; - align-items: center; - } + width: 100%; + height: fit-content; - .MuiSlider-rail { - height: 5px; - } + justify-content: space-between; + align-items: center; - .MuiSlider-track { - height: 5px; - background-color: var(--colorPrimary); - } + font-family: "DM Mono", monospace; + font-size: 0.8rem; + } +} - .MuiSlider-thumb { - background-color: var(--colorPrimary); +.slider-container { + position: relative; - } + z-index: 200; - h1, - h2, - h3, - h4, - h5, - h6, - p, - span { - color: var(--text-color); - } -} \ No newline at end of file + width: 100%; + height: 8px; + + transform: translateY(2px); + + .slider-background-track { + position: absolute; + + z-index: 100; + + width: 100%; + height: 100%; + + background-color: rgba(var(--layoutBackgroundColor), 0.7); + + border-radius: 24px; + + left: 0; + top: 0; + + backdrop-filter: blur(5px); + } + + .slider-progress-track { + position: absolute; + + left: 0; + top: 0; + + z-index: 110; + + height: 100%; + + background-color: var(--colorPrimary); + border-radius: 24px; + } + + // This class is on the itself. + // It needs to be transparent so the divs above show through. + // Its main role now is to provide the interactive thumb. + .slider-input { + position: absolute; + + left: 0; + top: 0; + + z-index: 120; + + padding: 0; + margin: 0; + + width: 100%; + height: 8px; + + -webkit-appearance: none; + appearance: none; + + background: transparent !important; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background-color: transparent; + + width: 8px; + height: 8px; + + cursor: pointer; + } + } +} + +.player-seek_bar-track-tooltip { + pointer-events: none; + + position: absolute; + z-index: 10; + + bottom: 100%; + + margin-bottom: 6px; + + padding: 4px 8px; + + background-color: var(--tooltip-background, #333); + color: var(--text-color); + border-radius: 24px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + + font-size: 0.8rem; + font-family: "DM Mono", monospace; + white-space: nowrap; +} diff --git a/packages/app/src/components/Player/SeekBar/slider.jsx b/packages/app/src/components/Player/SeekBar/slider.jsx new file mode 100644 index 00000000..cca2cf2c --- /dev/null +++ b/packages/app/src/components/Player/SeekBar/slider.jsx @@ -0,0 +1,139 @@ +import React from "react" +import { motion } from "framer-motion" + +const Slider = ({ + min = 0, + max = 100, + step = 1, + value, + disabled = false, + onChange, + onChangeCommitted, + valueLabelFormat, +}) => { + const [internalValue, setInternalValue] = React.useState(value) + const [tooltipVisible, setTooltipVisible] = React.useState(false) + const [tooltipValue, setTooltipValue] = React.useState(value) + const [tooltipPosition, setTooltipPosition] = React.useState(0) + const sliderRef = React.useRef(null) + + React.useEffect(() => { + setInternalValue(value) + }, [value]) + + const handleChange = React.useCallback( + (event) => { + const newValue = parseFloat(event.target.value) + setInternalValue(newValue) + if (onChange) { + onChange(event, newValue) + } + }, + [onChange], + ) + + const handleInteractionEnd = React.useCallback( + (event) => { + if (onChangeCommitted) { + onChangeCommitted(event, parseFloat(event.target.value)) + } + }, + [onChangeCommitted], + ) + + const handleMouseMove = React.useCallback( + (event) => { + if (!sliderRef.current) { + return null + } + + const rect = sliderRef.current.getBoundingClientRect() + const offsetX = event.clientX - rect.left + const width = sliderRef.current.offsetWidth + + let hoverValue = min + (offsetX / width) * (max - min) + let positionPercentage = (offsetX / width) * 100 + + positionPercentage = Math.max(0, Math.min(100, positionPercentage)) + + hoverValue = Math.max(min, Math.min(max, hoverValue)) + hoverValue = Math.round(hoverValue / step) * step + + if (Number.isNaN(hoverValue)) { + hoverValue = min + } + + if (typeof valueLabelFormat === "function") { + setTooltipValue(valueLabelFormat(hoverValue)) + } else { + setTooltipValue(hoverValue.toFixed(0)) + } + + setTooltipPosition(positionPercentage) + }, + [min, max, step], + ) + + const handleMouseEnter = () => { + if (!disabled) { + setTooltipVisible(true) + } + } + + const handleMouseLeave = () => { + setTooltipVisible(false) + } + + const progressPercentage = + max > min ? ((internalValue - min) / (max - min)) * 100 : 0 + + return ( +
+
+ + + + + + {tooltipVisible && !disabled && ( +
+ {tooltipValue} +
+ )} +
+ ) +} + +export default Slider diff --git a/packages/app/src/components/Player/ToolBarPlayer/index.jsx b/packages/app/src/components/Player/index.jsx similarity index 97% rename from packages/app/src/components/Player/ToolBarPlayer/index.jsx rename to packages/app/src/components/Player/index.jsx index 14f671ac..887635f7 100755 --- a/packages/app/src/components/Player/ToolBarPlayer/index.jsx +++ b/packages/app/src/components/Player/index.jsx @@ -34,7 +34,11 @@ const Indicators = ({ track, playerState }) => { if (track.metadata) { if (track.metadata.lossless) { - indicators.push() + indicators.push( + + + , + ) } } diff --git a/packages/app/src/components/Player/ToolBarPlayer/index.less b/packages/app/src/components/Player/index.less similarity index 95% rename from packages/app/src/components/Player/ToolBarPlayer/index.less rename to packages/app/src/components/Player/index.less index ca367a0b..6a21b0df 100755 --- a/packages/app/src/components/Player/ToolBarPlayer/index.less +++ b/packages/app/src/components/Player/index.less @@ -35,14 +35,6 @@ color: var(--text-color-black); } - .MuiSlider-root { - color: var(--text-color-black); - - .MuiSlider-rail { - color: var(--text-color-black); - } - } - .loadCircle { svg { path { @@ -150,11 +142,9 @@ overflow: hidden; - font-size: 1.5rem; + font-size: 1.4rem; font-weight: 600; - font-family: "Space Grotesk", sans-serif; - transition: all 150ms ease-in-out; color: currentColor; @@ -188,7 +178,7 @@ width: 100%; padding: 10px; - gap: 5px; + gap: 10px; display: flex; flex-direction: column; diff --git a/packages/app/src/components/Skeleton/index.jsx b/packages/app/src/components/Skeleton/index.jsx index 3b720728..bf472f7b 100755 --- a/packages/app/src/components/Skeleton/index.jsx +++ b/packages/app/src/components/Skeleton/index.jsx @@ -1,15 +1,14 @@ import React from "react" import { Skeleton } from "antd" -import { LoadingOutlined } from "@ant-design/icons" import "./index.less" -export default () => { - return
-
- -

Loading...

-
- -
-} \ No newline at end of file +const SkeletonComponent = () => { + return ( +
+ +
+ ) +} + +export default SkeletonComponent diff --git a/packages/app/src/components/Skeleton/index.less b/packages/app/src/components/Skeleton/index.less index 87f3cea4..3d7b173f 100755 --- a/packages/app/src/components/Skeleton/index.less +++ b/packages/app/src/components/Skeleton/index.less @@ -1,4 +1,10 @@ .skeleton { + display: flex; + flex-direction: column; + + width: 100%; + height: 100%; + svg { margin: 0 !important; } @@ -9,14 +15,7 @@ color: var(--background-color-contrast); } - .indicator { - color: var(--background-color-contrast); - display: flex; - flex-direction: row; - align-items: center; - } - background-color: var(--background-color-accent); border-radius: 8px; - padding: 10px; + padding: 20px; } diff --git a/packages/app/src/contexts/WithPlayerContext/index.jsx b/packages/app/src/contexts/WithPlayerContext/index.jsx index cde1e85a..1531b4a4 100755 --- a/packages/app/src/contexts/WithPlayerContext/index.jsx +++ b/packages/app/src/contexts/WithPlayerContext/index.jsx @@ -55,7 +55,7 @@ export class WithPlayerContext extends React.Component { state = app.cores.player.state events = { - "player.state.update": (state) => { + "player.state.update": async (state) => { this.setState(state) }, } diff --git a/packages/app/src/cores/player/classes/AudioBase.js b/packages/app/src/cores/player/classes/AudioBase.js index 1818bfcf..6e9a2ae0 100644 --- a/packages/app/src/cores/player/classes/AudioBase.js +++ b/packages/app/src/cores/player/classes/AudioBase.js @@ -1,4 +1,4 @@ -import { MediaPlayer } from "dashjs" +import { MediaPlayer, Debug } from "dashjs" import PlayerProcessors from "./PlayerProcessors" import AudioPlayerStorage from "../player.storage" @@ -29,6 +29,7 @@ export default class AudioBase { // configure some settings for audio this.audio.crossOrigin = "anonymous" this.audio.preload = "metadata" + this.audio.loop = this.player.state.playback_mode === "repeat" // listen all events for (const [key, value] of Object.entries(this.audioEvents)) { @@ -55,6 +56,9 @@ export default class AudioBase { resetSourceBuffersForTrackSwitch: true, }, }, + // debug: { + // logLevel: Debug.LOG_LEVEL_DEBUG, + // }, }) this.demuxer.initialize(this.audio, null, false) @@ -65,7 +69,10 @@ export default class AudioBase { this.audio.src = null this.audio.currentTime = 0 - this.demuxer.destroy() + if (this.demuxer) { + this.demuxer.destroy() + } + this.createDemuxer() } diff --git a/packages/app/src/cores/player/classes/PlayerUI.js b/packages/app/src/cores/player/classes/PlayerUI.js index 67c24d0f..aac45ceb 100644 --- a/packages/app/src/cores/player/classes/PlayerUI.js +++ b/packages/app/src/cores/player/classes/PlayerUI.js @@ -1,4 +1,4 @@ -import ToolBarPlayer from "@components/Player/ToolBarPlayer" +import Player from "@components/Player" export default class PlayerUI { constructor(player) { @@ -21,7 +21,7 @@ export default class PlayerUI { if (app.layout.tools_bar) { this.currentDomWindow = app.layout.tools_bar.attachRender( "mediaPlayer", - ToolBarPlayer, + Player, undefined, { position: "bottom", diff --git a/packages/app/src/cores/player/classes/TrackInstance.js b/packages/app/src/cores/player/classes/TrackInstance.js index cae48993..d8069ba6 100644 --- a/packages/app/src/cores/player/classes/TrackInstance.js +++ b/packages/app/src/cores/player/classes/TrackInstance.js @@ -27,34 +27,63 @@ export default class TrackInstance { play = async (params = {}) => { const startTime = performance.now() - if (!this.manifest.source.endsWith(".mpd")) { - this.player.base.demuxer.destroy() - this.player.base.audio.src = this.manifest.source + const isMpd = this.manifest.source.endsWith(".mpd") + const audioEl = this.player.base.audio + + if (!isMpd) { + // if a demuxer exists (from a previous MPD track), destroy it + if (this.player.base.demuxer) { + this.player.base.demuxer.destroy() + this.player.base.demuxer = null + } + + // set the audio source directly + if (audioEl.src !== this.manifest.source) { + audioEl.src = this.manifest.source + audioEl.load() // important to apply the new src and stop previous playback + } } else { + // ensure the direct 'src' attribute is removed if it was set + const currentSrc = audioEl.getAttribute("src") + + if (currentSrc && !currentSrc.startsWith("blob:")) { + // blob: indicates MSE is likely already in use + audioEl.removeAttribute("src") + audioEl.load() // tell the element to update its state after src removal + } + + // ensure a demuxer instance exists if (!this.player.base.demuxer) { this.player.base.createDemuxer() } - await this.player.base.demuxer.attachSource( - `${this.manifest.source}?t=${Date.now()}`, + // attach the mpd source to the demuxer + await this.player.base.demuxer.attachSource(this.manifest.source) + } + + // reset audio properties + audioEl.currentTime = params.time ?? 0 + audioEl.volume = 1 + + if (this.player.base.processors && this.player.base.processors.gain) { + this.player.base.processors.gain.set(this.player.state.volume) + } + + if (audioEl.paused) { + try { + await audioEl.play() + } catch (error) { + console.error("[INSTANCE] Error during audio.play():", error) + } + } else { + console.log( + "[INSTANCE] Audio is already playing or will start shortly.", ) } - this.player.base.audio.currentTime = params.time ?? 0 + this._loadMs = performance.now() - startTime - if (this.player.base.audio.paused) { - await this.player.base.audio.play() - } - - // reset audio volume and gain - this.player.base.audio.volume = 1 - this.player.base.processors.gain.set(this.player.state.volume) - - const endTime = performance.now() - - this._loadMs = endTime - startTime - - console.log(`[INSTANCE] Playing >`, this) + console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this) } pause = async () => { @@ -68,64 +97,4 @@ export default class TrackInstance { this.player.base.audio.play() } - - // resolveManifest = async () => { - // if (typeof this.manifest === "string") { - // this.manifest = { - // src: this.manifest, - // } - // } - - // this.manifest = new TrackManifest(this.manifest, { - // serviceProviders: this.player.serviceProviders, - // }) - - // if (this.manifest.service) { - // if (!this.player.serviceProviders.has(this.manifest.service)) { - // throw new Error( - // `Service ${this.manifest.service} is not supported`, - // ) - // } - - // // try to resolve source file - // if (!this.manifest.source) { - // console.log("Resolving manifest cause no source defined") - - // this.manifest = await this.player.serviceProviders.resolve( - // this.manifest.service, - // this.manifest, - // ) - - // console.log("Manifest resolved", this.manifest) - // } - // } - - // if (!this.manifest.source) { - // throw new Error("Manifest `source` is required") - // } - - // // set empty metadata if not provided - // if (!this.manifest.metadata) { - // this.manifest.metadata = {} - // } - - // // auto name if a title is not provided - // if (!this.manifest.metadata.title) { - // this.manifest.metadata.title = this.manifest.source.split("/").pop() - // } - - // // process overrides - // const override = await this.manifest.serviceOperations.fetchOverride() - - // if (override) { - // console.log( - // `Override found for track ${this.manifest._id}`, - // override, - // ) - - // this.manifest.overrides = override - // } - - // return this.manifest - // } } diff --git a/packages/app/src/cores/player/classes/TrackManifest.js b/packages/app/src/cores/player/classes/TrackManifest.js index 446f650d..a2dd06b1 100644 --- a/packages/app/src/cores/player/classes/TrackManifest.js +++ b/packages/app/src/cores/player/classes/TrackManifest.js @@ -97,18 +97,6 @@ export default class TrackManifest { } serviceOperations = { - fetchLikeStatus: async () => { - if (!this._id) { - return null - } - - return await this.ctx.serviceProviders.operation( - "isItemFavourited", - this.service, - this, - "track", - ) - }, fetchLyrics: async () => { if (!this._id) { return null @@ -140,19 +128,31 @@ export default class TrackManifest { this, ) }, - toggleItemFavourite: async (to) => { + toggleItemFavorite: async (to) => { if (!this._id) { return null } return await this.ctx.serviceProviders.operation( - "toggleItemFavourite", + "toggleItemFavorite", this.service, this, - "track", + "tracks", to, ) }, + isItemFavorited: async () => { + if (!this._id) { + return null + } + + return await this.ctx.serviceProviders.operation( + "isItemFavorited", + this.service, + this, + "tracks", + ) + }, } toSeriableObject = () => { diff --git a/packages/app/src/cores/player/player.core.js b/packages/app/src/cores/player/player.core.js index 2d0c260f..484d6c8f 100755 --- a/packages/app/src/cores/player/player.core.js +++ b/packages/app/src/cores/player/player.core.js @@ -119,6 +119,7 @@ export default class Player extends Core { return this.queue.currentItem } + // TODO: Improve performance for large playlists async start(manifest, { time, startIndex = 0, radioId } = {}) { this.ui.attachPlayerComponent() @@ -150,6 +151,10 @@ export default class Player extends Core { playlist = await this.serviceProviders.resolveMany(playlist) } + if (playlist.some((item) => !item.source)) { + playlist = await this.serviceProviders.resolveMany(playlist) + } + for await (let [index, _manifest] of playlist.entries()) { let instance = new TrackInstance(_manifest, this) @@ -176,12 +181,12 @@ export default class Player extends Core { // similar to player.start, but add to the queue // if next is true, it will add to the queue to the top of the queue - async addToQueue(manifest, { next = false }) { + async addToQueue(manifest, { next = false } = {}) { if (typeof manifest === "string") { manifest = await this.serviceProviders.resolve(manifest) } - let instance = await this.createInstance(manifest) + let instance = new TrackInstance(manifest, this) this.queue.add(instance, next === true ? "start" : "end") diff --git a/packages/app/src/cores/player/providers/comtymusic.js b/packages/app/src/cores/player/providers/comtymusic.js index bec8e352..91e7c074 100644 --- a/packages/app/src/cores/player/providers/comtymusic.js +++ b/packages/app/src/cores/player/providers/comtymusic.js @@ -1,42 +1,42 @@ import MusicModel from "comty.js/models/music" export default class ComtyMusicServiceInterface { - static id = "default" + static id = "default" - resolve = async (manifest) => { - if (typeof manifest === "string" && manifest.startsWith("https://")) { - return { - source: manifest.source, - service: "default", - } - } + resolve = async (manifest) => { + if (typeof manifest === "string" && manifest.startsWith("https://")) { + return { + source: manifest.source, + service: "default", + } + } - if (typeof manifest === "string") { - manifest = { - _id: manifest, - service: ComtyMusicServiceInterface.id, - } - } + if (typeof manifest === "string") { + manifest = { + _id: manifest, + service: ComtyMusicServiceInterface.id, + } + } - const track = await MusicModel.getTrackData(manifest._id) + const track = await MusicModel.getTrackData(manifest._id) - return track - } + return track + } - resolveLyrics = async (manifest, options) => { - return await MusicModel.getTrackLyrics(manifest._id, options) - } + resolveLyrics = async (manifest, options) => { + return await MusicModel.getTrackLyrics(manifest._id, options) + } - resolveOverride = async (manifest) => { - // not supported yet for comty music service - return {} - } + resolveOverride = async (manifest) => { + // not supported yet for comty music service + return {} + } - isItemFavourited = async (manifest, itemType) => { - return await MusicModel.isItemFavourited(itemType, manifest._id) - } + isItemFavorited = async (manifest, itemType) => { + return await MusicModel.isItemFavorited(itemType, manifest._id) + } - toggleItemFavourite = async (manifest, itemType, to) => { - return await MusicModel.toggleItemFavourite(itemType, manifest._id, to) - } -} \ No newline at end of file + toggleItemFavorite = async (manifest, itemType, to) => { + return await MusicModel.toggleItemFavorite(itemType, manifest._id, to) + } +} diff --git a/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx b/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx index 4b4f3563..a15ddca1 100755 --- a/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx +++ b/packages/app/src/layouts/components/@mobile/bottomBar/index.jsx @@ -5,7 +5,11 @@ import { motion, AnimatePresence } from "motion/react" import { Icons, createIconRender } from "@components/Icons" -import { WithPlayerContext, Context } from "@contexts/WithPlayerContext" +import { + WithPlayerContext, + Context, + usePlayerStateContext, +} from "@contexts/WithPlayerContext" import { QuickNavMenuItems, @@ -36,33 +40,66 @@ const tourSteps = [ const openPlayerView = () => { app.layout.draggable.open("player", PlayerView) } + const openCreator = () => { app.layout.draggable.open("creator", CreatorView) } const PlayerButton = (props) => { + const [currentManifest, setCurrentManifest] = React.useState(null) + const [coverAnalyzed, setCoverAnalyzed] = React.useState(null) + + const [player] = usePlayerStateContext((state) => { + setCurrentManifest((prev) => { + if (!state.track_manifest) { + return null + } + + if (prev?._id !== state.track_manifest?._id) { + return state.track_manifest + } + + return prev + }) + }) + React.useEffect(() => { - openPlayerView() - }, []) + if (currentManifest) { + const track = app.cores.player.track() + + if (!app.layout.draggable.exists("player")) { + openPlayerView() + } + + if (track.manifest?.analyzeCoverColor) { + track.manifest + .analyzeCoverColor() + .then((analysis) => { + setCoverAnalyzed(analysis) + }) + .catch((err) => { + console.error(err) + }) + } + } + }, [currentManifest]) + + const isPlaying = player?.playback_status === "playing" ?? false return (
- {props.playback === "playing" ? ( - - ) : ( - - )} + {isPlaying ? : }
) } @@ -385,18 +422,7 @@ export class BottomBar extends React.Component { {this.context.track_manifest && (
- +
)} diff --git a/packages/app/src/layouts/components/draggableDrawer/index.jsx b/packages/app/src/layouts/components/draggableDrawer/index.jsx index 0dea3bd0..54a93298 100644 --- a/packages/app/src/layouts/components/draggableDrawer/index.jsx +++ b/packages/app/src/layouts/components/draggableDrawer/index.jsx @@ -6,172 +6,168 @@ import ActionsMenu from "../@mobile/actionsMenu" import "./index.less" export class DraggableDrawerController extends React.Component { - constructor(props) { - super(props) + constructor(props) { + super(props) - this.interface = { - open: this.open, - destroy: this.destroy, - actions: this.actions, - } + this.interface = { + open: this.open, + destroy: this.destroy, + actions: this.actions, + exists: this.exists, + } - this.state = { - drawers: [], - } - } + this.state = { + drawers: [], + } + } - componentDidMount() { - app.layout.draggable = this.interface - } + componentDidMount() { + app.layout.draggable = this.interface + } - async handleDrawerOnClosed(drawer) { - if (!drawer) { - return false - } + async handleDrawerOnClosed(drawer) { + if (!drawer) { + return false + } - if (typeof drawer.options.onClosed === "function") { - await drawer.options.onClosed() - } + if (typeof drawer.options.onClosed === "function") { + await drawer.options.onClosed() + } - this.destroy(drawer.id) - } + this.destroy(drawer.id) + } - actions = (data) => { - const win = this.open("actions-menu", ActionsMenu, { - componentProps: { - ...data, - } - }) + actions = (data) => { + const win = this.open("actions-menu", ActionsMenu, { + componentProps: { + ...data, + }, + }) - return win - } + return win + } - open = (id, render, options = {}) => { - let drawerObj = { - id: id, - render: render, - options: options - } + open = (id, render, options = {}) => { + let drawerObj = { + id: id, + render: render, + options: options, + } - const win = app.cores.window_mng.render( - id, - this.handleDrawerOnClosed(drawerObj)} - > - { - React.createElement(render, { - ...options.componentProps, - }) - } - - ) + const win = app.cores.window_mng.render( + id, + this.handleDrawerOnClosed(drawerObj)} + > + {React.createElement(render, { + ...options.componentProps, + })} + , + ) - drawerObj.winId = win.id + drawerObj.winId = win.id - this.setState({ - drawers: [...this.state.drawers, drawerObj], - }) + this.setState({ + drawers: [...this.state.drawers, drawerObj], + }) - return { - ...drawerObj, - close: () => this.destroy(id), - } - } + return { + ...drawerObj, + close: () => this.destroy(id), + } + } - destroy = (id) => { - const drawerIndex = this.state.drawers.findIndex((drawer) => drawer.id === id) + destroy = (id) => { + const drawerIndex = this.state.drawers.findIndex( + (drawer) => drawer.id === id, + ) - if (drawerIndex === -1) { - console.error(`Drawer [${id}] not found`) - return false - } + if (drawerIndex === -1) { + console.error(`Drawer [${id}] not found`) + return false + } - const drawer = this.state.drawers[drawerIndex] + const drawer = this.state.drawers[drawerIndex] - if (drawer.locked === true) { - console.error(`Drawer [${drawer.id}] is locked`) - return false - } + if (drawer.locked === true) { + console.error(`Drawer [${drawer.id}] is locked`) + return false + } - const drawers = this.state.drawers + const drawers = this.state.drawers - drawers.splice(drawerIndex, 1) + drawers.splice(drawerIndex, 1) - this.setState({ drawers: drawers }) + this.setState({ drawers: drawers }) - app.cores.window_mng.close(drawer.id ?? id) - } + app.cores.window_mng.close(drawer.id ?? id) + } - /** - * This lifecycle method is called after the component has been updated. - * It will toggle the root scale effect based on the amount of drawers. - * If there are no drawers, the root scale effect is disabled. - * If there are one or more drawers, the root scale effect is enabled. - */ - componentDidUpdate() { - if (this.state.drawers.length === 0) { - app.layout.toggleRootScaleEffect(false) - } else { - app.layout.toggleRootScaleEffect(true) - } - } + exists = (id) => { + return this.state.drawers.findIndex((drawer) => drawer.id === id) !== -1 + } - render() { - return null - } + /** + * This lifecycle method is called after the component has been updated. + * It will toggle the root scale effect based on the amount of drawers. + * If there are no drawers, the root scale effect is disabled. + * If there are one or more drawers, the root scale effect is enabled. + */ + componentDidUpdate() { + if (this.state.drawers.length === 0) { + app.layout.toggleRootScaleEffect(false) + } else { + app.layout.toggleRootScaleEffect(true) + } + } + + render() { + return null + } } export const DraggableDrawer = (props) => { - const [isOpen, setIsOpen] = React.useState(true) + const [isOpen, setIsOpen] = React.useState(true) - async function handleOnOpenChanged(to) { - if (to === true) { - return to - } + async function handleOnOpenChanged(to) { + if (to === true) { + return to + } - setIsOpen(false) + setIsOpen(false) - if (typeof props.onClosed === "function") { - setTimeout(() => { - props.onClosed() - }, 350) - } + if (typeof props.onClosed === "function") { + setTimeout(() => { + props.onClosed() + }, 350) + } - return to - } + return to + } - return - - + return ( + + + - { - setIsOpen(false) - }} - > - + { + setIsOpen(false) + }} + > + - - {props.options?.title ?? "Drawer Title"} - + + {props.options?.title ?? "Drawer Title"} + - { - React.cloneElement(props.children, { - close: () => setIsOpen(false), - }) - } - - - -} \ No newline at end of file + {React.cloneElement(props.children, { + close: () => setIsOpen(false), + })} + + + + ) +} diff --git a/packages/app/src/pages/_debug/loqui/index.jsx b/packages/app/src/pages/_debug/loqui/index.jsx new file mode 100644 index 00000000..913d974a --- /dev/null +++ b/packages/app/src/pages/_debug/loqui/index.jsx @@ -0,0 +1,49 @@ +import React from "react" + +const defaultURL = "ws://localhost:19236" + +function useLoquiWs() { + const [socket, setSocket] = React.useState(null) + + function create() { + const s = new WebSocket(defaultURL) + + s.addEventListener("open", (event) => { + console.log("WebSocket connection opened") + }) + + s.addEventListener("close", (event) => { + console.log("WebSocket connection closed") + }) + + s.addEventListener("error", (event) => { + console.log("WebSocket error", event) + }) + + s.addEventListener("message", (event) => { + console.log("Message from server ", event.data) + }) + + setSocket(s) + } + + React.useEffect(() => { + create() + + return () => { + if (socket) { + socket.close() + } + } + }, []) + + return [socket] +} + +const Loqui = () => { + const [socket] = useLoquiWs() + + return
{defaultURL}
+} + +export default Loqui diff --git a/packages/app/src/pages/account/tabs/music/index.jsx b/packages/app/src/pages/account/tabs/music/index.jsx index e1603c22..10666047 100755 --- a/packages/app/src/pages/account/tabs/music/index.jsx +++ b/packages/app/src/pages/account/tabs/music/index.jsx @@ -12,7 +12,7 @@ export default (props) => { const user_id = props.state.user._id const [L_Releases, R_Releases, E_Releases, M_Releases] = - app.cores.api.useRequest(MusicModel.getReleases, { + app.cores.api.useRequest(MusicModel.getAllReleases, { user_id: user_id, }) diff --git a/packages/app/src/pages/lyrics/components/controller/index.jsx b/packages/app/src/pages/lyrics/components/controller/index.jsx index 116c287d..238b1ec7 100644 --- a/packages/app/src/pages/lyrics/components/controller/index.jsx +++ b/packages/app/src/pages/lyrics/components/controller/index.jsx @@ -7,6 +7,7 @@ import useHideOnMouseStop from "@hooks/useHideOnMouseStop" import { Icons } from "@components/Icons" import Controls from "@components/Player/Controls" +import SeekBar from "@components/Player/SeekBar" import LiveInfo from "@components/Player/LiveInfo" import { usePlayerStateContext } from "@contexts/WithPlayerContext" @@ -130,9 +131,11 @@ const PlayerController = React.forwardRef((props, ref) => { )}
-
- {playerState.track_manifest?.artistStr} -
+ {playerState.track_manifest?.artist && ( +
+ {playerState.track_manifest?.artist} +
+ )} {playerState.live && ( @@ -141,40 +144,7 @@ const PlayerController = React.forwardRef((props, ref) => { - {!playerState.live && ( -
-
{ - setDraggingTime(true) - }} - onMouseUp={(e) => { - const rect = - e.currentTarget.getBoundingClientRect() - const seekTime = - (trackDuration * (e.clientX - rect.left)) / - rect.width - - onDragEnd(seekTime) - }} - onMouseMove={(e) => { - const rect = - e.currentTarget.getBoundingClientRect() - const atWidth = - ((e.clientX - rect.left) / rect.width) * 100 - - setCurrentDragWidth(atWidth) - }} - > -
-
-
- )} + {!playerState.live && }
{playerState.track_manifest?.metadata?.lossless && ( diff --git a/packages/app/src/pages/lyrics/components/video/index.jsx b/packages/app/src/pages/lyrics/components/video/index.jsx index 253b4fac..f5e0ebc0 100644 --- a/packages/app/src/pages/lyrics/components/video/index.jsx +++ b/packages/app/src/pages/lyrics/components/video/index.jsx @@ -1,179 +1,233 @@ import React from "react" import HLS from "hls.js" - import classnames from "classnames" + import { usePlayerStateContext } from "@contexts/WithPlayerContext" const maxLatencyInMs = 55 const LyricsVideo = React.forwardRef((props, videoRef) => { const [playerState] = usePlayerStateContext() - const { lyrics } = props const [initialLoading, setInitialLoading] = React.useState(true) - const [syncInterval, setSyncInterval] = React.useState(null) const [syncingVideo, setSyncingVideo] = React.useState(false) const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0) + const isDebugEnabled = React.useMemo( + () => app.cores.settings.is("_debug", true), + [], + ) + const hls = React.useRef(new HLS()) + const syncIntervalRef = React.useRef(null) - async function seekVideoToSyncAudio() { - if (!lyrics) { - return null + const stopSyncInterval = React.useCallback(() => { + setSyncingVideo(false) + if (syncIntervalRef.current) { + clearInterval(syncIntervalRef.current) + syncIntervalRef.current = null } + }, [setSyncingVideo]) + const seekVideoToSyncAudio = React.useCallback(async () => { if ( + !lyrics || !lyrics.video_source || - typeof lyrics.sync_audio_at_ms === "undefined" + typeof lyrics.sync_audio_at_ms === "undefined" || + !videoRef.current ) { return null } - const currentTrackTime = app.cores.player.controls.seek() - + const currentTrackTime = window.app.cores.player.controls.seek() setSyncingVideo(true) let newTime = currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000 - - // dec some ms to ensure the video seeks correctly newTime -= 5 / 1000 videoRef.current.currentTime = newTime - } + }, [lyrics, videoRef, setSyncingVideo]) - async function syncPlayback() { - // if something is wrong, stop syncing - if ( - videoRef.current === null || - !lyrics || - !lyrics.video_source || - typeof lyrics.sync_audio_at_ms === "undefined" || - playerState.playback_status !== "playing" - ) { - return stopSyncInterval() + const syncPlayback = React.useCallback( + async (override = false) => { + if ( + !videoRef.current || + !lyrics || + !lyrics.video_source || + typeof lyrics.sync_audio_at_ms === "undefined" + ) { + stopSyncInterval() + return + } + + if (playerState.playback_status !== "playing" && !override) { + stopSyncInterval() + return + } + + const currentTrackTime = window.app.cores.player.controls.seek() + const currentVideoTime = + videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000 + const maxOffset = maxLatencyInMs / 1000 + const currentVideoTimeDiff = Math.abs( + currentVideoTime - currentTrackTime, + ) + + setCurrentVideoLatency(currentVideoTimeDiff) + + if (syncingVideo === true) { + return + } + + if (currentVideoTimeDiff > maxOffset) { + seekVideoToSyncAudio() + } + }, + [ + videoRef, + lyrics, + playerState.playback_status, + setCurrentVideoLatency, + syncingVideo, + seekVideoToSyncAudio, + stopSyncInterval, + ], + ) + + const startSyncInterval = React.useCallback(() => { + if (syncIntervalRef.current) { + clearInterval(syncIntervalRef.current) } + syncIntervalRef.current = setInterval(syncPlayback, 300) + }, [syncPlayback]) - const currentTrackTime = app.cores.player.controls.seek() - const currentVideoTime = - videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000 - - //console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`) - - const maxOffset = maxLatencyInMs / 1000 - const currentVideoTimeDiff = Math.abs( - currentVideoTime - currentTrackTime, - ) - - setCurrentVideoLatency(currentVideoTimeDiff) - - if (syncingVideo === true) { - return false - } - - if (currentVideoTimeDiff > maxOffset) { - seekVideoToSyncAudio() - } - } - - function startSyncInterval() { - setSyncInterval(setInterval(syncPlayback, 300)) - } - - function stopSyncInterval() { - setSyncingVideo(false) - setSyncInterval(null) - clearInterval(syncInterval) - } - - //* handle when player is loading React.useEffect(() => { + setCurrentVideoLatency(0) + const videoElement = videoRef.current + if (!videoElement) return + + if (lyrics && lyrics.video_source) { + console.log("VIDEO:: Loading video source >", lyrics.video_source) + + if ( + hls.current.media === videoElement && + (lyrics.video_source.endsWith(".mp4") || !lyrics.video_source) + ) { + hls.current.stopLoad() + } + + if (lyrics.video_source.endsWith(".mp4")) { + if (hls.current.media === videoElement) { + hls.current.detachMedia() + } + videoElement.src = lyrics.video_source + } else { + if (HLS.isSupported()) { + if (hls.current.media !== videoElement) { + hls.current.attachMedia(videoElement) + } + hls.current.loadSource(lyrics.video_source) + } else if ( + videoElement.canPlayType("application/vnd.apple.mpegurl") + ) { + videoElement.src = lyrics.video_source + } + } + + if (typeof lyrics.sync_audio_at_ms !== "undefined") { + videoElement.loop = false + syncPlayback(true) + } else { + videoElement.loop = true + videoElement.currentTime = 0 + } + } else { + videoElement.src = "" + if (hls.current) { + hls.current.stopLoad() + if (hls.current.media) { + hls.current.detachMedia() + } + } + } + setInitialLoading(false) + }, [lyrics, videoRef, hls, setCurrentVideoLatency, setInitialLoading]) + + React.useEffect(() => { + stopSyncInterval() + + if (initialLoading || !videoRef.current) { + return + } + + const videoElement = videoRef.current + const canPlayVideo = lyrics && lyrics.video_source + + if (!canPlayVideo) { + videoElement.pause() + return + } + if ( - lyrics?.video_source && playerState.loading === true && playerState.playback_status === "playing" ) { - videoRef.current.pause() + videoElement.pause() + return } - if ( - lyrics?.video_source && - playerState.loading === false && - playerState.playback_status === "playing" - ) { - videoRef.current.play() - } - }, [playerState.loading]) + const shouldSync = typeof lyrics.sync_audio_at_ms !== "undefined" - //* Handle when playback status change - React.useEffect(() => { - if (initialLoading === false) { - console.log( - `VIDEO:: Playback status changed to ${playerState.playback_status}`, - ) - - if (lyrics && lyrics.video_source) { - if (playerState.playback_status === "playing") { - videoRef.current.play() - startSyncInterval() - } else { - videoRef.current.pause() - stopSyncInterval() - } + if (playerState.playback_status === "playing") { + videoElement + .play() + .catch((error) => + console.error("VIDEO:: Error playing video:", error), + ) + if (shouldSync) { + startSyncInterval() } + } else { + videoElement.pause() } - }, [playerState.playback_status]) - - //* Handle when lyrics object change - React.useEffect(() => { - setCurrentVideoLatency(0) - stopSyncInterval() - - if (lyrics) { - if (lyrics.video_source) { - console.log("Loading video source >", lyrics.video_source) - - if (lyrics.video_source.endsWith(".mp4")) { - videoRef.current.src = lyrics.video_source - } else { - hls.current.loadSource(lyrics.video_source) - } - - if (typeof lyrics.sync_audio_at_ms !== "undefined") { - videoRef.current.loop = false - videoRef.current.currentTime = - lyrics.sync_audio_at_ms / 1000 - - startSyncInterval() - } else { - videoRef.current.loop = true - videoRef.current.currentTime = 0 - } - - if (playerState.playback_status === "playing") { - videoRef.current.play() - } - } - } - - setInitialLoading(false) - }, [lyrics]) + }, [ + lyrics, + playerState.playback_status, + playerState.loading, + initialLoading, + videoRef, + startSyncInterval, + stopSyncInterval, + ]) React.useEffect(() => { - videoRef.current.addEventListener("seeked", (event) => { + const videoElement = videoRef.current + const hlsInstance = hls.current + + const handleSeeked = () => { setSyncingVideo(false) - }) + } - hls.current.attachMedia(videoRef.current) + if (videoElement) { + videoElement.addEventListener("seeked", handleSeeked) + } return () => { stopSyncInterval() + + if (videoElement) { + videoElement.removeEventListener("seeked", handleSeeked) + } + if (hlsInstance) { + hlsInstance.destroy() + } } - }, []) + }, [videoRef, hls, stopSyncInterval, setSyncingVideo]) return ( <> - {props.lyrics?.sync_audio_at && ( + {isDebugEnabled && (

Maximun latency

@@ -195,6 +249,7 @@ const LyricsVideo = React.forwardRef((props, videoRef) => { controls={false} muted preload="auto" + playsInline /> ) diff --git a/packages/app/src/pages/lyrics/index.less b/packages/app/src/pages/lyrics/index.less index 0e03ac83..07bd1f0b 100644 --- a/packages/app/src/pages/lyrics/index.less +++ b/packages/app/src/pages/lyrics/index.less @@ -1,329 +1,298 @@ .lyrics { - position: relative; + position: relative; - z-index: 100; + z-index: 100; - width: 100vw; - height: 100vh; + width: 100vw; + height: 100vh; - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - &.stopped { - .lyrics-video { - filter: blur(6px); - } - } + &.stopped { + .lyrics-video { + filter: blur(6px); + } + } - .lyrics-background-color { - position: absolute; + .lyrics-background-color { + position: absolute; - z-index: 100; + z-index: 100; - width: 100%; - height: 100%; + width: 100%; + height: 100%; - background: - linear-gradient(0deg, rgba(var(--dominant-color), 1), rgba(0, 0, 0, 0)), - url("data:image/svg+xml,%3Csvg viewBox='0 0 284 284' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='7.59' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); - } + background: + linear-gradient(0deg, rgb(var(--dominant-color)), rgba(0, 0, 0, 0)), + url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0' numOctaves='10' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); + } - .lyrics-background-wrapper { - z-index: 110; - position: absolute; + .lyrics-background-wrapper { + z-index: 110; + position: absolute; - top: 0; - left: 0; + top: 0; + left: 0; - width: 100vw; - height: 100vh; + width: 100vw; + height: 100vh; - backdrop-filter: blur(10px); + backdrop-filter: blur(10px); - .lyrics-background-cover { - position: relative; + .lyrics-background-cover { + position: relative; - z-index: 110; + z-index: 110; - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - align-items: center; - justify-content: center; + align-items: center; + justify-content: center; - width: 100%; - height: 100%; + width: 100%; + height: 100%; - img { - width: 40vw; - height: 40vw; + img { + width: 40vw; + height: 40vw; - object-fit: cover; + object-fit: cover; - border-radius: 24px; - } - } - } + border-radius: 24px; + } + } + } - .lyrics-video { - z-index: 120; - position: absolute; + .lyrics-video { + z-index: 120; + position: absolute; - top: 0; - left: 0; + top: 0; + left: 0; - width: 100vw; - height: 100vh; + width: 100vw; + height: 100vh; - object-fit: cover; + object-fit: cover; - transition: all 150ms ease-out; + transition: all 150ms ease-out; - &.hidden { - opacity: 0; - } - } + &.hidden { + opacity: 0; + } + } - .lyrics-text-wrapper { - z-index: 200; - position: fixed; + .lyrics-text-wrapper { + z-index: 200; + position: fixed; - bottom: 0; - left: 0; + bottom: 0; + left: 0; - padding: 60px; + padding: 60px; - .lyrics-text { - display: flex; - flex-direction: column; + .lyrics-text { + display: flex; + flex-direction: column; - width: 600px; - height: 200px; + width: 600px; + height: 200px; - padding: 20px; - gap: 30px; + padding: 20px; + gap: 30px; - overflow: hidden; + overflow: hidden; - background-color: rgba(var(--background-color-accent-values), 0.6); - border-radius: 12px; + background-color: rgba(var(--background-color-accent-values), 0.6); + border-radius: 12px; - backdrop-filter: blur(5px); - -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); - .line { - font-size: 2rem; + .line { + font-size: 2rem; - opacity: 0.1; + opacity: 0.1; - margin: 0; + margin: 0; - &.current { - opacity: 1; - } - } - } - } + &.current { + opacity: 1; + } + } + } + } - .lyrics-player-controller-wrapper { - position: fixed; - z-index: 210; + .lyrics-player-controller-wrapper { + position: fixed; + z-index: 210; - bottom: 0; - right: 0; + bottom: 0; + right: 0; - padding: 60px; + padding: 60px; - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - &.hidden { - opacity: 0; - } + &.hidden { + opacity: 0; + } - .lyrics-player-controller { - position: relative; + .lyrics-player-controller { + position: relative; - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - align-items: center; + align-items: center; - gap: 10px; + gap: 10px; - width: 300px; + width: 300px; - padding: 30px; + padding: 20px; - border-radius: 12px; + border-radius: 12px; - backdrop-filter: blur(5px); - -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); - background-color: rgba(var(--background-color-accent-values), 0.8); + background-color: rgba(var(--background-color-accent-values), 0.8); - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - &:hover { - gap: 20px; + &:hover { + gap: 20px; - .player-controls { - opacity: 1; - height: 30px; - } + .player-controls { + opacity: 1; + height: 30px; + } - .lyrics-player-controller-tags { - opacity: 1; - height: 10px; - } - } + .lyrics-player-controller-tags { + opacity: 1; + height: 10px; + } + } - .lyrics-player-controller-info { - display: flex; - flex-direction: column; + .lyrics-player-controller-info { + display: flex; + flex-direction: column; - width: 100%; + width: 100%; - gap: 10px; + gap: 5px; - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - .lyrics-player-controller-info-title { - font-size: 1.5rem; - font-weight: 600; + .lyrics-player-controller-info-title { + font-size: 1.4rem; + font-weight: 600; - width: 100%; + width: 100%; - color: var(--background-color-contrast); + color: var(--background-color-contrast); - h4 { - margin: 0; - } + h4 { + margin: 0; + } - .lyrics-player-controller-title-text { - transition: all 150ms ease-in-out; + .lyrics-player-controller-title-text { + transition: all 150ms ease-in-out; - width: 90%; + width: 90%; - overflow: hidden; + overflow: hidden; - // do not wrap text - white-space: nowrap; + // do not wrap text + white-space: nowrap; - &.overflown { - opacity: 0; - height: 0px; - } - } - } + &.overflown { + opacity: 0; + height: 0px; + } + } + } - .lyrics-player-controller-info-details { - display: flex; - flex-direction: row; + .lyrics-player-controller-info-details { + display: flex; + flex-direction: row; - align-items: center; + align-items: center; - gap: 7px; + gap: 7px; - font-size: 0.6rem; + font-size: 0.7rem; - font-weight: 400; + font-weight: 400; - // do not wrap text - white-space: nowrap; + // do not wrap text + white-space: nowrap; - h3 { - margin: 0; - } - } - } + h3 { + margin: 0; + } + } + } - .player-controls { - opacity: 0; - height: 0px; - transition: all 150ms ease-in-out; - } + .player-controls { + opacity: 0; + height: 0px; + transition: all 150ms ease-in-out; + } - .lyrics-player-controller-progress-wrapper { - width: 100%; + .lyrics-player-controller-tags { + display: flex; + flex-direction: row; - .lyrics-player-controller-progress { - display: flex; - flex-direction: row; + align-items: center; - align-items: center; + justify-content: center; - width: 100%; + width: 100%; + height: 0px; - margin: auto; + gap: 10px; - transition: all 150ms ease-in-out; + opacity: 0; - border-radius: 12px; + transition: all 150ms ease-in-out; + } + } + } - background-color: rgba(var(--background-color-accent-values), 0.8); + .videoDebugOverlay { + position: fixed; - &:hover { - .lyrics-player-controller-progress-bar { - height: 10px; - } - } + top: 20px; + right: 20px; - .lyrics-player-controller-progress-bar { - height: 5px; + z-index: 300; - background-color: white; + display: flex; - border-radius: 12px; + flex-direction: column; - transition: all 150ms ease-in-out; - } - } - } + padding: 10px; + border-radius: 12px; - .lyrics-player-controller-tags { - display: flex; - flex-direction: row; + background-color: rgba(var(--background-color-accent-values), 0.8); - align-items: center; + width: 200px; + height: fit-content; - justify-content: center; + transition: all 150ms ease-in-out; - width: 100%; - height: 0px; + &.hidden { + opacity: 0; + } + } +} - gap: 10px; - - opacity: 0; - - transition: all 150ms ease-in-out; - } - } - } - - .videoDebugOverlay { - position: fixed; - - top: 20px; - right: 20px; - - z-index: 300; - - display: flex; - - flex-direction: column; - - padding: 10px; - border-radius: 12px; - - background-color: rgba(var(--background-color-accent-values), 0.8); - - width: 200px; - height: fit-content; - - transition: all 150ms ease-in-out; - - &.hidden { - opacity: 0; - } - } -} \ No newline at end of file +.lyrics-text .line .word.current-word { + /* Styling for the currently active word */ + font-weight: bold; + color: yellow; /* Example highlight */ +} diff --git a/packages/app/src/pages/messages/[to_user_id]/index.jsx b/packages/app/src/pages/messages/[to_user_id]/index.jsx index 66cc86fe..f4c60c08 100644 --- a/packages/app/src/pages/messages/[to_user_id]/index.jsx +++ b/packages/app/src/pages/messages/[to_user_id]/index.jsx @@ -13,173 +13,154 @@ import UserService from "@models/user" import "./index.less" const ChatPage = (props) => { - const { to_user_id } = props.params + const { to_user_id } = props.params - const messagesRef = React.useRef() + const messagesRef = React.useRef() - const [isOnBottomView, setIsOnBottomView] = React.useState(true) - const [currentText, setCurrentText] = React.useState("") + const [isOnBottomView, setIsOnBottomView] = React.useState(true) + const [currentText, setCurrentText] = React.useState("") - const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest( - UserService.data, - { - user_id: to_user_id - } - ) - const [L_History, R_History, E_History, M_History] = app.cores.api.useRequest( - ChatsService.getChatHistory, - to_user_id - ) + const [L_User, R_User, E_User, M_User] = app.cores.api.useRequest( + UserService.data, + { + user_id: to_user_id, + }, + ) + const [L_History, R_History, E_History, M_History] = + app.cores.api.useRequest(ChatsService.getChatHistory, to_user_id) - const { - sendMessage, - messages, - setMessages, - setScroller, - emitTypingEvent, - isRemoteTyping, - } = useChat(to_user_id) + const { + sendMessage, + messages, + setMessages, + setScroller, + emitTypingEvent, + isRemoteTyping, + } = useChat(to_user_id) - console.log(R_User) + console.log(R_User) - async function submitMessage(e) { - e.preventDefault() + async function submitMessage(e) { + e.preventDefault() - if (!currentText) { - return false - } + if (!currentText) { + return false + } - await sendMessage(currentText) + await sendMessage(currentText) - setCurrentText("") - } + setCurrentText("") + } - async function onInputChange(e) { - const value = e.target.value + async function onInputChange(e) { + const value = e.target.value - setCurrentText(value) + setCurrentText(value) - if (value === "") { - emitTypingEvent(false) - } { - emitTypingEvent(true) - } - } + if (value === "") { + emitTypingEvent(false) + } + { + emitTypingEvent(true) + } + } - React.useEffect(() => { - if (R_History) { - setMessages(R_History.list) - // scroll to bottom - messagesRef.current?.scrollTo({ - top: messagesRef.current.scrollHeight, - behavior: "smooth", - }) - } - }, [R_History]) + // React.useEffect(() => { + // if (R_History) { + // setMessages(R_History.list) + // // scroll to bottom + // messagesRef.current?.scrollTo({ + // top: messagesRef.current.scrollHeight, + // behavior: "smooth", + // }) + // } + // }, [R_History]) - React.useEffect(() => { - if (isOnBottomView === true) { - setScroller(messagesRef) - } else { - setScroller(null) - } - }, [isOnBottomView]) + React.useEffect(() => { + if (isOnBottomView === true) { + setScroller(messagesRef) + } else { + setScroller(null) + } + }, [isOnBottomView]) - if (E_History) { - return - } + if (E_History) { + return ( + + ) + } - if (L_History) { - return - } + if (L_History) { + return + } - return
-
- -
+ return ( +
+
+ +
-
- { - messages.length === 0 && - } +
+ {messages.length === 0 && } - { - messages.map((line, index) => { - return
-
-
- - - {line.user.username} - -
+ {messages.map((line, index) => { + return ( +
+
+
+ + {line.user.username} +
-
-

- {line.content} -

-
-
-
- }) - } -
+
+

{line.content}

+
+
+
+ ) + })} +
-
-
- - } - onClick={submitMessage} - /> -
+
+
+ + } + onClick={submitMessage} + /> +
- { - isRemoteTyping && R_User &&
- {R_User.username} is typing... -
- } -
-
+ {isRemoteTyping && R_User && ( +
+ {R_User.username} is typing... +
+ )} +
+
+ ) } -export default ChatPage \ No newline at end of file +export default ChatPage diff --git a/packages/app/src/pages/music/[type]/[id]/index.jsx b/packages/app/src/pages/music/[type]/[id]/index.jsx deleted file mode 100644 index 0789c939..00000000 --- a/packages/app/src/pages/music/[type]/[id]/index.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react" -import * as antd from "antd" - -import PlaylistView from "@components/Music/PlaylistView" - -import MusicService from "@models/music" - -import "./index.less" - -const Item = (props) => { - const { type, id } = props.params - - const [loading, result, error, makeRequest] = app.cores.api.useRequest(MusicService.getReleaseData, id) - - if (error) { - return - } - - if (loading) { - return - } - - return
- -
-} - -export default Item \ No newline at end of file diff --git a/packages/app/src/pages/music/index.jsx b/packages/app/src/pages/music/index.jsx index 8c82f511..ebe9f414 100755 --- a/packages/app/src/pages/music/index.jsx +++ b/packages/app/src/pages/music/index.jsx @@ -3,6 +3,8 @@ import React from "react" import { Icons } from "@components/Icons" import { PagePanelWithNavMenu } from "@components/PagePanels" +import useCenteredContainer from "@hooks/useCenteredContainer" + import Tabs from "./tabs" const NavMenuHeader = ( @@ -13,6 +15,8 @@ const NavMenuHeader = ( ) export default () => { + useCenteredContainer(false) + return ( { + const { type, id } = props.params + + const [loading, result, error, makeRequest] = app.cores.api.useRequest( + MusicService.getReleaseData, + id, + ) + + if (error) { + return ( + + ) + } + + if (loading) { + return + } + + return ( + + ) +} + +export default ListView diff --git a/packages/app/src/pages/music/[type]/[id]/index.less b/packages/app/src/pages/music/list/[id]/index.less similarity index 100% rename from packages/app/src/pages/music/[type]/[id]/index.less rename to packages/app/src/pages/music/list/[id]/index.less diff --git a/packages/app/src/pages/music/tabs/explore/components/FeedItems/index.jsx b/packages/app/src/pages/music/tabs/explore/components/FeedItems/index.jsx new file mode 100644 index 00000000..acefc2ea --- /dev/null +++ b/packages/app/src/pages/music/tabs/explore/components/FeedItems/index.jsx @@ -0,0 +1,129 @@ +import React from "react" +import classnames from "classnames" +import * as antd from "antd" +import { Translation } from "react-i18next" + +import { Icons } from "@components/Icons" + +import Playlist from "@components/Music/Playlist" +import Track from "@components/Music/Track" +import Radio from "@components/Music/Radio" + +import "./index.less" + +const FeedItems = (props) => { + const maxItems = props.itemsPerPage ?? 10 + + const [page, setPage] = React.useState(0) + const [ended, setEnded] = React.useState(false) + + const [loading, result, error, makeRequest] = app.cores.api.useRequest( + props.fetchMethod, + { + limit: maxItems, + page: page, + }, + ) + + const handlePageChange = (newPage) => { + // check if newPage is NaN + if (newPage !== newPage) { + return false + } + + if (typeof makeRequest === "function") { + makeRequest({ + limit: maxItems, + page: newPage, + }) + } + + return newPage + } + + const onClickPrev = () => { + if (page === 0) { + return + } + + setPage((currentPage) => handlePageChange(currentPage - 1)) + } + + const onClickNext = () => { + if (ended) { + return + } + + setPage((currentPage) => handlePageChange(currentPage + 1)) + } + + React.useEffect(() => { + if (result) { + if (typeof result.has_more !== "undefined") { + setEnded(!result.has_more) + } else { + setEnded(result.items.length < maxItems) + } + } + }, [result, maxItems]) + + if (error) { + console.error(error) + + return ( +
+ +
+ ) + } + + return ( +
+
+

+ {props.headerIcon} + {(t) => t(props.headerTitle)} +

+ + {!props.disablePagination && ( +
+ } + onClick={onClickPrev} + disabled={page === 0 || loading} + /> + + } + onClick={onClickNext} + disabled={ended || loading} + /> +
+ )} +
+ +
+ {loading && } + + {!loading && + result?.items?.map((item, index) => { + if (props.type === "radios") { + return + } + + if (props.type === "tracks") { + return + } + + return + })} +
+
+ ) +} + +export default FeedItems diff --git a/packages/app/src/pages/music/tabs/explore/components/FeedItems/index.less b/packages/app/src/pages/music/tabs/explore/components/FeedItems/index.less new file mode 100644 index 00000000..91644b16 --- /dev/null +++ b/packages/app/src/pages/music/tabs/explore/components/FeedItems/index.less @@ -0,0 +1,63 @@ +.music-feed-items { + display: flex; + flex-direction: column; + + width: 100%; + + gap: 10px; + + &.tracks { + .music-feed-items-content { + display: flex; + flex-direction: column; + gap: 10px; + } + } + + &.playlists { + .music-feed-items-content { + display: grid; + grid-template-columns: repeat(2, 1fr); + + gap: 30px; + } + } + + &.radios { + .music-feed-items-content { + display: grid; + grid-template-columns: repeat(2, 1fr); + + gap: 30px; + } + } + + .music-feed-items-header { + display: flex; + flex-direction: row; + + align-items: center; + + /* h1 { + font-size: 1.5rem; + margin: 0; + } */ + + .music-feed-items-actions { + display: flex; + flex-direction: row; + + gap: 10px; + + align-self: center; + + margin-left: auto; + } + } + + .music-feed-items-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(4, 1fr)); + gap: 10px; + } +} diff --git a/packages/app/src/pages/music/tabs/explore/components/Navbar/index.jsx b/packages/app/src/pages/music/tabs/explore/components/Navbar/index.jsx index 0d1cd58c..7d4e0542 100644 --- a/packages/app/src/pages/music/tabs/explore/components/Navbar/index.jsx +++ b/packages/app/src/pages/music/tabs/explore/components/Navbar/index.jsx @@ -3,7 +3,7 @@ import React from "react" import Searcher from "@components/Searcher" import SearchModel from "@models/search" -const MusicNavbar = (props) => { +const MusicNavbar = React.forwardRef((props, ref) => { return (
{ />
) -} +}) export default MusicNavbar diff --git a/packages/app/src/pages/music/tabs/explore/components/RecentlyPlayedList/index.jsx b/packages/app/src/pages/music/tabs/explore/components/RecentlyPlayedList/index.jsx index 5ce9bdc4..5bb6f573 100644 --- a/packages/app/src/pages/music/tabs/explore/components/RecentlyPlayedList/index.jsx +++ b/packages/app/src/pages/music/tabs/explore/components/RecentlyPlayedList/index.jsx @@ -8,61 +8,65 @@ import MusicModel from "@models/music" import "./index.less" const RecentlyPlayedItem = (props) => { - const { track } = props + const { track } = props - return
app.cores.player.start(track._id)} - > -
- -
+ return ( +
app.cores.player.start(track._id)} + > +
+ +
-
- -
+
+ +
-
-

{track.title}

-
-
+
+

{track.title}

+
+
+ ) } const RecentlyPlayedList = (props) => { - const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest(MusicModel.getRecentyPlayed, { - limit: 7 - }) + const [L_Tracks, R_Tracks, E_Tracks, M_Tracks] = app.cores.api.useRequest( + MusicModel.getRecentyPlayed, + { + limit: 6, + }, + ) - if (E_Tracks) { - return null - } + if (E_Tracks) { + return null + } - return
-
-

Recently played

-
+ return ( +
+
+

+ Recently played +

+
-
- { - L_Tracks && - } +
+ {L_Tracks && } - { - !L_Tracks &&
- { - R_Tracks.map((track, index) => { - return - }) - } -
- } -
-
+ {R_Tracks && R_Tracks.lenght === 0 && } + + {!L_Tracks && ( +
+ {R_Tracks.map((track, index) => { + return ( + + ) + })} +
+ )} +
+
+ ) } -export default RecentlyPlayedList \ No newline at end of file +export default RecentlyPlayedList diff --git a/packages/app/src/pages/music/tabs/explore/components/ReleasesList/index.jsx b/packages/app/src/pages/music/tabs/explore/components/ReleasesList/index.jsx deleted file mode 100644 index e201b4e9..00000000 --- a/packages/app/src/pages/music/tabs/explore/components/ReleasesList/index.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import React from "react" -import * as antd from "antd" -import { Translation } from "react-i18next" - -import { Icons } from "@components/Icons" -import Playlist from "@components/Music/Playlist" - -import "./index.less" - -const ReleasesList = (props) => { - const hopNumber = props.hopsPerPage ?? 9 - - const [offset, setOffset] = React.useState(0) - const [ended, setEnded] = React.useState(false) - - const [loading, result, error, makeRequest] = app.cores.api.useRequest( - props.fetchMethod, - { - limit: hopNumber, - trim: offset, - }, - ) - - const onClickPrev = () => { - if (offset === 0) { - return - } - - setOffset((value) => { - const newOffset = value - hopNumber - - // check if newOffset is NaN - if (newOffset !== newOffset) { - return false - } - - if (typeof makeRequest === "function") { - makeRequest({ - trim: newOffset, - limit: hopNumber, - }) - } - - return newOffset - }) - } - - const onClickNext = () => { - if (ended) { - return - } - - setOffset((value) => { - const newOffset = value + hopNumber - - // check if newOffset is NaN - if (newOffset !== newOffset) { - return false - } - - if (typeof makeRequest === "function") { - makeRequest({ - trim: newOffset, - limit: hopNumber, - }) - } - - return newOffset - }) - } - - React.useEffect(() => { - if (result) { - if (typeof result.has_more !== "undefined") { - setEnded(!result.has_more) - } else { - setEnded(result.items.length < hopNumber) - } - } - }, [result]) - - if (error) { - console.error(error) - - return ( -
- -
- ) - } - - return ( -
-
-

- {props.headerIcon} - {(t) => t(props.headerTitle)} -

- -
- } - onClick={onClickPrev} - disabled={offset === 0 || loading} - /> - - } - onClick={onClickNext} - disabled={ended || loading} - /> -
-
-
- {loading && } - {!loading && - result.items.map((playlist, index) => { - return - })} -
-
- ) -} - -export default ReleasesList diff --git a/packages/app/src/pages/music/tabs/explore/components/ReleasesList/index.less b/packages/app/src/pages/music/tabs/explore/components/ReleasesList/index.less deleted file mode 100644 index b195bda8..00000000 --- a/packages/app/src/pages/music/tabs/explore/components/ReleasesList/index.less +++ /dev/null @@ -1,74 +0,0 @@ -@min-item-size: 200px; - -.music-releases-list { - display: flex; - flex-direction: column; - - overflow-x: visible; - - .music-releases-list-header { - display: flex; - flex-direction: row; - - align-items: center; - - margin-bottom: 20px; - - h1 { - font-size: 1.5rem; - margin: 0; - } - - .music-releases-list-actions { - display: flex; - flex-direction: row; - - gap: 10px; - - align-self: center; - - margin-left: auto; - } - } - - .music-releases-list-items { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(@min-item-size, 1fr)); - gap: 10px; - /* display: grid; - - grid-gap: 20px; - grid-template-columns: repeat(2, 1fr); - - @media (min-width: 768px) { - grid-template-columns: repeat(3, 1fr); - } - - @media (min-width: 1000px) { - grid-template-columns: repeat(4, 1fr); - } - - @media (min-width: 1500px) { - grid-template-columns: repeat(7, 1fr); - } - - @media (min-width: 1600px) { - grid-template-columns: repeat(7, 1fr); - } - - @media (min-width: 1920px) { - grid-template-columns: repeat(9, 1fr); - } */ - - .playlist { - justify-self: center; - //min-width: 372px !important; - - width: unset; - height: unset; - - min-width: @min-item-size; - min-height: @min-item-size; - } - } -} diff --git a/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx b/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx index fcb1f510..5abf8880 100644 --- a/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx +++ b/packages/app/src/pages/music/tabs/explore/components/SearchResults/index.jsx @@ -8,105 +8,102 @@ import MusicTrack from "@components/Music/Track" import Playlist from "@components/Music/Playlist" const ResultGroupsDecorators = { - "playlists": { - icon: "MdPlaylistPlay", - label: "Playlists", - renderItem: (props) => { - return - } - }, - "tracks": { - icon: "MdMusicNote", - label: "Tracks", - renderItem: (props) => { - return app.cores.player.start(props.item)} - onClick={() => app.location.push(`/play/${props.item._id}`)} - /> - } - } + playlists: { + icon: "MdPlaylistPlay", + label: "Playlists", + renderItem: (props) => { + return + }, + }, + tracks: { + icon: "MdMusicNote", + label: "Tracks", + renderItem: (props) => { + return ( + app.cores.player.start(props.item)} + onClick={() => app.location.push(`/play/${props.item._id}`)} + /> + ) + }, + }, } -const SearchResults = ({ - data -}) => { - if (typeof data !== "object") { - return null - } +const SearchResults = ({ data }) => { + if (typeof data !== "object") { + return null + } - let groupsKeys = Object.keys(data) + let groupsKeys = Object.keys(data) - // filter out groups with no items array property - groupsKeys = groupsKeys.filter((key) => { - if (!Array.isArray(data[key].items)) { - return false - } + // filter out groups with no items array property + groupsKeys = groupsKeys.filter((key) => { + if (!Array.isArray(data[key].items)) { + return false + } - return true - }) + return true + }) - // filter out groups with empty items array - groupsKeys = groupsKeys.filter((key) => { - return data[key].items.length > 0 - }) + // filter out groups with empty items array + groupsKeys = groupsKeys.filter((key) => { + return data[key].items.length > 0 + }) - if (groupsKeys.length === 0) { - return
- -
- } + if (groupsKeys.length === 0) { + return ( +
+ +
+ ) + } - return
- { - groupsKeys.map((key, index) => { - const decorator = ResultGroupsDecorators[key] ?? { - icon: null, - label: key, - renderItem: () => null - } + return ( +
+ {groupsKeys.map((key, index) => { + const decorator = ResultGroupsDecorators[key] ?? { + icon: null, + label: key, + renderItem: () => null, + } - return
-
-

- { - createIconRender(decorator.icon) - } - - {(t) => t(decorator.label)} - -

-
+ return ( +
+
+

+ {createIconRender(decorator.icon)} + + {(t) => t(decorator.label)} + +

+
-
- { - data[key].items.map((item, index) => { - return decorator.renderItem({ - key: index, - item - }) - }) - } -
-
- }) - } -
+
+ {data[key].items.map((item, index) => { + return decorator.renderItem({ + key: index, + item, + }) + })} +
+
+ ) + })} +
+ ) } -export default SearchResults \ No newline at end of file +export default SearchResults diff --git a/packages/app/src/pages/music/tabs/explore/index.jsx b/packages/app/src/pages/music/tabs/explore/index.jsx index 55858ba1..326918d9 100755 --- a/packages/app/src/pages/music/tabs/explore/index.jsx +++ b/packages/app/src/pages/music/tabs/explore/index.jsx @@ -1,76 +1,83 @@ import React from "react" import classnames from "classnames" -import useCenteredContainer from "@hooks/useCenteredContainer" - import Searcher from "@components/Searcher" import { Icons } from "@components/Icons" -import FeedModel from "@models/feed" import SearchModel from "@models/search" +import MusicModel from "@models/music" +import RadioModel from "@models/radio" import Navbar from "./components/Navbar" import RecentlyPlayedList from "./components/RecentlyPlayedList" import SearchResults from "./components/SearchResults" -import ReleasesList from "./components/ReleasesList" -import FeaturedPlaylist from "./components/FeaturedPlaylist" +import FeedItems from "./components/FeedItems" import "./index.less" const MusicExploreTab = (props) => { - const [searchResults, setSearchResults] = React.useState(false) + const [searchResults, setSearchResults] = React.useState(false) - useCenteredContainer(false) + React.useEffect(() => { + app.layout.page_panels.attachComponent("music_navbar", Navbar, { + props: { + setSearchResults: setSearchResults, + }, + }) - React.useEffect(() => { - app.layout.page_panels.attachComponent("music_navbar", Navbar, { - props: { - setSearchResults: setSearchResults, - } - }) + return () => { + if (app.layout.page_panels) { + app.layout.page_panels.detachComponent("music_navbar") + } + } + }, []) - return () => { - if (app.layout.page_panels) { - app.layout.page_panels.detachComponent("music_navbar") - } - } - }, []) + return ( +
+ {app.isMobile && ( + + SearchModel.search("music", keywords, params) + } + onSearchResult={setSearchResults} + onEmpty={() => setSearchResults(false)} + /> + )} - return
- { - app.isMobile && SearchModel.search("music", keywords, params)} - onSearchResult={setSearchResults} - onEmpty={() => setSearchResults(false)} - /> - } + {searchResults && } - { - searchResults && - } + {!searchResults && } - { - !searchResults &&
- + {!searchResults && ( +
+ } + fetchMethod={MusicModel.getAllTracks} + itemsPerPage={6} + /> - + } + fetchMethod={MusicModel.getAllReleases} + /> - } - fetchMethod={FeedModel.getGlobalMusicFeed} - /> -
- } -
+ } + fetchMethod={RadioModel.getTrendings} + disablePagination + /> +
+ )} +
+ ) } -export default MusicExploreTab \ No newline at end of file +export default MusicExploreTab diff --git a/packages/app/src/pages/music/tabs/explore/index.less b/packages/app/src/pages/music/tabs/explore/index.less index 294ff2bf..cd73fe9b 100755 --- a/packages/app/src/pages/music/tabs/explore/index.less +++ b/packages/app/src/pages/music/tabs/explore/index.less @@ -1,108 +1,14 @@ -html { - &.mobile { - .musicExplorer { - .playlistExplorer_section_list { - overflow: visible; - overflow-x: scroll; +&.mobile { + .music-explore { + padding: 0 10px; - width: unset; - display: flex; - flex-direction: row; - - grid-gap: 10px; - } - } - } -} - -.featured_playlist { - position: relative; - - display: flex; - flex-direction: row; - - overflow: hidden; - - background-color: var(--background-color-accent); - - width: 100%; - min-height: 200px; - height: fit-content; - - border-radius: 12px; - - cursor: pointer; - - &:hover { - .featured_playlist_content { - h1, - p { - -webkit-text-stroke-width: 1.6px; - -webkit-text-stroke-color: var(--border-color); - - color: var(--background-color-contrast); - } + .recently_played-content { + padding: 0; } - .lazy-load-image-background { - opacity: 1; - } - } - - .lazy-load-image-background { - z-index: 50; - - position: absolute; - - opacity: 0.3; - - transition: all 300ms ease-in-out !important; - - img { - width: 100%; - height: 100%; - } - } - - .featured_playlist_content { - z-index: 55; - - padding: 20px; - - display: flex; - flex-direction: column; - - h1 { - font-size: 2.5rem; - font-family: "Space Grotesk", sans-serif; - font-weight: 900; - - transition: all 300ms ease-in-out !important; - } - - p { - font-size: 1rem; - font-family: "Space Grotesk", sans-serif; - - transition: all 300ms ease-in-out !important; - } - - .featured_playlist_genre { - z-index: 55; - - position: absolute; - - left: 0; - bottom: 0; - - margin: 10px; - - background-color: var(--background-color-accent); - border: 1px solid var(--border-color); - - border-radius: 12px; - - padding: 10px 20px; + .music-explore-content { + display: flex; + flex-direction: column; } } } @@ -118,14 +24,14 @@ html { border-radius: 12px; } -.musicExplorer { +.music-explore { display: flex; flex-direction: column; width: 100%; height: 100%; - gap: 20px; + gap: 30px; &.search-focused { .feed_main { @@ -134,18 +40,19 @@ html { } } - .feed_main { - display: flex; - flex-direction: column; + .music-explore-content { + display: grid; + grid-template-columns: repeat(2, auto); + grid-template-rows: auto; width: 100%; height: 100%; - gap: 50px; + gap: 30px; - transition: all 0.2s ease-in-out; - - overflow-x: visible; + @media screen and (max-width: 1200px) { + grid-template-columns: 1fr; + } } } diff --git a/packages/app/src/pages/music/tabs/library/index.jsx b/packages/app/src/pages/music/tabs/library/index.jsx index 0d27c2dc..dd699f17 100755 --- a/packages/app/src/pages/music/tabs/library/index.jsx +++ b/packages/app/src/pages/music/tabs/library/index.jsx @@ -4,24 +4,30 @@ import * as antd from "antd" import { Icons } from "@components/Icons" import TracksLibraryView from "./views/tracks" +import ReleasesLibraryView from "./views/releases" import PlaylistLibraryView from "./views/playlists" import "./index.less" -const TabToView = { - tracks: TracksLibraryView, - playlist: PlaylistLibraryView, - releases: PlaylistLibraryView, -} - -const TabToHeader = { +const Views = { tracks: { - icon: , + value: "tracks", label: "Tracks", + icon: , + element: TracksLibraryView, }, - playlist: { - icon: , + releases: { + value: "releases", + label: "Releases", + icon: , + element: ReleasesLibraryView, + }, + playlists: { + value: "playlists", label: "Playlists", + icon: , + element: PlaylistLibraryView, + disabled: true, }, } @@ -34,29 +40,13 @@ const Library = (props) => { , - }, - { - value: "playlist", - label: "Playlists", - icon: , - }, - { - value: "releases", - label: "Releases", - icon: , - }, - ]} + options={Object.values(Views)} />
{selectedTab && - TabToView[selectedTab] && - React.createElement(TabToView[selectedTab])} + Views[selectedTab] && + React.createElement(Views[selectedTab].element)}
) } diff --git a/packages/app/src/pages/music/tabs/library/views/playlists/index.jsx b/packages/app/src/pages/music/tabs/library/views/playlists/index.jsx index 2f010e89..dd8b9e69 100644 --- a/packages/app/src/pages/music/tabs/library/views/playlists/index.jsx +++ b/packages/app/src/pages/music/tabs/library/views/playlists/index.jsx @@ -1,181 +1,76 @@ import React from "react" import * as antd from "antd" -import classnames from "classnames" -import Image from "@components/Image" -import { Icons } from "@components/Icons" -import OpenPlaylistCreator from "@components/Music/PlaylistCreator" +import PlaylistView from "@components/Music/PlaylistView" import MusicModel from "@models/music" -import "./index.less" +const loadLimit = 50 -const ReleaseTypeDecorators = { - "user": () =>

- - Playlist -

, - "playlist": () =>

- - Playlist -

, - "editorial": () =>

- - Official Playlist -

, - "single": () =>

- - Single -

, - "album": () =>

- - Album -

, - "ep": () =>

- - EP -

, - "mix": () =>

- - Mix -

, +const MyLibraryPlaylists = () => { + const [offset, setOffset] = React.useState(0) + const [items, setItems] = React.useState([]) + const [hasMore, setHasMore] = React.useState(true) + const [initialLoading, setInitialLoading] = React.useState(true) + + const [L_Library, R_Library, E_Library, M_Library] = + app.cores.api.useRequest(MusicModel.getMyLibrary, { + offset: offset, + limit: loadLimit, + kind: "playlists", + }) + + async function onLoadMore() { + const newOffset = offset + loadLimit + + setOffset(newOffset) + + M_Library({ + offset: newOffset, + limit: loadLimit, + }) + } + + React.useEffect(() => { + if (R_Library && R_Library.items) { + if (initialLoading === true) { + setInitialLoading(false) + } + + if (R_Library.items.length === 0) { + setHasMore(false) + } else { + setItems((prev) => { + prev = [...prev, ...R_Library.items] + + return prev + }) + } + } + }, [R_Library]) + + if (E_Library) { + return + } + + if (initialLoading) { + return + } + + return ( + + ) } -function isNotAPlaylist(type) { - return type === "album" || type === "ep" || type === "mix" || type === "single" -} - -const PlaylistItem = (props) => { - const data = props.data ?? {} - - const handleOnClick = () => { - if (typeof props.onClick === "function") { - props.onClick(data) - } - - if (props.type !== "action") { - if (data.service) { - return app.navigation.goToPlaylist(`${data._id}?service=${data.service}`) - } - - return app.navigation.goToPlaylist(data._id) - } - } - - return
-
- { - React.isValidElement(data.icon) - ?
- {data.icon} -
- : playlist icon - } -
- -
-
-

- { - data.service === "tidal" && - } - { - data.title ?? "Unnamed playlist" - } -

-
- - { - data.owner &&
-

- { - data.owner - } -

-
- } - - { - data.description &&
-

- { - data.description - } -

- - { - ReleaseTypeDecorators[String(data.type).toLowerCase()] && ReleaseTypeDecorators[String(data.type).toLowerCase()](props) - } - - { - data.public - ?

- - Public -

- - :

- - Private -

- } -
- } -
-
-} - -const PlaylistLibraryView = (props) => { - const [L_Playlists, R_Playlists, E_Playlists, M_Playlists] = app.cores.api.useRequest(MusicModel.getFavoritePlaylists) - - if (E_Playlists) { - console.error(E_Playlists) - - return - } - - if (L_Playlists) { - return - } - - return
- , - title: "Create new", - }} - onClick={OpenPlaylistCreator} - /> - - { - R_Playlists.items.map((playlist) => { - playlist.icon = playlist.cover ?? playlist.thumbnail - playlist.description = `${playlist.numberOfTracks ?? playlist.list.length} tracks` - - return - }) - } -
-} - -export default PlaylistLibraryView \ No newline at end of file +export default MyLibraryPlaylists diff --git a/packages/app/src/pages/music/tabs/library/views/releases/index.jsx b/packages/app/src/pages/music/tabs/library/views/releases/index.jsx new file mode 100644 index 00000000..5718edf1 --- /dev/null +++ b/packages/app/src/pages/music/tabs/library/views/releases/index.jsx @@ -0,0 +1,66 @@ +import React from "react" +import * as antd from "antd" + +import Playlist from "@components/Music/Playlist" + +import MusicModel from "@models/music" + +const loadLimit = 50 + +const MyLibraryReleases = () => { + const [offset, setOffset] = React.useState(0) + const [items, setItems] = React.useState([]) + const [hasMore, setHasMore] = React.useState(true) + const [initialLoading, setInitialLoading] = React.useState(true) + + const [L_Library, R_Library, E_Library, M_Library] = + app.cores.api.useRequest(MusicModel.getMyLibrary, { + offset: offset, + limit: loadLimit, + kind: "releases", + }) + + async function onLoadMore() { + const newOffset = offset + loadLimit + + setOffset(newOffset) + + M_Library({ + offset: newOffset, + limit: loadLimit, + kind: "releases", + }) + } + + React.useEffect(() => { + if (R_Library && R_Library.items) { + if (initialLoading === true) { + setInitialLoading(false) + } + + if (R_Library.items.length === 0) { + setHasMore(false) + } else { + setItems((prev) => { + prev = [...prev, ...R_Library.items] + + return prev + }) + } + } + }, [R_Library]) + + if (E_Library) { + return + } + + if (initialLoading) { + return + } + + return items.map((item, index) => { + return + }) +} + +export default MyLibraryReleases diff --git a/packages/app/src/pages/music/tabs/library/views/releases/index.less b/packages/app/src/pages/music/tabs/library/views/releases/index.less new file mode 100644 index 00000000..da9e5164 --- /dev/null +++ b/packages/app/src/pages/music/tabs/library/views/releases/index.less @@ -0,0 +1,166 @@ +@playlist_item_icon_size: 50px; + +.playlist_item { + display: flex; + flex-direction: row; + + width: 100%; + + height: 70px; + + gap: 10px; + + padding: 10px; + + border: 1px solid var(--border-color); + + border-radius: 12px; + + cursor: pointer; + + overflow: hidden; + + &.release { + .playlist_item_icon { + img { + border-radius: 50%; + } + } + } + + &.action { + .playlist_item_icon { + color: var(--colorPrimary); + } + } + + .playlist_item_icon { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + width: @playlist_item_icon_size; + height: @playlist_item_icon_size; + + min-width: @playlist_item_icon_size; + min-height: @playlist_item_icon_size; + + overflow: hidden; + + border-radius: 12px; + + img { + width: @playlist_item_icon_size; + height: @playlist_item_icon_size; + + min-width: @playlist_item_icon_size; + min-height: @playlist_item_icon_size; + + object-fit: cover; + } + + .playlist_item_icon_svg { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + background-color: var(--background-color-accent); + + svg { + margin: 0; + font-size: 2rem; + } + } + } + + .playlist_item_info { + display: flex; + flex-direction: column; + + //align-items: center; + justify-content: center; + + height: 100%; + width: 90%; + + text-overflow: ellipsis; + + .playlist_item_info_title { + display: inline; + + + font-size: 0.8rem; + + text-overflow: ellipsis; + + h1 { + font-weight: 700; + white-space: nowrap; + + overflow: hidden; + } + } + + .playlist_item_info_owner { + display: inline; + + overflow: hidden; + + font-size: 0.7rem; + + h4 { + font-weight: 400; + } + } + + .playlist_item_info_description { + display: inline-flex; + flex-direction: row; + + gap: 10px; + + font-size: 0.7rem; + + p { + font-weight: 500; + + white-space: nowrap; + + overflow: hidden; + + text-transform: uppercase; + + svg { + margin-right: 0.4rem; + } + } + } + + h1, + h2, + h3, + h4, + h5, + h6, + p, + span { + margin: 0; + } + } +} + +.own_playlists { + display: flex; + flex-direction: column; + + width: 100%; + + gap: 10px; +} \ No newline at end of file diff --git a/packages/app/src/pages/music/tabs/library/views/tracks/index.jsx b/packages/app/src/pages/music/tabs/library/views/tracks/index.jsx index 8db27e1f..6973277f 100644 --- a/packages/app/src/pages/music/tabs/library/views/tracks/index.jsx +++ b/packages/app/src/pages/music/tabs/library/views/tracks/index.jsx @@ -13,49 +13,47 @@ const TracksLibraryView = () => { const [hasMore, setHasMore] = React.useState(true) const [initialLoading, setInitialLoading] = React.useState(true) - const [L_Favourites, R_Favourites, E_Favourites, M_Favourites] = - app.cores.api.useRequest(MusicModel.getFavouriteFolder, { + const [L_Library, R_Library, E_Library, M_Library] = + app.cores.api.useRequest(MusicModel.getMyLibrary, { offset: offset, limit: loadLimit, + kind: "tracks", }) async function onLoadMore() { - const newOffset = offset + loadLimit + setOffset((prevOffset) => { + const newOffset = prevOffset + loadLimit - setOffset(newOffset) + M_Library({ + offset: newOffset, + limit: loadLimit, + kind: "tracks", + }) - M_Favourites({ - offset: newOffset, - limit: loadLimit, + if (newOffset >= R_Library.total_items) { + setHasMore(false) + } + + return newOffset }) } React.useEffect(() => { - if (R_Favourites && R_Favourites.tracks) { + if (R_Library && R_Library.items) { if (initialLoading === true) { setInitialLoading(false) } - if (R_Favourites.tracks.items.length === 0) { - setHasMore(false) - } else { - setItems((prev) => { - prev = [...prev, ...R_Favourites.tracks.items] + setItems((prev) => { + prev = [...prev, ...R_Library.items] - return prev - }) - } + return prev + }) } - }, [R_Favourites]) + }, [R_Library]) - if (E_Favourites) { - return ( - - ) + if (E_Library) { + return } if (initialLoading) { @@ -66,15 +64,14 @@ const TracksLibraryView = () => { ) } diff --git a/packages/app/src/pages/music/tabs/radio/index.jsx b/packages/app/src/pages/music/tabs/radio/index.jsx index f9264907..a5603228 100644 --- a/packages/app/src/pages/music/tabs/radio/index.jsx +++ b/packages/app/src/pages/music/tabs/radio/index.jsx @@ -1,58 +1,12 @@ import React from "react" import { Skeleton, Result } from "antd" -import RadioModel from "@models/radio" -import Image from "@components/Image" -import { MdPlayCircle, MdHeadphones } from "react-icons/md" +import RadioModel from "@models/radio" + +import Radio from "@components/Music/Radio" import "./index.less" -const RadioItem = ({ item, style }) => { - const onClickItem = () => { - app.cores.player.start( - { - title: item.name, - source: item.http_src, - cover: item.background, - }, - { - radioId: item.radio_id, - }, - ) - } - - if (!item) { - return ( -
-
- -
-
- ) - } - - return ( -
- -
-

{item.name}

-

{item.description}

- -
-
- - {item.now_playing.song.text} -
-
- - {item.listeners} -
-
-
-
- ) -} - const RadioTab = () => { const [L_Radios, R_Radios, E_Radios, M_Radios] = app.cores.api.useRequest( RadioModel.getRadioList, @@ -69,12 +23,12 @@ const RadioTab = () => { return (
{R_Radios.map((item) => ( - + ))} - - - + + +
) } diff --git a/packages/app/src/pages/music/tabs/radio/index.less b/packages/app/src/pages/music/tabs/radio/index.less index bf83d6ea..466e6fc9 100644 --- a/packages/app/src/pages/music/tabs/radio/index.less +++ b/packages/app/src/pages/music/tabs/radio/index.less @@ -7,87 +7,9 @@ gap: 10px; width: 100%; -} -.radio-list-item { - position: relative; - - display: flex; - flex-direction: column; - - min-width: @min-item-width; - min-height: @min-item-height; - - border-radius: 16px; - background-color: var(--background-color-accent); - - overflow: hidden; - - &:hover { - cursor: pointer; - - .radio-list-item-content { - backdrop-filter: blur(2px); - } - } - - &.empty { - cursor: default; - } - - .lazy-load-image-background, - .radio-list-item-cover { - position: absolute; - - z-index: 1; - - top: 0; - left: 0; - - width: 100%; - height: 100%; - - img { - object-fit: cover; - } - } - - .radio-list-item-content { - position: relative; - z-index: 2; - - display: flex; - flex-direction: column; - - justify-content: space-between; - - width: 100%; - height: 100%; - - padding: 16px; - - transition: all 150ms ease-in-out; - - .radio-list-item-info { - display: flex; - align-items: center; - - gap: 8px; - - .radio-list-item-info-item { - display: flex; - - flex-direction: row; - align-items: center; - - gap: 8px; - padding: 4px; - - background-color: rgba(var(--bg_color_3), 0.7); - - border-radius: 8px; - font-size: 0.7rem; - } - } + .radio-item { + min-width: @min-item-width; + min-height: @min-item-height; } } diff --git a/packages/app/src/pages/studio/tv/[profile_id]/components/HiddenText/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/components/HiddenText/index.jsx new file mode 100644 index 00000000..02adea46 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/components/HiddenText/index.jsx @@ -0,0 +1,67 @@ +import React from "react" +import * as antd from "antd" + +import { IoMdClipboard, IoMdEye, IoMdEyeOff } from "react-icons/io" + +const HiddenText = (props) => { + const [visible, setVisible] = React.useState(false) + + function copyToClipboard() { + try { + navigator.clipboard.writeText(props.value) + antd.message.success("Copied to clipboard") + } catch (error) { + console.error(error) + antd.message.error("Failed to copy to clipboard") + } + } + + return ( +
+ {visible ? props.value : "********"} + + : } + type="ghost" + size="small" + onClick={() => setVisible(!visible)} + /> + + } + type="ghost" + size="small" + onClick={copyToClipboard} + /> +
+ ) +} + +export default HiddenText diff --git a/packages/app/src/pages/studio/tv/[profile_id]/components/StreamRateChart/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/components/StreamRateChart/index.jsx new file mode 100644 index 00000000..203d1a78 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/components/StreamRateChart/index.jsx @@ -0,0 +1,278 @@ +import React, { useEffect, useRef } from "react" +import * as d3 from "d3" + +import { formatBitrate } from "../../liveTabUtils" + +const CHART_HEIGHT = 220 +const MIN_DATA_POINTS_FOR_CHART = 3 +const ONE_MINUTE_IN_MS = 1 * 60 * 1000; // 1 minute in milliseconds + +const Y_AXIS_MAX_TARGET_KBPS = 14000 +const Y_AXIS_DISPLAY_MAX_KBPS = Y_AXIS_MAX_TARGET_KBPS * 1.1 +const MAX_Y_DOMAIN_BPS_FROM_CONFIG = (Y_AXIS_DISPLAY_MAX_KBPS * 1000) / 8 + +const StreamRateChart = ({ streamData }) => { + const d3ContainerRef = useRef(null) + const tooltipRef = useRef(null) + + useEffect(() => { + if ( + streamData && + streamData.length >= MIN_DATA_POINTS_FOR_CHART && + d3ContainerRef.current + ) { + const svgElement = d3ContainerRef.current + const tooltipDiv = d3.select(tooltipRef.current) + + const availableWidth = + svgElement.clientWidth || + (svgElement.parentNode && svgElement.parentNode.clientWidth) || + 600 + + const availableHeight = CHART_HEIGHT + + const margin = { top: 20, right: 20, bottom: 30, left: 75 } // Adjusted right margin + const width = availableWidth - margin.left - margin.right + const height = availableHeight - margin.top - margin.bottom + + const svg = d3.select(svgElement) + svg.selectAll("*").remove() + + // Define a clip-path for the lines area + svg.append("defs").append("clipPath") + .attr("id", "chart-lines-clip") // Unique ID for clipPath + .append("rect") + .attr("width", width) // Clip to the plotting area width + .attr("height", height); // Clip to the plotting area height + + // Main chart group for axes (not clipped) + const chartG = svg + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // Group for lines, this group will be clipped + const linesG = chartG.append("g") + .attr("clip-path", "url(#chart-lines-clip)"); + + const xScale = d3 + .scaleTime() + // Domain will now span the actual data present in streamData (up to 1 minute) + .domain(d3.extent(streamData, (d) => new Date(d.time))) + .range([0, width]) + + const currentMaxBps = d3.max(streamData, (d) => d.receivedRate) || 0 + const yDomainMax = Math.max( + MAX_Y_DOMAIN_BPS_FROM_CONFIG, + currentMaxBps, + ) + + const yScale = d3 + .scaleLinear() + .domain([0, yDomainMax > 0 ? yDomainMax : (1000 * 1000) / 8]) + .range([height, 0]) + .nice() + + const xAxis = d3 + .axisBottom(xScale) + .ticks(Math.min(5, Math.floor(width / 80))) + .tickFormat(d3.timeFormat("%H:%M:%S")) + + const yAxis = d3.axisLeft(yScale).ticks(5).tickFormat(formatBitrate) + + chartG + .append("g") + .attr("class", "x-axis") + .attr("transform", `translate(0,${height})`) + .call(xAxis) + .selectAll("text") + .style("fill", "#8c8c8c") + chartG.selectAll(".x-axis path").style("stroke", "#444") + chartG.selectAll(".x-axis .tick line").style("stroke", "#444") + + chartG + .append("g") + .attr("class", "y-axis") + .call(yAxis) + .selectAll("text") + .style("fill", "#8c8c8c") + chartG.selectAll(".y-axis path").style("stroke", "#444") + chartG.selectAll(".y-axis .tick line").style("stroke", "#444") + + const lineReceived = d3 + .line() + .x((d) => xScale(new Date(d.time))) + .y((d) => yScale(d.receivedRate)) + .curve(d3.curveMonotoneX) + + const receivedColor = "#2ecc71" + + // Filter data to ensure valid points for the line + const validStreamDataForLine = streamData.filter( + d => d && typeof d.receivedRate === 'number' && !isNaN(d.receivedRate) && d.time + ); + + // Append the line path to the clipped group 'linesG' + // Only draw if there's enough valid data to form a line + if (validStreamDataForLine.length > 1) { + linesG + .append("path") + .datum(validStreamDataForLine) + .attr("fill", "none") + .attr("stroke", receivedColor) + .attr("stroke-width", 2) + .attr("d", lineReceived); + // curveMonotoneX is applied in the lineReceived generator definition + } + + // Tooltip focus elements are appended to chartG so they are not clipped by the lines' clip-path + const focus = chartG + .append("g") + .attr("class", "focus") + .style("display", "none") + + focus + .append("line") + .attr("class", "focus-line") + .attr("y1", 0) + .attr("y2", height) + .attr("stroke", "#aaa") + .attr("stroke-width", 1) + .attr("stroke-dasharray", "3,3") + + focus + .append("circle") + .attr("r", 4) + .attr("class", "focus-circle-received") + .style("fill", receivedColor) + .style("stroke", "white") + + chartG + .append("rect") + .attr("class", "overlay") + .attr("width", width) + .attr("height", height) + .style("fill", "none") + .style("pointer-events", "all") + .on("mouseover", () => { + focus.style("display", null) + tooltipDiv.style("display", "block") + }) + .on("mouseout", () => { + focus.style("display", "none") + tooltipDiv.style("display", "none") + }) + .on("mousemove", mousemove) + + const bisectDate = d3.bisector((d) => new Date(d.time)).left + + function mousemove(event) { + const [mouseX] = d3.pointer(event, this) + + const x0 = xScale.invert(mouseX) + const i = bisectDate(streamData, x0, 1) + const d0 = streamData[i - 1] + const d1 = streamData[i] + + const t0 = d0 ? new Date(d0.time) : null + const t1 = d1 ? new Date(d1.time) : null + const d = t1 && x0 - t0 > t1 - x0 ? d1 : d0 + + if (d) { + const focusX = xScale(new Date(d.time)) + focus.attr("transform", `translate(${focusX},0)`) + focus + .select(".focus-circle-received") + .attr("cy", yScale(d.receivedRate)) + + const tooltipX = margin.left + focusX + 15 + const receivedY = yScale(d.receivedRate) + const tooltipY = margin.top + receivedY + + tooltipDiv + .style("left", `${tooltipX}px`) + .style("top", `${tooltipY}px`) + .html( + `Time: ${d3.timeFormat("%H:%M:%S")(new Date(d.time))}
` + + `Received: ${formatBitrate(d.receivedRate)}`, + ) + } + } + } else if (d3ContainerRef.current) { + const svg = d3.select(d3ContainerRef.current) + + svg.selectAll("*").remove() + + if (streamData && streamData.length < MIN_DATA_POINTS_FOR_CHART) { + const currentSvgElement = d3ContainerRef.current + + svg.append("text") + .attr( + "x", + (currentSvgElement?.clientWidth || + (currentSvgElement?.parentNode && + currentSvgElement?.parentNode.clientWidth) || + 600) / 2, + ) + .attr("y", CHART_HEIGHT / 2) + .attr("text-anchor", "middle") + .text( + `Collecting data... (${streamData?.length || 0}/${MIN_DATA_POINTS_FOR_CHART})`, + ) + .style("fill", "#8c8c8c") + .style("font-size", "12px") + } + } + }, [streamData]) + + return ( +
+ + +
+ + {(!streamData || streamData.length === 0) && ( +
+ Waiting for stream data... +
+ )} +
+ ) +} + +export default StreamRateChart diff --git a/packages/app/src/pages/studio/tv/[profile_id]/header.jsx b/packages/app/src/pages/studio/tv/[profile_id]/header.jsx new file mode 100644 index 00000000..94da6088 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/header.jsx @@ -0,0 +1,81 @@ +import React from "react" +import { FiEye, FiRadio } from "react-icons/fi" + +const ProfileHeader = ({ profile, streamHealth }) => { + const streamRef = React.useRef(streamHealth ?? {}) + const [thumbnail, setThumbnail] = React.useState( + profile.info.offline_thumbnail, + ) + + async function setTimedThumbnail() { + setThumbnail(() => { + if (streamRef.current.online && profile.info.thumbnail) { + return `${profile.info.thumbnail}?t=${Date.now()}` + } + + return profile.info.offline_thumbnail + }) + } + + React.useEffect(() => { + streamRef.current = streamHealth + }, [streamHealth]) + + React.useEffect(() => { + const timedThumbnailInterval = setInterval(setTimedThumbnail, 5000) + + return () => { + clearInterval(timedThumbnailInterval) + } + }, []) + + return ( +
+ + +
+
+

+ {profile.info.title} +

+ +

+ {profile.info.description} +

+
+ +
+ {streamHealth?.online ? ( +
+ + On Live + +
+ ) : ( +
+ Offline +
+ )} + +
+ + + {streamHealth?.viewers} + +
+
+
+
+ ) +} + +export default ProfileHeader diff --git a/packages/app/src/pages/studio/tv/[profile_id]/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/index.jsx new file mode 100644 index 00000000..27a77f23 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/index.jsx @@ -0,0 +1,217 @@ +import React from "react" +import * as antd from "antd" + +import Streaming from "@models/spectrum" + +import useCenteredContainer from "@hooks/useCenteredContainer" + +import ProfileHeader from "./header" + +import LiveTab from "./tabs/Live" +import StreamConfiguration from "./tabs/StreamConfiguration" +import RestreamManager from "./tabs/RestreamManager" +import MediaUrls from "./tabs/MediaUrls" + +import "./index.less" + +const KeyToComponent = { + live: LiveTab, + configuration: StreamConfiguration, + restreams: RestreamManager, + media_urls: MediaUrls, +} + +const useSpectrumWS = () => { + const client = React.useMemo(() => Streaming.createWebsocket(), []) + + React.useEffect(() => { + client.connect() + + return () => { + client.destroy() + } + }, []) + + return client +} + +const ProfileData = (props) => { + const { profile_id } = props.params + + if (!profile_id) { + return null + } + + useCenteredContainer(false) + + const ws = useSpectrumWS() + + const [loading, setLoading] = React.useState(false) + const [fetching, setFetching] = React.useState(true) + const [error, setError] = React.useState(null) + const [profile, setProfile] = React.useState(null) + const [selectedTab, setSelectedTab] = React.useState("live") + const [streamHealth, setStreamHealth] = React.useState(null) + const streamHealthIntervalRef = React.useRef(null) + + async function fetchStreamHealth() { + if (!ws) { + return false + } + + const health = await ws.call("stream:health", profile_id) + + setStreamHealth(health) + } + + async function fetchProfileData(idToFetch) { + setFetching(true) + setError(null) + + try { + const result = await Streaming.getProfile(idToFetch) + + if (result) { + setProfile(result) + } else { + setError({ + message: + "Profile not found or an error occurred while fetching.", + }) + } + } catch (err) { + console.error("Error fetching profile:", err) + setError(err) + } finally { + setFetching(false) + } + } + + async function handleProfileUpdate(key, value) { + if (!profile || !profile._id) { + antd.message.error("Profile data is not available for update.") + return false + } + + setLoading(true) + + try { + const updatedProfile = await Streaming.updateProfile(profile._id, { + [key]: value, + }) + + antd.message.success("Change applyed") + setProfile(updatedProfile) + } catch (err) { + console.error(`Error updating profile (${key}):`, err) + + const errorMessage = + err.response?.data?.message || + err.message || + `Failed to update ${key}.` + + antd.message.error(errorMessage) + + return false + } finally { + setLoading(false) + } + } + + React.useEffect(() => { + if (profile_id) { + fetchProfileData(profile_id) + } else { + setProfile(null) + setError(null) + } + }, [profile_id]) + + React.useEffect(() => { + if (profile_id) { + streamHealthIntervalRef.current = setInterval( + fetchStreamHealth, + 1000, + ) + } + + return () => { + clearInterval(streamHealthIntervalRef.current) + } + }, [profile_id]) + + if (fetching) { + return + } + + if (error) { + return ( + fetchProfileData(profile_id)} + > + Retry + , + ]} + /> + ) + } + + if (!profile) { + return ( + + ) + } + + return ( +
+ + + setSelectedTab(value)} + value={selectedTab} + /> + + {KeyToComponent[selectedTab] && + React.createElement(KeyToComponent[selectedTab], { + profile, + loading, + handleProfileUpdate, + streamHealth, + })} +
+ ) +} + +export default ProfileData diff --git a/packages/app/src/pages/studio/tv/[profile_id]/index.less b/packages/app/src/pages/studio/tv/[profile_id]/index.less new file mode 100644 index 00000000..5e9770d0 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/index.less @@ -0,0 +1,246 @@ +.profile-view { + display: flex; + flex-direction: column; + + width: 100%; + + gap: 20px; + + .profile-header { + position: relative; + + max-height: 300px; + height: 300px; + + background-repeat: no-repeat; + background-position: center; + background-size: cover; + + border-radius: 12px; + + overflow: hidden; + + &__card { + display: flex; + flex-direction: row; + + align-items: center; + + width: fit-content; + + padding: 5px 10px; + gap: 7px; + + border-radius: 12px; + + background-color: var(--background-color-primary); + + &.titles { + flex-direction: column; + align-items: flex-start; + justify-content: center; + } + + &.on_live { + background-color: var(--colorPrimary); + } + + &.viewers { + font-family: "DM Mono", monospace; + } + } + + .profile-header__image { + position: absolute; + + height: 100%; + width: 100%; + + left: 0; + top: 0; + + z-index: 10; + } + + .profile-header__content { + position: relative; + + display: flex; + flex-direction: column; + + z-index: 20; + + height: 100%; + width: 100%; + + padding: 30px; + + gap: 10px; + + background-color: rgba(0, 0, 0, 0.5); + } + } + + .profile-section { + display: flex; + flex-direction: column; + + gap: 10px; + + &__header { + display: flex; + + flex-direction: row; + align-items: center; + + gap: 10px; + + span { + font-size: 1.2rem; + font-weight: 600; + } + + svg { + font-size: 1.3rem; + opacity: 0.7; + } + } + } + + .content-panel { + display: flex; + flex-direction: column; + + width: 100%; + + padding: 10px; + + background-color: var(--background-color-accent); + + border: 1px solid var(--border-color); + border-radius: 8px; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09); + + &__header { + display: flex; + flex-direction: row; + + align-items: center; + + justify-content: space-between; + + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid var(--border-color); + + font-size: 1rem; + font-weight: 500; + } + + &__content { + display: flex; + flex-direction: column; + + gap: 10px; + + flex-grow: 1; + } + } + + .data-field { + display: flex; + flex-direction: column; + + gap: 6px; + + .profile-section:not(.content-panel) > & { + padding: 10px 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + } + + &__label { + display: flex; + flex-direction: column; + + font-size: 0.9rem; + font-weight: 500; + + svg { + font-size: 1.1em; + } + + p { + font-size: 0.7rem; + opacity: 0.7; + } + } + + &__value { + font-size: 0.9rem; + word-break: break-all; + + code { + background-color: var(--background-color-primary); + + padding: 5px 8px; + border-radius: 8px; + + font-size: 0.8rem; + font-family: "DM Mono", monospace; + } + } + + &__description { + font-size: 0.8rem; + + p { + margin-bottom: 0; + line-height: 1.4; + } + } + + &__content { + display: flex; + flex-direction: row; + + gap: 10px; + + align-items: center; + } + } + + .ant-segmented { + background-color: var(--background-color-accent); + + .ant-segmented-thumb { + left: var(--thumb-active-left); + width: var(--thumb-active-width); + + background-color: var(--background-color-primary-2); + + transition: all 150ms ease-in-out; + } + } +} + +.restream-server-item { + display: flex; + flex-direction: row; + + align-items: center; + justify-content: space-between; + + width: 100%; + + background-color: var(--background-color-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09); + + padding: 10px; +} diff --git a/packages/app/src/pages/studio/tv/[profile_id]/liveTabUtils.js b/packages/app/src/pages/studio/tv/[profile_id]/liveTabUtils.js new file mode 100644 index 00000000..1e2e9663 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/liveTabUtils.js @@ -0,0 +1,38 @@ +export const formatBytes = (bytes, decimals = 2) => { + if ( + bytes === undefined || + bytes === null || + isNaN(parseFloat(bytes)) || + !isFinite(bytes) + ) + return "0 Bytes" + if (bytes === 0) { + return "0 Bytes" + } + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i] +} + +export const formatBitrate = (bytesPerSecond) => { + if (typeof bytesPerSecond !== "number" || isNaN(bytesPerSecond)) { + return "0 Kbps" + } + + const bitsPerSecond = bytesPerSecond * 8 + + if (bitsPerSecond >= 1000000) { + return `${(bitsPerSecond / 1000000).toFixed(1)} Mbps` + } + + if (bitsPerSecond >= 1000 || bitsPerSecond === 0) { + return `${(bitsPerSecond / 1000).toFixed(0)} Kbps` + } + + return `${bitsPerSecond.toFixed(0)} bps` +} diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx new file mode 100644 index 00000000..0e94c799 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.jsx @@ -0,0 +1,231 @@ +import { Button, Input, Statistic, Tag } from "antd" +import UploadButton from "@components/UploadButton" + +import { FiImage, FiInfo } from "react-icons/fi" +import { MdTextFields, MdDescription } from "react-icons/md" + +import StreamRateChart from "../../components/StreamRateChart" +import { formatBytes, formatBitrate } from "../../liveTabUtils" +import { useStreamSignalQuality } from "../../useStreamSignalQuality" + +import "./index.less" + +const MAX_DATA_POINTS = 30 // Approx 30 seconds of data (if 1 point per second) +const Y_AXIS_MAX_TARGET_KBPS = 14000 + +const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => { + const [newTitle, setNewTitle] = React.useState(profile.info.title) + const [newDescription, setNewDescription] = React.useState( + profile.info.description, + ) + const [streamData, setStreamData] = React.useState([]) + + const targetMaxBitrateBpsForQuality = React.useMemo( + () => (Y_AXIS_MAX_TARGET_KBPS * 1000) / 8, + [], + ) + + const signalQualityInfo = useStreamSignalQuality( + streamHealth, + targetMaxBitrateBpsForQuality, + ) + + React.useEffect(() => { + if ( + streamHealth && + signalQualityInfo.currentReceivedRateBps !== undefined && + signalQualityInfo.currentSentRateBps !== undefined + ) { + const newPoint = { + time: new Date(), + sentRate: signalQualityInfo.currentSentRateBps, + receivedRate: signalQualityInfo.currentReceivedRateBps, + } + + setStreamData((prevData) => + [...prevData, newPoint].slice(-MAX_DATA_POINTS), + ) + } + }, [ + streamHealth, + signalQualityInfo.currentSentRateBps, + signalQualityInfo.currentReceivedRateBps, + ]) + + async function saveProfileInfo() { + handleProfileUpdate("info", { + title: newTitle, + description: newDescription, + }) + } + + return ( +
+
+
+ + Information + +
+
+
+
+ + Title + +
+
+ setNewTitle(e.target.value)} + maxLength={50} + showCount + /> +
+
+ +
+
+ + Description + +
+
+ + setNewDescription(e.target.value) + } + maxLength={200} + showCount + /> +
+
+ +
+
+ + Offline Thumbnail + +

Displayed when the stream is offline

+
+
+ { + handleProfileUpdate("info", { + ...profile.info, + offline_thumbnail: response.url, + }) + }} + children={"Update"} + /> +
+
+ + +
+
+ +
+
+
+ Live Preview & Status +
+
+
+ Stream Status:{" "} + {streamHealth?.online ? ( + Online + ) : ( + Offline + )} +
+ +
+ {streamHealth?.online + ? "Video Preview Area" + : "Stream is Offline"} +
+
+
+ +
+
+
+

Network Stats

+ + + {signalQualityInfo.status} + +
+ + + {signalQualityInfo.message} + +
+ +
+
+
+ +
+
+ +
+
+ 0 + ? streamData[streamData.length - 1] + .sentRate + : 0 + } + formatter={formatBitrate} + /> +
+
+ 0 + ? streamData[streamData.length - 1] + .receivedRate + : 0 + } + formatter={formatBitrate} + /> +
+
+
+ +
+
+
+
+
+ ) +} + +export default Live diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less new file mode 100644 index 00000000..3add9d24 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/Live/index.less @@ -0,0 +1,72 @@ +.live-tab-layout { + display: flex; + flex-direction: column; + + width: 100%; + gap: 20px; + + .live-tab-grid { + display: grid; + gap: 20px; + + grid-template-columns: 1fr; + + @media (min-width: 769px) { + grid-template-columns: 1fr 1fr; + } + } + + .status-indicator__message { + font-size: 0.7rem; + } + + .live-tab-preview { + display: flex; + + align-items: center; + justify-content: center; + + width: 100%; + min-height: 200px; + + background-color: var(--background-color-primary); + + border: 1px solid var(--border-color, #e8e8e8); + border-radius: 4px; + + font-size: 1rem; + } + + .live-tab-stats { + display: grid; + gap: 16px; + + grid-template-columns: repeat(2, 1fr); + + @media (min-width: 769px) { + grid-template-columns: repeat(4, 1fr); + } + + .ant-statistic { + font-size: 1.2rem; + + .ant-statistic-title { + font-size: 0.8rem; + margin: 0; + } + + .ant-statistic-content { + height: fit-content; + } + .ant-statistic-content-value { + font-size: 1.2rem; + font-family: "DM Mono", monospace; + height: fit-content; + } + } + } + + .live-tab-chart { + width: 100%; + } +} diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx new file mode 100644 index 00000000..12b68a57 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/MediaUrls/index.jsx @@ -0,0 +1,134 @@ +import React from "react" +import * as antd from "antd" +import { FiLink } from "react-icons/fi" + +const MediaUrls = ({ profile }) => { + const { sources } = profile + + if (!sources || Object.keys(sources).length === 0) { + return null + } + + const { hls, rtsp, html } = sources + + const rtspt = rtsp ? rtsp.replace("rtsp://", "rtspt://") : null + + return ( +
+
+ + Medias + +
+ + {hls && ( +
+
+ HLS +
+ +
+

+ This protocol is highly compatible with a multitude + of devices and services. Recommended for general + use. +

+
+ +
+ + + {hls} + + +
+
+ )} + + {rtsp && ( +
+
+ RTSP [tcp] +
+
+

+ This protocol has the lowest possible latency and + the best quality. A compatible player is required. +

+
+
+ + + {rtsp} + + +
+
+ )} + + {rtspt && ( +
+
+ RTSPT [vrchat] +
+
+

+ This protocol has the lowest possible latency and + the best quality available. Only works for VRChat + video players. +

+
+
+ + + {rtspt} + + +
+
+ )} + + {html && ( +
+
+ HTML Viewer +
+
+

+ Share a link to easily view your stream on any + device with a web browser. +

+
+
+ + + {html} + + +
+
+ )} +
+ ) +} + +export default MediaUrls diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/RestreamManager/NewRestreamServerForm.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/RestreamManager/NewRestreamServerForm.jsx new file mode 100644 index 00000000..a3d46c19 --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/RestreamManager/NewRestreamServerForm.jsx @@ -0,0 +1,99 @@ +import React from "react" +import * as antd from "antd" +import { FiPlusCircle } from "react-icons/fi" +import Streaming from "@models/spectrum" + +const NewRestreamServerForm = ({ profile, loading, handleProfileUpdate }) => { + const [newRestreamHost, setNewRestreamHost] = React.useState("") + const [newRestreamKey, setNewRestreamKey] = React.useState("") + + async function handleAddRestream() { + if (!newRestreamHost || !newRestreamKey) { + antd.message.error("Host URL and Key are required.") + return + } + + if ( + !newRestreamHost.startsWith("rtmp://") && + !newRestreamHost.startsWith("rtsp://") + ) { + antd.message.error( + "Invalid host URL. Must start with rtmp:// or rtsp://", + ) + return + } + + try { + const updatedProfile = await Streaming.addRestreamToProfile( + profile._id, + { host: newRestreamHost, key: newRestreamKey }, + ) + if (updatedProfile && updatedProfile.restreams) { + handleProfileUpdate("restreams", updatedProfile.restreams) + setNewRestreamHost("") + setNewRestreamKey("") + antd.message.success("Restream server added successfully.") + } else { + antd.message.error( + "Failed to add restream server: No profile data returned from API.", + ) + } + } catch (err) { + console.error("Failed to add restream server:", err) + const errorMessage = + err.response?.data?.message || + err.message || + "An unknown error occurred while adding the restream server." + antd.message.error(errorMessage) + } + } + + return ( +
+
+ New server +

Add a new restream server to the list.

+
+ +
+ Host + setNewRestreamHost(e.target.value)} + disabled={loading} + /> +
+ +
+ Key + setNewRestreamKey(e.target.value)} + disabled={loading} + /> +
+ + } + > + Add Restream Server + + +

+ Please be aware! Pushing your stream to a malicious server could + be harmful, leading to data leaks and key stoling. +
Only use servers you trust. +

+
+ ) +} + +export default NewRestreamServerForm diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/RestreamManager/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/RestreamManager/index.jsx new file mode 100644 index 00000000..b489da4d --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/RestreamManager/index.jsx @@ -0,0 +1,127 @@ +import React from "react" +import * as antd from "antd" +import Streaming from "@models/spectrum" + +import HiddenText from "../../components/HiddenText" +import { FiXCircle } from "react-icons/fi" +import NewRestreamServerForm from "./NewRestreamServerForm" + +// Component to manage restream settings +const RestreamManager = ({ profile, loading, handleProfileUpdate }) => { + async function handleToggleRestreamEnabled(isEnabled) { + await handleProfileUpdate("options", { + ...profile.options, + restream: isEnabled, + }) + } + + async function handleDeleteRestream(indexToDelete) { + if (!profile || !profile._id) { + antd.message.error("Profile not loaded. Cannot delete restream.") + return + } + + try { + const updatedProfile = await Streaming.deleteRestreamFromProfile( + profile._id, + { index: indexToDelete }, + ) + if (updatedProfile && updatedProfile.restreams) { + handleProfileUpdate("restreams", updatedProfile.restreams) + antd.message.success("Restream server deleted successfully.") + } else { + antd.message.error( + "Failed to delete restream server: No profile data returned from API.", + ) + } + } catch (err) { + console.error("Failed to delete restream server:", err) + const errorMessage = + err.response?.data?.message || + err.message || + "An unknown error occurred while deleting the restream server." + antd.message.error(errorMessage) + } + } + + return ( + <> +
+
+
+
+ Enable Restreaming +

+ Allow this stream to be re-broadcasted to other + configured platforms. +

+

+ Only works if the stream is not in private mode. +

+
+ +
+ +

Must restart the livestream to apply changes

+
+
+
+
+ + {profile.options.restream && ( +
+
+ Customs servers +

View or modify the list of custom servers.

+
+ + {profile.restreams.map((item, index) => ( +
+
+ + {item.host} + +

+ {item.key + ? item.key.replace(/./g, "*") + : ""} +

+
+ +
+ } + danger + onClick={() => handleDeleteRestream(index)} + loading={loading} + > + Delete + +
+
+ ))} + + {profile.restreams.length === 0 && ( +
+ No restream servers configured. +
+ )} +
+ )} + + {profile.options.restream && ( + + )} + + ) +} + +export default RestreamManager diff --git a/packages/app/src/pages/studio/tv/[profile_id]/tabs/StreamConfiguration/index.jsx b/packages/app/src/pages/studio/tv/[profile_id]/tabs/StreamConfiguration/index.jsx new file mode 100644 index 00000000..347beeca --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/tabs/StreamConfiguration/index.jsx @@ -0,0 +1,116 @@ +import React from "react" +import * as antd from "antd" +import HiddenText from "../../components/HiddenText" + +import { IoMdEyeOff } from "react-icons/io" +import { GrStorage, GrConfigure } from "react-icons/gr" +import { MdOutlineWifiTethering } from "react-icons/md" + +const StreamConfiguration = ({ profile, loading, handleProfileUpdate }) => { + return ( + <> +
+
+ + Server +
+ +
+
+ Ingestion URL +
+ +
+ + + {profile.ingestion_url} + + +
+
+ +
+
+ Stream Key +
+ +
+ +
+
+
+ +
+
+ + Options +
+ +
+
+ + Private Mode + +
+
+

+ When this is enabled, only users with the livestream + url can access the stream. +

+
+
+ + handleProfileUpdate("options", { + ...profile.options, + private: checked, + }) + } + /> + +

+ Must restart the livestream to apply changes +

+
+
+ +
+
+ + + DVR [beta] + +
+
+

+ Save a copy of your stream with its entire duration. + You can download this copy after finishing this + livestream. +

+
+
+ + handleProfileUpdate("options", { + ...profile.options, + dvr: checked, + }) + } + disabled + /> +
+
+
+ + ) +} + +export default StreamConfiguration diff --git a/packages/app/src/pages/studio/tv/[profile_id]/useStreamSignalQuality.js b/packages/app/src/pages/studio/tv/[profile_id]/useStreamSignalQuality.js new file mode 100644 index 00000000..b9784c0a --- /dev/null +++ b/packages/app/src/pages/studio/tv/[profile_id]/useStreamSignalQuality.js @@ -0,0 +1,124 @@ +import { useState, useEffect, useRef } from "react" + +const SMA_WINDOW_SIZE = 10 +const FLUCTUATION_THRESHOLD_PERCENT = 50 + +export const useStreamSignalQuality = (streamHealth, targetMaxBitrateBps) => { + const [signalQuality, setSignalQuality] = useState({ + status: "Calculating...", + message: "Waiting for stream data to assess stability.", + color: "orange", + currentReceivedRateBps: 0, + currentSentRateBps: 0, + }) + + const previousSampleRef = useRef(null) + const receivedBitrateHistoryRef = useRef([]) + + useEffect(() => { + if ( + streamHealth && + typeof streamHealth.bytesSent === "number" && + typeof streamHealth.bytesReceived === "number" + ) { + const currentTime = new Date() + + let calculatedSentRateBps = 0 + let calculatedReceivedRateBps = 0 + + if (previousSampleRef.current) { + const timeDiffSeconds = + (currentTime.getTime() - + previousSampleRef.current.time.getTime()) / + 1000 + + if (timeDiffSeconds > 0.1) { + calculatedSentRateBps = Math.max( + 0, + (streamHealth.bytesSent - + previousSampleRef.current.totalBytesSent) / + timeDiffSeconds, + ) + calculatedReceivedRateBps = Math.max( + 0, + (streamHealth.bytesReceived - + previousSampleRef.current.totalBytesReceived) / + timeDiffSeconds, + ) + } + } + + const newHistory = [ + ...receivedBitrateHistoryRef.current, + calculatedReceivedRateBps, + ].slice(-SMA_WINDOW_SIZE) + + receivedBitrateHistoryRef.current = newHistory + + let newStatus = "Calculating..." + let newMessage = `Gathering incoming stream data (${newHistory.length}/${SMA_WINDOW_SIZE})...` + let newColor = "geekblue" + + if (newHistory.length >= SMA_WINDOW_SIZE / 2) { + const sum = newHistory.reduce((acc, val) => acc + val, 0) + const sma = sum / newHistory.length + + if (sma > 0) { + const fluctuationPercent = + (Math.abs(calculatedReceivedRateBps - sma) / sma) * 100 + + if (fluctuationPercent > FLUCTUATION_THRESHOLD_PERCENT) { + newStatus = "Unstable" + newMessage = `Incoming bitrate fluctuating significantly (±${fluctuationPercent.toFixed(0)}%).` + newColor = "red" + } else if ( + calculatedReceivedRateBps < + targetMaxBitrateBps * 0.1 + ) { + newStatus = "Low Incoming Bitrate" + newMessage = "Incoming stream bitrate is very low." + newColor = "orange" + } else { + newStatus = "Good" + newMessage = "Incoming stream appears stable." + newColor = "green" + } + } else if (calculatedReceivedRateBps > 0) { + newStatus = "Good" + newMessage = "Incoming stream started." + newColor = "green" + } else { + newStatus = "No Incoming Data" + newMessage = "No incoming data transmission detected." + newColor = "red" + } + } + + setSignalQuality({ + status: newStatus, + message: newMessage, + color: newColor, + currentReceivedRateBps: calculatedReceivedRateBps, + currentSentRateBps: calculatedSentRateBps, + }) + + previousSampleRef.current = { + time: currentTime, + totalBytesSent: streamHealth.bytesSent, + totalBytesReceived: streamHealth.bytesReceived, + } + } else { + setSignalQuality({ + status: "No Data", + message: "Stream health information is not available.", + color: "grey", + currentReceivedRateBps: 0, + currentSentRateBps: 0, + }) + previousSampleRef.current = null + receivedBitrateHistoryRef.current = [] + } + }, [streamHealth, targetMaxBitrateBps]) + + return signalQuality +} diff --git a/packages/app/src/pages/studio/tv/components/EditableText/index.jsx b/packages/app/src/pages/studio/tv/components/EditableText/index.jsx deleted file mode 100644 index 73de60a8..00000000 --- a/packages/app/src/pages/studio/tv/components/EditableText/index.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react" -import * as antd from "antd" -import classnames from "classnames" - -import { MdSave, MdEdit, MdClose } from "react-icons/md" - -import "./index.less" - -const EditableText = (props) => { - const [loading, setLoading] = React.useState(false) - const [isEditing, setEditing] = React.useState(false) - const [value, setValue] = React.useState(props.value) - - async function handleSave(newValue) { - setLoading(true) - - if (typeof props.onSave === "function") { - await props.onSave(newValue) - - setEditing(false) - setLoading(false) - } else { - setValue(newValue) - setLoading(false) - } - } - - function handleCancel() { - setValue(props.value) - setEditing(false) - } - - React.useEffect(() => { - setValue(props.value) - }, [props.value]) - - return
- { - !isEditing && setEditing(true)} - className="editable-text-value" - > - - - {value} - - } - { - isEditing &&
- setValue(e.target.value)} - loading={loading} - disabled={loading} - onPressEnter={() => handleSave(value)} - /> - handleSave(value)} - icon={} - loading={loading} - disabled={loading} - size="small" - /> - } - size="small" - /> -
- } -
-} - -export default EditableText \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/EditableText/index.less b/packages/app/src/pages/studio/tv/components/EditableText/index.less deleted file mode 100644 index 21fd0046..00000000 --- a/packages/app/src/pages/studio/tv/components/EditableText/index.less +++ /dev/null @@ -1,49 +0,0 @@ -.editable-text { - border-radius: 12px; - - font-size: 14px; - - --fontSize: 14px; - --fontWeight: normal; - - font-family: "DM Mono", sans-serif; - - .editable-text-value { - display: flex; - flex-direction: row; - - align-items: center; - - gap: 7px; - - font-size: var(--fontSize); - font-weight: var(--fontWeight); - - svg { - font-size: 1rem; - opacity: 0.6; - } - } - - .editable-text-input-container { - display: flex; - flex-direction: row; - - align-items: center; - - gap: 10px; - - font-family: "DM Mono", sans-serif; - - .ant-input { - background-color: transparent; - - font-family: "DM Mono", sans-serif; - - font-size: var(--fontSize); - font-weight: var(--fontWeight); - - padding: 0 10px; - } - } -} \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/HiddenText/index.jsx b/packages/app/src/pages/studio/tv/components/HiddenText/index.jsx deleted file mode 100644 index c1e84976..00000000 --- a/packages/app/src/pages/studio/tv/components/HiddenText/index.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react" -import * as antd from "antd" - -import { IoMdClipboard, IoMdEye, IoMdEyeOff } from "react-icons/io" - -const HiddenText = (props) => { - const [visible, setVisible] = React.useState(false) - - function copyToClipboard() { - try { - navigator.clipboard.writeText(props.value) - antd.message.success("Copied to clipboard") - } catch (error) { - console.error(error) - antd.message.error("Failed to copy to clipboard") - } - } - - return
- } - type="ghost" - size="small" - onClick={copyToClipboard} - /> - - - { - visible ? props.value : "********" - } - - - : } - type="ghost" - size="small" - onClick={() => setVisible(!visible)} - /> -
-} - -export default HiddenText \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileConnection/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileConnection/index.jsx deleted file mode 100644 index c78ca741..00000000 --- a/packages/app/src/pages/studio/tv/components/ProfileConnection/index.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react" -import * as antd from "antd" - -import useRequest from "comty.js/hooks/useRequest" -import Streaming from "@models/spectrum" - -const ProfileConnection = (props) => { - const [loading, result, error, repeat] = useRequest(Streaming.getConnectionStatus, { - profile_id: props.profile_id - }) - - React.useEffect(() => { - repeat({ - profile_id: props.profile_id - }) - }, [props.profile_id]) - - if (error) { - return - Disconnected - - } - - if (loading) { - return - Loading - - } - - return - Connected - -} - -export default ProfileConnection \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx index 5d4c0c90..ccb587d2 100644 --- a/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx +++ b/packages/app/src/pages/studio/tv/components/ProfileCreator/index.jsx @@ -21,7 +21,7 @@ const ProfileCreator = (props) => { await props.onEdit(name) } } else { - const result = await Streaming.createOrUpdateProfile({ + const result = await Streaming.createProfile({ profile_name: name, }).catch((error) => { console.error(error) diff --git a/packages/app/src/pages/studio/tv/components/ProfileData/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileData/index.jsx deleted file mode 100644 index db2cef85..00000000 --- a/packages/app/src/pages/studio/tv/components/ProfileData/index.jsx +++ /dev/null @@ -1,360 +0,0 @@ -import React from "react" -import * as antd from "antd" - -import Streaming from "@models/spectrum" - -import EditableText from "../EditableText" -import HiddenText from "../HiddenText" -import ProfileCreator from "../ProfileCreator" - -import { MdOutlineWifiTethering } from "react-icons/md" -import { IoMdEyeOff } from "react-icons/io" -import { GrStorage, GrConfigure } from "react-icons/gr" -import { FiLink } from "react-icons/fi" - -import "./index.less" - -const ProfileData = (props) => { - if (!props.profile_id) { - return null - } - - const [loading, setLoading] = React.useState(false) - const [fetching, setFetching] = React.useState(true) - const [error, setError] = React.useState(null) - const [profile, setProfile] = React.useState(null) - - async function fetchData(profile_id) { - setFetching(true) - - const result = await Streaming.getProfile({ profile_id }).catch( - (error) => { - console.error(error) - setError(error) - return null - }, - ) - - if (result) { - setProfile(result) - } - - setFetching(false) - } - - async function handleChange(key, value) { - setLoading(true) - - const result = await Streaming.createOrUpdateProfile({ - [key]: value, - _id: profile._id, - }).catch((error) => { - console.error(error) - antd.message.error("Failed to update") - return false - }) - - if (result) { - antd.message.success("Updated") - setProfile(result) - } - - setLoading(false) - } - - async function handleDelete() { - setLoading(true) - - const result = await Streaming.deleteProfile({ - profile_id: profile._id, - }).catch((error) => { - console.error(error) - antd.message.error("Failed to delete") - return false - }) - - if (result) { - antd.message.success("Deleted") - app.eventBus.emit("app:profile_deleted", profile._id) - } - - setLoading(false) - } - - async function handleEditName() { - app.layout.modal.open("name_editor", ProfileCreator, { - props: { - editValue: profile.profile_name, - onEdit: async (value) => { - await handleChange("profile_name", value) - app.eventBus.emit("app:profiles_updated", profile._id) - }, - }, - }) - } - - React.useEffect(() => { - fetchData(props.profile_id) - }, [props.profile_id]) - - if (error) { - return ( - fetchData(props.profile_id)} - > - Retry - , - ]} - /> - ) - } - - if (fetching) { - return - } - - return ( -
-
- -
- { - return handleChange("title", newValue) - }} - disabled={loading} - /> - { - return handleChange("description", newValue) - }} - disabled={loading} - /> -
-
- -
-
- - Server -
- -
-
- Ingestion URL -
- -
- {profile.ingestion_url} -
-
- -
-
- Stream Key -
- -
- -
-
-
- -
-
- - Configuration -
- -
-
- - Private Mode -
- -
-

- When this is enabled, only users with the livestream - url can access the stream. -

-
- -
- handleChange("private", value)} - /> -
- -
-

- Must restart the livestream to apply changes -

-
-
- -
-
- - DVR [beta] -
- -
-

- Save a copy of your stream with its entire duration. - You can download this copy after finishing this - livestream. -

-
- -
- -
-
-
- - {profile.sources && ( -
-
- - Media URL -
- -
-
- HLS -
- -
-

- This protocol is highly compatible with a - multitude of devices and services. Recommended - for general use. -

-
- -
- {profile.sources.hls} -
-
-
-
- RTSP [tcp] -
- -
-

- This protocol has the lowest possible latency - and the best quality. A compatible player is - required. -

-
- -
- {profile.sources.rtsp} -
-
-
-
- RTSPT [vrchat] -
- -
-

- This protocol has the lowest possible latency - and the best quality available. Only works for - VRChat video players. -

-
- -
- - {profile.sources.rtsp.replace( - "rtsp://", - "rtspt://", - )} - -
-
-
-
- HTML Viewer -
- -
-

- Share a link to easily view your stream on any - device with a web browser. -

-
- -
- {profile.sources.html} -
-
-
- )} - -
-
- Other -
- -
-
- Delete profile -
- -
- - - Delete - - -
-
- -
-
- Change profile name -
- -
- - Change - -
-
-
-
- ) -} - -export default ProfileData diff --git a/packages/app/src/pages/studio/tv/components/ProfileData/index.less b/packages/app/src/pages/studio/tv/components/ProfileData/index.less deleted file mode 100644 index 7eacbff4..00000000 --- a/packages/app/src/pages/studio/tv/components/ProfileData/index.less +++ /dev/null @@ -1,66 +0,0 @@ -.tvstudio-profile-data { - display: flex; - flex-direction: column; - - gap: 20px; - - .tvstudio-profile-data-header { - position: relative; - - max-height: 200px; - - background-repeat: no-repeat; - background-position: center; - background-size: cover; - - border-radius: 12px; - - overflow: hidden; - - .tvstudio-profile-data-header-image { - position: absolute; - - left: 0; - top: 0; - - z-index: 10; - - width: 100%; - } - - .tvstudio-profile-data-header-content { - position: relative; - - display: flex; - flex-direction: column; - - z-index: 20; - - padding: 30px 10px; - - gap: 5px; - - background-color: rgba(0, 0, 0, 0.5); - } - } - - .tvstudio-profile-data-field { - display: flex; - flex-direction: column; - - gap: 10px; - - .tvstudio-profile-data-field-header { - display: flex; - flex-direction: row; - - align-items: center; - - gap: 10px; - - span { - font-size: 1.5rem; - } - } - } -} \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/components/ProfileSelector/index.jsx b/packages/app/src/pages/studio/tv/components/ProfileSelector/index.jsx deleted file mode 100644 index af28055b..00000000 --- a/packages/app/src/pages/studio/tv/components/ProfileSelector/index.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import React from "react" -import * as antd from "antd" - -import Streaming from "@models/spectrum" - -const ProfileSelector = (props) => { - const [loading, list, error, repeat] = app.cores.api.useRequest(Streaming.getOwnProfiles) - const [selectedProfileId, setSelectedProfileId] = React.useState(null) - - function handleOnChange(value) { - if (typeof props.onChange === "function") { - props.onChange(value) - } - - setSelectedProfileId(value) - } - - const handleOnCreateNewProfile = async (data) => { - await repeat() - handleOnChange(data._id) - } - - const handleOnDeletedProfile = async (profile_id) => { - await repeat() - handleOnChange(list[0]._id) - } - - React.useEffect(() => { - app.eventBus.on("app:new_profile", handleOnCreateNewProfile) - app.eventBus.on("app:profile_deleted", handleOnDeletedProfile) - app.eventBus.on("app:profiles_updated", repeat) - - return () => { - app.eventBus.off("app:new_profile", handleOnCreateNewProfile) - app.eventBus.off("app:profile_deleted", handleOnDeletedProfile) - app.eventBus.off("app:profiles_updated", repeat) - } - }, []) - - if (error) { - return - Retry - - ]} - /> - } - - if (loading) { - return - } - - return - { - list.map((profile) => { - return - {profile.profile_name ?? String(profile._id)} - - }) - } - -} - -//const ProfileSelectorForwardRef = React.forwardRef(ProfileSelector) - -export default ProfileSelector \ No newline at end of file diff --git a/packages/app/src/pages/studio/tv/index.jsx b/packages/app/src/pages/studio/tv/index.jsx index ae4c7500..03db7019 100644 --- a/packages/app/src/pages/studio/tv/index.jsx +++ b/packages/app/src/pages/studio/tv/index.jsx @@ -1,57 +1,64 @@ import React from "react" import * as antd from "antd" -import ProfileSelector from "./components/ProfileSelector" -import ProfileData from "./components/ProfileData" import ProfileCreator from "./components/ProfileCreator" +import Skeleton from "@components/Skeleton" + +import Streaming from "@models/spectrum" import useCenteredContainer from "@hooks/useCenteredContainer" import "./index.less" -const TVStudioPage = (props) => { - useCenteredContainer(true) - - const [selectedProfileId, setSelectedProfileId] = React.useState(null) - - function newProfileModal() { - app.layout.modal.open("tv_profile_creator", ProfileCreator, { - props: { - onCreate: (id, data) => { - setSelectedProfileId(id) - }, - } - }) - } - - return
-
- - - - Create new - -
- - { - selectedProfileId && - } - - { - !selectedProfileId &&
-

- Select profile or create new -

-
- } -
+const Profile = ({ profile, onClick }) => { + return
{profile.profile_name}
} -export default TVStudioPage \ No newline at end of file +const TVStudioPage = (props) => { + useCenteredContainer(false) + + const [loading, list, error, repeat] = app.cores.api.useRequest( + Streaming.getOwnProfiles, + ) + + function handleNewProfileClick() { + app.layout.modal.open("tv_profile_creator", ProfileCreator, { + props: { + onCreate: (id, data) => { + setSelectedProfileId(id) + }, + }, + }) + } + + function handleProfileClick(id) { + app.location.push(`/studio/tv/${id}`) + } + + if (loading) { + return + } + + return ( +
+
+ + Create new + +
+ + {list.length > 0 && + list.map((profile, index) => { + return ( + handleProfileClick(profile._id)} + /> + ) + })} +
+ ) +} + +export default TVStudioPage diff --git a/packages/app/src/pages/timeline/tabs.jsx b/packages/app/src/pages/timeline/tabs.jsx index edd74531..d786f50f 100755 --- a/packages/app/src/pages/timeline/tabs.jsx +++ b/packages/app/src/pages/timeline/tabs.jsx @@ -3,22 +3,22 @@ import GlobalTab from "./components/global" import SavedPostsTab from "./components/savedPosts" export default [ - { - key: "feed", - label: "Feed", - icon: "IoMdPaper", - component: FeedTab - }, - { - key: "global", - label: "Global", - icon: "FiGlobe", - component: GlobalTab - }, - { - key: "savedPosts", - label: "Saved posts", - icon: "FiBookmark", - component: SavedPostsTab - } -] \ No newline at end of file + { + key: "feed", + label: "Feed", + icon: "IoMdPaper", + component: FeedTab, + }, + { + key: "global", + label: "Global", + icon: "FiGlobe", + component: GlobalTab, + }, + { + key: "savedPosts", + label: "Saved", + icon: "FiBookmark", + component: SavedPostsTab, + }, +] diff --git a/packages/app/src/settings/components/sessionItem/index.jsx b/packages/app/src/settings/components/sessionItem/index.jsx index 96295cff..7a54276f 100755 --- a/packages/app/src/settings/components/sessionItem/index.jsx +++ b/packages/app/src/settings/components/sessionItem/index.jsx @@ -16,129 +16,122 @@ import FirefoxIcon from "./icons/firefox" import "./index.less" const DeviceIcon = (props) => { - if (!props.ua) { - return null - } + if (!props.ua) { + return null + } - if (props.ua.ua === "capacitor") { - return - } + if (props.ua.ua === "capacitor") { + return + } - switch (props.ua.browser.name) { - case "Chrome": { - return - } - case "Firefox": { - return - } - default: { - return - } - } + switch (props.ua.browser.name) { + case "Chrome": { + return + } + case "Firefox": { + return + } + default: { + return + } + } } const SessionItem = (props) => { - const { session } = props + const { session } = props - const [collapsed, setCollapsed] = React.useState(true) + const [collapsed, setCollapsed] = React.useState(true) - const onClickCollapse = () => { - setCollapsed((prev) => { - return !prev - }) - } + const onClickCollapse = () => { + setCollapsed((prev) => { + return !prev + }) + } - const onClickRevoke = () => { - // if (typeof props.onClickRevoke === "function") { - // props.onClickRevoke(session) - // } - } + const onClickRevoke = () => { + // if (typeof props.onClickRevoke === "function") { + // props.onClickRevoke(session) + // } + } - const isCurrentSession = React.useMemo(() => { - const currentUUID = SessionModel.session_uuid - return session.session_uuid === currentUUID - }) + const isCurrentSession = React.useMemo(() => { + const currentUUID = SessionModel.session_uuid + return session.session_uuid === currentUUID + }) - const ua = React.useMemo(() => { - return UAParser(session.client) - }) + const ua = React.useMemo(() => { + return UAParser(session.client) + }) - return
-
-
- -
+ return ( +
+
+
+ +
- -
-
-

{session.session_uuid}

-
+ +
+
+

+ {session._id} +

+
-
-
- +
+
+ - - {moment(session.date).format("DD/MM/YYYY HH:mm")} - -
-
- + + {moment(session.date).format( + "DD/MM/YYYY HH:mm", + )} + +
+
+ - - {session.ip_address} - -
-
-
- -
+ {session.ip_address} +
+
+
+ +
-
-
- - Revoke - -
+
+
+ + Revoke + +
-
- +
+ - - {session.location} - -
+ {session.location} +
- { - ua.device.vendor &&
- + {ua.device.vendor && ( +
+ - - {ua.device.vendor} | {ua.device.model} - -
- } -
-
+ + {ua.device.vendor} | {ua.device.model} + +
+ )} +
+
+ ) } -export default SessionItem \ No newline at end of file +export default SessionItem diff --git a/packages/app/src/settings/components/sessions/index.jsx b/packages/app/src/settings/components/sessions/index.jsx index eece9a3a..5629653b 100755 --- a/packages/app/src/settings/components/sessions/index.jsx +++ b/packages/app/src/settings/components/sessions/index.jsx @@ -8,70 +8,80 @@ import SessionModel from "@models/session" import "./index.less" export default () => { - const [loading, setLoading] = React.useState(true) - const [sessions, setSessions] = React.useState([]) - const [sessionsPage, setSessionsPage] = React.useState(1) - const [itemsPerPage, setItemsPerPage] = React.useState(3) + const [loading, setLoading] = React.useState(true) + const [sessions, setSessions] = React.useState([]) + const [sessionsPage, setSessionsPage] = React.useState(1) + const [itemsPerPage, setItemsPerPage] = React.useState(3) - const loadSessions = async () => { - setLoading(true) + const loadSessions = async () => { + setLoading(true) - const response = await SessionModel.getAllSessions().catch((err) => { - console.error(err) - app.message.error("Failed to load sessions") - return null - }) + const response = await SessionModel.getAllSessions().catch((err) => { + console.error(err) + app.message.error("Failed to load sessions") + return null + }) - if (response) { - setSessions(response) - } + if (response) { + setSessions(response) + } - setLoading(false) - } + setLoading(false) + } - const onClickRevoke = async (session) => { - console.log(session) + const onClickRevoke = async (session) => { + console.log(session) - app.message.warning("Not implemented yet") - } + app.message.warning("Not implemented yet") + } - const onClickRevokeAll = async () => { - app.message.warning("Not implemented yet") - } + const onClickDestroyAll = async () => { + app.layout.modal.confirm({ + headerText: "Are you sure you want to delete this release?", + descriptionText: "This action cannot be undone.", + onConfirm: async () => { + await SessionModel.destroyAll() + await app.auth.logout(true) + }, + }) + } - React.useEffect(() => { - loadSessions() - }, []) + React.useEffect(() => { + loadSessions() + }, []) - if (loading) { - return - } + if (loading) { + return + } - const offset = (sessionsPage - 1) * itemsPerPage - const slicedItems = sessions.slice(offset, offset + itemsPerPage) + const offset = (sessionsPage - 1) * itemsPerPage + const slicedItems = sessions.slice(offset, offset + itemsPerPage) - return
-
- { - slicedItems.map((session) => { - return - }) - } + return ( +
+
+ {slicedItems.map((session) => { + return ( + + ) + })} - { - setSessionsPage(page) - }} - total={sessions.length} - showTotal={(total) => { - return `${total} Sessions` - }} - simple - /> -
-
-} \ No newline at end of file + { + setSessionsPage(page) + }} + total={sessions.length} + showTotal={(total) => { + return `${total} Sessions` + }} + simple + /> +
+ Destroy all +
+ ) +} diff --git a/packages/app/src/utils/arrayBufferToBase64/index.js b/packages/app/src/utils/arrayBufferToBase64/index.js new file mode 100644 index 00000000..cded6971 --- /dev/null +++ b/packages/app/src/utils/arrayBufferToBase64/index.js @@ -0,0 +1,11 @@ +export default (buffer) => { + const bytes = new Uint8Array(buffer) + + let binary = "" + + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + + return window.btoa(binary) +} diff --git a/packages/app/src/utils/base64ToArrayBuffer/index.js b/packages/app/src/utils/base64ToArrayBuffer/index.js new file mode 100644 index 00000000..45fbbd9e --- /dev/null +++ b/packages/app/src/utils/base64ToArrayBuffer/index.js @@ -0,0 +1,11 @@ +export default (base64) => { + const binaryString = window.atob(base64) + + const bytes = new Uint8Array(binaryString.length) + + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + return bytes +} diff --git a/packages/server/boot b/packages/server/boot index bf184b5d..8d569ad0 100755 --- a/packages/server/boot +++ b/packages/server/boot @@ -117,7 +117,7 @@ function registerAliases() { registerBaseAliases(global["__src"], global["aliases"]) } -async function injectEnvFromInfisical() { +global.injectEnvFromInfisical = async function injectEnvFromInfisical() { const envMode = (global.FORCE_ENV ?? global.isProduction) ? "prod" : "dev" console.log( diff --git a/packages/server/classes/SegmentedAudioMPDJob/index.js b/packages/server/classes/SegmentedAudioMPDJob/index.js index 365660ca..3c613d94 100644 --- a/packages/server/classes/SegmentedAudioMPDJob/index.js +++ b/packages/server/classes/SegmentedAudioMPDJob/index.js @@ -13,6 +13,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { audioBitrate: "320k", audioSampleRate: "48000", segmentTime: 10, + minBufferTime: 5, includeMetadata: true, ...params, } @@ -20,7 +21,6 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { buildSegmentationArgs = () => { const args = [ - //`-threads 1`, // limits to one thread `-v error -hide_banner -progress pipe:1`, `-i ${this.params.input}`, `-c:a ${this.params.audioCodec}`, @@ -56,6 +56,39 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { return args } + _updateMpdMinBufferTime = async (mpdPath, newMinBufferTimeSecs) => { + try { + const mpdTagRegex = /(]*)/ + let mpdContent = await fs.promises.readFile(mpdPath, "utf-8") + + const minBufferTimeAttribute = `minBufferTime="PT${newMinBufferTimeSecs}.0S"` + const existingMinBufferTimeRegex = + /(]*minBufferTime=")[^"]*(")/ + + if (existingMinBufferTimeRegex.test(mpdContent)) { + mpdContent = mpdContent.replace( + existingMinBufferTimeRegex, + `$1PT${newMinBufferTimeSecs}.0S$2`, + ) + await fs.promises.writeFile(mpdPath, mpdContent, "utf-8") + } else { + if (mpdTagRegex.test(mpdContent)) { + mpdContent = mpdContent.replace( + mpdTagRegex, + `$1 ${minBufferTimeAttribute}`, + ) + + await fs.promises.writeFile(mpdPath, mpdContent, "utf-8") + } + } + } catch (error) { + console.error( + `[SegmentedAudioMPDJob] Error updating MPD minBufferTime for ${mpdPath}:`, + error, + ) + } + } + run = async () => { const segmentationCmd = this.buildSegmentationArgs() const outputPath = @@ -75,7 +108,7 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { const inputProbe = await Utils.probe(this.params.input) try { - const result = await this.ffmpeg({ + const ffmpegResult = await this.ffmpeg({ args: segmentationCmd, onProcess: (process) => { this.handleProgress( @@ -89,6 +122,17 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { cwd: outputPath, }) + if (fs.existsSync(outputFile)) { + await this._updateMpdMinBufferTime( + outputFile, + this.params.minBufferTime, + ) + } else { + console.warn( + `[SegmentedAudioMPDJob] MPD file ${outputFile} not found after ffmpeg run. Skipping minBufferTime update.`, + ) + } + let outputProbe = await Utils.probe(outputFile) this.emit("end", { @@ -100,9 +144,9 @@ export default class SegmentedAudioMPDJob extends FFMPEGLib { outputFile: outputFile, }) - return result + return ffmpegResult } catch (err) { - return this.emit("error", err) + this.emit("error", err) } } } diff --git a/packages/server/db_models/chatKey/index.js b/packages/server/db_models/chatKey/index.js new file mode 100644 index 00000000..5794ecc9 --- /dev/null +++ b/packages/server/db_models/chatKey/index.js @@ -0,0 +1,40 @@ +export default { + name: "ChatKey", + collection: "chat_keys", + schema: { + user_id_1: { + type: String, + required: true, + }, + user_id_2: { + type: String, + required: true, + }, + encrypted_key_1: { + type: String, + required: true, + }, + encrypted_key_2: { + type: String, + default: null, + }, + created_at: { + type: Number, + default: () => new Date().getTime(), + }, + updated_at: { + type: Number, + default: () => new Date().getTime(), + }, + }, + extend: { + async findByUsers(user1, user2) { + return await this.findOne({ + $or: [ + { user_id_1: user1, user_id_2: user2 }, + { user_id_1: user2, user_id_2: user1 }, + ], + }) + }, + }, +} diff --git a/packages/server/db_models/chatMessage/index.js b/packages/server/db_models/chatMessage/index.js index d31b79ab..4557b66d 100644 --- a/packages/server/db_models/chatMessage/index.js +++ b/packages/server/db_models/chatMessage/index.js @@ -1,11 +1,15 @@ export default { - name: "ChatMessage", - collection: "chats_messages", - schema: { - type: { type: String, required: true }, - from_user_id: { type: String, required: true }, - to_user_id: { type: String, required: true }, - content: { type: String, required: true }, - created_at: { type: Date, required: true }, - } -} \ No newline at end of file + name: "ChatMessage", + collection: "chats_messages", + schema: { + type: { type: String, required: true }, + from_user_id: { type: String, required: true }, + to_user_id: { type: String, required: true }, + content: { type: String, required: true }, + created_at: { type: Date, required: true }, + encrypted: { + type: Boolean, + default: false, + }, + }, +} diff --git a/packages/server/db_models/index.js b/packages/server/db_models/index.js index 159b6d34..31dfac21 100755 --- a/packages/server/db_models/index.js +++ b/packages/server/db_models/index.js @@ -3,21 +3,33 @@ import fs from "fs" import path from "path" function generateModels() { - let models = {} + let models = {} - const dirs = fs.readdirSync(__dirname).filter(file => file !== "index.js") + const dirs = fs.readdirSync(__dirname).filter((file) => file !== "index.js") - dirs.forEach((file) => { - const model = require(path.join(__dirname, file)).default + dirs.forEach((file) => { + const model = require(path.join(__dirname, file)).default - if (mongoose.models[model.name]) { - return models[model.name] = mongoose.model(model.name) - } + if (mongoose.models[model.name]) { + return (models[model.name] = mongoose.model(model.name)) + } - return models[model.name] = mongoose.model(model.name, new Schema(model.schema), model.collection) - }) + model.schema = new Schema(model.schema) - return models + if (model.extend) { + Object.keys(model.extend).forEach((key) => { + model.schema.statics[key] = model.extend[key] + }) + } + + return (models[model.name] = mongoose.model( + model.name, + model.schema, + model.collection, + )) + }) + + return models } -module.exports = generateModels() \ No newline at end of file +module.exports = generateModels() diff --git a/packages/server/db_models/musicLibraryItem/index.js b/packages/server/db_models/musicLibraryItem/index.js new file mode 100644 index 00000000..60ec34ef --- /dev/null +++ b/packages/server/db_models/musicLibraryItem/index.js @@ -0,0 +1,23 @@ +export default { + name: "MusicLibraryItem", + collection: "music_library_items", + schema: { + user_id: { + type: String, + required: true, + }, + item_id: { + type: String, + required: true, + }, + kind: { + type: String, + required: true, + enum: ["tracks", "playlists", "releases"], + }, + created_at: { + type: Date, + required: true, + }, + }, +} diff --git a/packages/server/db_models/playlist/index.js b/packages/server/db_models/playlist/index.js index 6b9a9cf9..a771ddf4 100755 --- a/packages/server/db_models/playlist/index.js +++ b/packages/server/db_models/playlist/index.js @@ -1,41 +1,43 @@ export default { - name: "Playlist", - collection: "playlists", - schema: { - user_id: { - type: String, - required: true - }, - title: { - type: String, - required: true - }, - description: { - type: String - }, - list: { - type: Object, - default: [], - required: true - }, - cover: { - type: String, - default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" - }, - thumbnail: { - type: String, - default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" - }, - created_at: { - type: Date, - required: true - }, - publisher: { - type: Object, - }, - public: { - type: Boolean, - default: true, - }, - } -} \ No newline at end of file + name: "Playlist", + collection: "playlists", + schema: { + user_id: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + }, + list: { + type: Object, + default: [], + required: true, + }, + cover: { + type: String, + default: + "https://storage.ragestudio.net/comty-static-assets/default_song.png", + }, + thumbnail: { + type: String, + default: + "https://storage.ragestudio.net/comty-static-assets/default_song.png", + }, + created_at: { + type: Date, + required: true, + }, + publisher: { + type: Object, + }, + public: { + type: Boolean, + default: true, + }, + }, +} diff --git a/packages/server/db_models/track/index.js b/packages/server/db_models/track/index.js index 7a440e0e..0c7789a5 100755 --- a/packages/server/db_models/track/index.js +++ b/packages/server/db_models/track/index.js @@ -27,8 +27,9 @@ export default { type: Boolean, default: true, }, - publish_date: { + created_at: { type: Date, + required: true, }, cover: { type: String, diff --git a/packages/server/db_models/userChat/index.js b/packages/server/db_models/userChat/index.js new file mode 100644 index 00000000..efe63b69 --- /dev/null +++ b/packages/server/db_models/userChat/index.js @@ -0,0 +1,23 @@ +export default { + name: "UserChat", + collection: "user_chats", + schema: { + user_1: { + type: Object, + required: true, + }, + user_2: { + type: Object, + required: true, + }, + started_at: { + type: Number, + default: () => new Date().getTime(), + }, + updated_at: { + type: Number, + default: () => new Date().getTime(), + }, + // ... set other things like themes, or more info + }, +} diff --git a/packages/server/db_models/userDHPair/index.js b/packages/server/db_models/userDHPair/index.js new file mode 100644 index 00000000..1b6ad33d --- /dev/null +++ b/packages/server/db_models/userDHPair/index.js @@ -0,0 +1,15 @@ +export default { + name: "UserDHKeyPair", + collection: "user_dh_key_pairs", + schema: { + user_id: { + type: String, + required: true, + unique: true, + }, + str: { + type: String, + required: true, + }, + }, +} diff --git a/packages/server/scripts/migrations/removeDuplicateTracks.js b/packages/server/scripts/migrations/removeDuplicateTracks.js new file mode 100644 index 00000000..4b954192 --- /dev/null +++ b/packages/server/scripts/migrations/removeDuplicateTracks.js @@ -0,0 +1,143 @@ +import DbManager from "@shared-classes/DbManager" +import { Track } from "@db_models" +import axios from "axios" + +async function main() { + await global.injectEnvFromInfisical() + + const db = new DbManager() + await db.initialize() + + const tracks = await Track.find() + + console.log(`Total tracks in database: ${tracks.length}`) + + // Group tracks by ETag + const tracksByETag = new Map() + + for (const track of tracks) { + if ( + !track.source || + typeof track.source !== "string" || + !track.source.startsWith("http") + ) { + console.warn( + ` Skipping track ID ${track._id} due to invalid or missing source URL: "${track.source}"`, + ) + continue + } + + const index = tracks.indexOf(track) + + try { + console.log( + ` [${index + 1}/${tracks.length}] Fetching ETag for source: ${track.source} (Track ID: ${track._id})`, + ) + const response = await axios.head(track.source, { + timeout: 10000, // 10 seconds timeout + // Add headers to mimic a browser to avoid some 403s or other blocks + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + Accept: "*/*", // More generic accept for HEAD + "Accept-Encoding": "gzip, deflate, br", + Connection: "keep-alive", + }, + }) + // ETag header can be 'etag' or 'ETag' (case-insensitive) + const etag = response.headers["etag"] || response.headers["ETag"] + + if (etag) { + if (!tracksByETag.has(etag)) { + tracksByETag.set(etag, []) + } + tracksByETag.get(etag).push(track) + // console.log(` ETag: ${etag} found for source: ${track.source}`) + } else { + console.warn( + ` No ETag found for source: ${track.source} (Track ID: ${track._id})`, + ) + } + } catch (error) { + let errorMessage = error.message + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + errorMessage = `Server responded with status ${error.response.status} ${error.response.statusText}` + } else if (error.request) { + // The request was made but no response was received + errorMessage = + "No response received from server (e.g., timeout, network error)" + } + // else: Something happened in setting up the request that triggered an Error + + console.error( + ` Error fetching ETag for ${track.source} (Track ID: ${track._id}): ${errorMessage}`, + ) + } + } + + console.log( + `Finished fetching ETags. Found ${tracksByETag.size} unique ETags.`, + ) + + // Process groups to find and delete duplicates + let deletedCount = 0 + + for (const [etag, tracksForETag] of tracksByETag.entries()) { + if (tracksForETag.length > 1) { + console.log( + `Found ${tracksForETag.length} tracks for ETag: "${etag}"`, + ) + + // Sort tracks by _id (lexicographically largest first - assuming larger _id is newer) + // This ensures that we consistently pick the same track to keep if ETags are identical. + tracksForETag.sort((a, b) => + b._id.toString().localeCompare(a._id.toString()), + ) + + const trackToKeep = tracksForETag[0] + const tracksToDelete = tracksForETag.slice(1) // All tracks except the newest one + + if (tracksToDelete.length > 0) { + const idsToDelete = tracksToDelete.map((track) => track._id) + + console.log( + ` Keeping Track ID: ${trackToKeep._id} (Source: ${trackToKeep.source}) - selected due to largest _id (assumed newer).`, + ) + tracksToDelete.forEach((t) => { + console.log( + ` Marking for deletion: Track ID: ${t._id} (Source: ${t.source})`, + ) + }) + console.log( + ` Attempting to delete ${idsToDelete.length} duplicate tracks for ETag: "${etag}"`, + ) + + try { + const deleteResult = await Track.deleteMany({ + _id: { $in: idsToDelete }, + }) + + if (deleteResult.deletedCount > 0) { + console.log( + ` Successfully deleted ${deleteResult.deletedCount} tracks for ETag: "${etag}"`, + ) + deletedCount += deleteResult.deletedCount + } else { + console.warn( + ` Deletion command executed for ETag "${etag}", but no tracks were deleted. IDs: ${idsToDelete.join(", ")}`, + ) + } + } catch (dbError) { + console.error( + ` Database error deleting tracks for ETag "${etag}": ${dbError.message}`, + ) + } + } + } + } + console.log(`Finished processing. Total tracks deleted: ${deletedCount}.`) +} + +main() diff --git a/packages/server/scripts/migrations/userTrackLikesToLibraryItems.js b/packages/server/scripts/migrations/userTrackLikesToLibraryItems.js new file mode 100644 index 00000000..8b08a9a9 --- /dev/null +++ b/packages/server/scripts/migrations/userTrackLikesToLibraryItems.js @@ -0,0 +1,53 @@ +import DbManager from "@shared-classes/DbManager" +import { TrackLike, MusicLibraryItem } from "@db_models" + +async function main() { + await global.injectEnvFromInfisical() + + const db = new DbManager() + await db.initialize() + + if (!TrackLike) { + console.log("TrackLike model not found, skipping migration.") + return null + } + + if (!MusicLibraryItem) { + console.log("MusicLibraryItem model not found, skipping migration.") + return null + } + + // find all liked tracks + const likedTracks = await TrackLike.find() + const totalLikedTracks = await TrackLike.countDocuments() + + for await (const likedTrack of likedTracks) { + // first check if already exist a library item for this track like + let libraryItem = await MusicLibraryItem.findOne({ + user_id: likedTrack.user_id, + item_id: likedTrack.track_id, + kind: "tracks", + }) + + if (!libraryItem) { + console.log( + `Migrating [${likedTrack._id.toString()}] track like to library item...`, + ) + // if not exist, create a new one + libraryItem = new MusicLibraryItem({ + user_id: likedTrack.user_id, + item_id: likedTrack.track_id, + kind: "tracks", + created_at: likedTrack.created_at ?? new Date(), + }) + + await libraryItem.save() + } + } + + console.log({ + totalLikedTracks, + }) +} + +main() diff --git a/packages/server/services/auth/classes/account/methods/sessions.js b/packages/server/services/auth/classes/account/methods/sessions.js index 336aacea..adcc5dec 100644 --- a/packages/server/services/auth/classes/account/methods/sessions.js +++ b/packages/server/services/auth/classes/account/methods/sessions.js @@ -1,13 +1,15 @@ import { Session } from "@db_models" export default async (payload = {}) => { - const { user_id } = payload + const { user_id } = payload - if (!user_id) { - throw new OperationError(400, "user_id not provided") - } + if (!user_id) { + throw new OperationError(400, "user_id not provided") + } - const sessions = await Session.find({ user_id }) + const sessions = await Session.find({ user_id }).sort({ + created_at: -1, + }) - return sessions -} \ No newline at end of file + return sessions +} diff --git a/packages/server/services/auth/routes/auth/post.js b/packages/server/services/auth/routes/auth/post.js index 49687130..6815d5c2 100644 --- a/packages/server/services/auth/routes/auth/post.js +++ b/packages/server/services/auth/routes/auth/post.js @@ -1,5 +1,10 @@ import AuthToken from "@shared-classes/AuthToken" -import { UserConfig, MFASession, TosViolations } from "@db_models" +import { + UserConfig, + UserDHKeyPair, + MFASession, + TosViolations, +} from "@db_models" import obscureEmail from "@shared-utils/obscureEmail" import Account from "@classes/account" @@ -143,9 +148,15 @@ export default async (req, res) => { console.error(error) } + const keyPair = await UserDHKeyPair.findOne({ + user_id: user._id.toString(), + }) + return { + user_id: user._id.toString(), token: token, refreshToken: refreshToken, expires_in: AuthToken.authStrategy.expiresIn, + keyPairEnc: keyPair?.str, } } diff --git a/packages/server/services/auth/routes/sessions/all/delete.js b/packages/server/services/auth/routes/sessions/all/delete.js new file mode 100644 index 00000000..256df72a --- /dev/null +++ b/packages/server/services/auth/routes/sessions/all/delete.js @@ -0,0 +1,23 @@ +import { Session } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + let sessions = await Session.find({ + user_id: req.auth.session.user_id, + }) + + sessions = sessions.map((session) => { + return session._id.toString() + }) + + await Session.deleteMany({ + _id: sessions, + }) + + return { + ok: true, + sessions: sessions, + } + }, +} diff --git a/packages/server/services/chats/routes/chats/[chat_id]/history/get.js b/packages/server/services/chats/routes/chats/[chat_id]/history/get.js deleted file mode 100644 index ba8d2dda..00000000 --- a/packages/server/services/chats/routes/chats/[chat_id]/history/get.js +++ /dev/null @@ -1,74 +0,0 @@ -import { User, ChatMessage } from "@db_models" - -export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const { limit = 50, offset = 0, order = "asc" } = req.query - - const id = req.params.chat_id - - const [from_user_id, to_user_id] = [req.auth.session.user_id, id] - - const query = { - from_user_id: { - $in: [ - from_user_id, - to_user_id - ] - }, - to_user_id: { - $in: [ - from_user_id, - to_user_id - ] - }, - } - - let user_datas = await User.find({ - _id: [ - from_user_id, - to_user_id - ] - }) - - user_datas = user_datas.map((user) => { - user = user.toObject() - - if (!user) { - return { - _id: 0, - username: "Deleted User", - } - } - - user._id = user._id.toString() - - return user - }) - - let history = await ChatMessage.find(query) - .sort({ created_at: order === "desc" ? -1 : 1 }) - .skip(offset) - .limit(limit) - - history = history.map(async (item) => { - item = item.toObject() - - item.user = user_datas.find((user) => { - return user._id === item.from_user_id - }) - - return item - }) - - history = await Promise.all(history) - - return { - total: await ChatMessage.countDocuments(query), - offset: offset, - limit: limit, - order: order, - list: history - } - } -} \ No newline at end of file diff --git a/packages/server/services/chats/routes/chats/[to_user_id]/history/get.js b/packages/server/services/chats/routes/chats/[to_user_id]/history/get.js new file mode 100644 index 00000000..f86c86f9 --- /dev/null +++ b/packages/server/services/chats/routes/chats/[to_user_id]/history/get.js @@ -0,0 +1,66 @@ +import { User, ChatMessage } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const { limit = 50, offset = 0, order = "asc" } = req.query + + const [from_user_id, to_user_id] = [ + req.auth.session.user_id, + req.params.to_user_id, + ] + + const query = { + from_user_id: { + $in: [from_user_id, to_user_id], + }, + to_user_id: { + $in: [from_user_id, to_user_id], + }, + } + + let users_data = await User.find({ + _id: [from_user_id, to_user_id], + }) + + users_data = users_data.map((user) => { + user = user.toObject() + + if (!user) { + return { + _id: 0, + username: "Deleted User", + } + } + + user._id = user._id.toString() + + return user + }) + + let history = await ChatMessage.find(query) + .sort({ created_at: order === "desc" ? -1 : 1 }) + .skip(offset) + .limit(limit) + + history = history.map(async (item) => { + item = item.toObject() + + item.user = users_data.find((user) => { + return user._id === item.from_user_id + }) + + return item + }) + + history = await Promise.all(history) + + return { + total: await ChatMessage.countDocuments(query), + offset: offset, + limit: limit, + order: order, + list: history, + } + }, +} diff --git a/packages/server/services/chats/routes/chats/[to_user_id]/keys/get.js b/packages/server/services/chats/routes/chats/[to_user_id]/keys/get.js new file mode 100644 index 00000000..0ff7b859 --- /dev/null +++ b/packages/server/services/chats/routes/chats/[to_user_id]/keys/get.js @@ -0,0 +1,44 @@ +import { UserChat } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const current_user_id = req.auth.session.user_id + const target_user_id = req.params.to_user_id + + let chat = await UserChat.findOne({ + $or: [ + { + "user_1._id": current_user_id, + "user_2._id": target_user_id, + }, + { + "user_1._id": target_user_id, + "user_2._id": current_user_id, + }, + ], + }) + + if (!chat) { + return { + exists: false, + encryptedKey: null, + } + } + + let encryptedKey = null + + if (chat.user_1._id === current_user_id) { + encryptedKey = chat.user_1.key + } + + if (chat.user_2._id === current_user_id) { + encryptedKey = chat.user_2.key + } + + return { + exists: !!encryptedKey, + encryptedKey: encryptedKey, + } + }, +} diff --git a/packages/server/services/chats/routes/chats/[to_user_id]/keys/post.js b/packages/server/services/chats/routes/chats/[to_user_id]/keys/post.js new file mode 100644 index 00000000..1aeec041 --- /dev/null +++ b/packages/server/services/chats/routes/chats/[to_user_id]/keys/post.js @@ -0,0 +1,70 @@ +import { UserChat } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const current_user_id = req.auth.session.user_id + const target_user_id = req.params.to_user_id + + const { encryptedKey } = req.body + + if (!encryptedKey) { + throw new OperationError(400, "Encrypted key is required") + } + + let chat = await UserChat.findOne({ + $or: [ + { + "user_1._id": current_user_id, + "user_2._id": target_user_id, + }, + { + "user_1._id": target_user_id, + "user_2._id": current_user_id, + }, + ], + }) + + if (!chat) { + chat = await UserChat.create({ + user_1: { + _id: current_user_id, + key: encryptedKey, + }, + user_2: { + _id: target_user_id, + key: null, + }, + started_at: new Date().getTime(), + updated_at: new Date().getTime(), + }) + } else { + chat = chat.toObject() + + if (chat.user_1._id === current_user_id) { + console.log( + `User: ${current_user_id}, updating their key, slot 1`, + ) + + chat.user_1.key = encryptedKey + } + + if (chat.user_2._id === current_user_id) { + console.log( + `User: ${current_user_id}, updating their key, slot 2`, + ) + + chat.user_2.key = encryptedKey + } + + chat.updated_at = new Date().getTime() + + await UserChat.findByIdAndUpdate(chat._id, chat) + } + + return { + success: true, + message: "Encryption key saved successfully", + } + }, +} diff --git a/packages/server/services/chats/routes_ws/chat/send/message.js b/packages/server/services/chats/routes_ws/chat/send/message.js index 3dffd912..27a5469d 100644 --- a/packages/server/services/chats/routes_ws/chat/send/message.js +++ b/packages/server/services/chats/routes_ws/chat/send/message.js @@ -1,36 +1,37 @@ import { ChatMessage } from "@db_models" export default async (socket, payload, engine) => { - if (!socket.userData) { - throw new OperationError(401, "Unauthorized") - } + if (!socket.userData) { + throw new OperationError(401, "Unauthorized") + } - const created_at = new Date().getTime() + const created_at = new Date().getTime() - const [from_user_id, to_user_id] = [socket.userData._id, payload.to_user_id] + const [from_user_id, to_user_id] = [socket.userData._id, payload.to_user_id] - const wsMessageObj = { - ...payload, - created_at: created_at, - user: socket.userData, - _id: `msg:${from_user_id}:${created_at}`, - } + const wsMessageObj = { + ...payload, + created_at: created_at, + user: socket.userData, + _id: `msg:${from_user_id}:${created_at}`, + } - const doc = await ChatMessage.create({ - type: "user", - from_user_id: from_user_id, - to_user_id: to_user_id, - content: payload.content, - created_at: created_at, - }) + const doc = await ChatMessage.create({ + type: "user", + from_user_id: from_user_id, + to_user_id: to_user_id, + content: payload.content, + encrypted: !!payload.encrypted, + created_at: created_at, + }) - socket.emit("chat:receive:message", wsMessageObj) + socket.emit("chat:receive:message", wsMessageObj) - const targetSocket = await engine.find.socketByUserId(payload.to_user_id) + const targetSocket = await engine.find.socketByUserId(payload.to_user_id) - if (targetSocket) { - await targetSocket.emit("chat:receive:message", wsMessageObj) - } + if (targetSocket) { + await targetSocket.emit("chat:receive:message", wsMessageObj) + } - return doc -} \ No newline at end of file + return doc +} diff --git a/packages/server/services/main/routes/activity/client/post.js b/packages/server/services/main/routes/activity/client/post.js index bdf4f7c2..cb7f0b28 100644 --- a/packages/server/services/main/routes/activity/client/post.js +++ b/packages/server/services/main/routes/activity/client/post.js @@ -1,63 +1,69 @@ import { RecentActivity } from "@db_models" const IdToTypes = { - "player.play": "track_played" + "player.play": "track_played", } +const MAX_RECENT_ACTIVITIES = 10 + export default { - middlewares: [ - "withAuthentication", - ], - fn: async (req, res) => { - const user_id = req.auth.session.user_id - let { id, payload } = req.body + middlewares: ["withAuthentication"], + fn: async (req, res) => { + const user_id = req.auth.session.user_id + let { id, payload } = req.body - if (!id) { - throw new OperationError(400, "Event id is required") - } + if (!id) { + throw new OperationError(400, "Event id is required") + } - if (!payload) { - throw new OperationError(400, "Event payload is required") - } + if (!payload) { + throw new OperationError(400, "Event payload is required") + } - id = id.toLowerCase() + id = id.toLowerCase() - if (!IdToTypes[id]) { - throw new OperationError(400, `Event id ${id} is not supported`) - } + if (!IdToTypes[id]) { + throw new OperationError(400, `Event id ${id} is not supported`) + } - const type = IdToTypes[id] + const type = IdToTypes[id] - // get latest 20 activities - let latestActivities = await RecentActivity.find({ - user_id: user_id, - type: type, - }) - .limit(20) - .sort({ created_at: -1 }) + // Get the current latest activities + let latestActivities = await RecentActivity.find({ + user_id: user_id, + type: type, + }) + .limit(MAX_RECENT_ACTIVITIES) + .sort({ created_at: -1 }) // Newest first - // check if the activity is already in some position and remove - const sameLatestActivityIndex = latestActivities.findIndex((activity) => { - return activity.payload === payload && activity.type === type - }) + const sameActivity = await RecentActivity.findOne({ + user_id: user_id, + type: type, + payload: payload, + }) - // if the activity is already in some position, remove it from that position - if (sameLatestActivityIndex !== -1) { - latestActivities.splice(sameLatestActivityIndex, 1) - } + if (sameActivity) { + // This event's payload/type is already in the recent activities. + // The old instance should be removed to make way for the new one. + await RecentActivity.findByIdAndDelete(sameActivity._id.toString()) + } else { + // This event's payload/type is not in the recent activities. + // The oldest activity should be removed to make way for the new one. + if (latestActivities.length >= MAX_RECENT_ACTIVITIES) { + await RecentActivity.findByIdAndDelete( + latestActivities[MAX_RECENT_ACTIVITIES - 1]._id.toString(), + ) + } + } - // if the list is full, remove the oldest activity and add the new one - if (latestActivities.length >= 20) { - await RecentActivity.findByIdAndDelete(latestActivities[latestActivities.length - 1]._id) - } + // Create the new activity + const newActivity = await RecentActivity.create({ + user_id: user_id, + type: type, + payload: payload, + created_at: new Date(), + }) - const activity = await RecentActivity.create({ - user_id: user_id, - type: type, - payload: payload, - created_at: new Date(), - }) - - return activity - } -} \ No newline at end of file + return newActivity + }, +} diff --git a/packages/server/services/music/classes/library/index.js b/packages/server/services/music/classes/library/index.js new file mode 100644 index 00000000..187322f1 --- /dev/null +++ b/packages/server/services/music/classes/library/index.js @@ -0,0 +1,18 @@ +import { Track, Playlist, MusicRelease } from "@db_models" +import { MusicLibraryItem } from "@db_models" + +import toggleFavorite from "./methods/toggleFavorite" +import getUserLibrary from "./methods/getUserLibrary" +import isFavorite from "./methods/isFavorite" + +export default class Library { + static kindToModel = { + tracks: Track, + playlists: Playlist, + releases: MusicRelease, + } + + static toggleFavorite = toggleFavorite + static getUserLibrary = getUserLibrary + static isFavorite = isFavorite +} diff --git a/packages/server/services/music/classes/library/methods/getUserLibrary.js b/packages/server/services/music/classes/library/methods/getUserLibrary.js new file mode 100644 index 00000000..d2121755 --- /dev/null +++ b/packages/server/services/music/classes/library/methods/getUserLibrary.js @@ -0,0 +1,180 @@ +import { MusicLibraryItem } from "@db_models" + +import Library from ".." + +async function fetchSingleKindData(userId, kind, limit, offsetStr) { + const Model = Library.kindToModel[kind] + const parsedOffset = parseInt(offsetStr, 10) + + // this should be redundant if the initial check in `fn` was already done, + // but its a good safeguard. + if (!Model) { + console.warn(`Model not found for kind: ${kind} in fetchSingleKindData`) + return { items: [], total_items: 0, offset: parsedOffset } + } + + const query = { user_id: userId, kind: kind } + + const libraryItems = await MusicLibraryItem.find(query) + .limit(limit) + .skip(parsedOffset) + .sort({ created_at: -1 }) + .lean() + + if (libraryItems.length === 0) { + // we get total_items even if the current page is empty, + // as there might be items on other pages. + const total_items = await MusicLibraryItem.countDocuments(query) + return { items: [], total_items: total_items, offset: parsedOffset } + } + + const total_items = await MusicLibraryItem.countDocuments(query) + + const itemIds = libraryItems.map((item) => item.item_id) + const actualItems = await Model.find({ _id: { $in: itemIds } }).lean() + const actualItemsMap = new Map( + actualItems.map((item) => [item._id.toString(), item]), + ) + + const enrichedItems = libraryItems + .map((libraryItem) => { + const actualItem = actualItemsMap.get( + libraryItem.item_id.toString(), + ) + if (actualItem) { + return { + ...actualItem, + liked: true, + liked_at: libraryItem.created_at, + library_item_id: libraryItem._id, + } + } + console.warn( + `Actual item not found for kind ${kind} with ID ${libraryItem.item_id}`, + ) + return null + }) + .filter((item) => item !== null) + + return { + items: enrichedItems, + total_items: total_items, + offset: parsedOffset, + } +} + +async function fetchAllKindsData(userId, limit, offsetStr) { + const parsedOffset = parseInt(offsetStr, 10) + const baseQuery = { user_id: userId } + + // initialize the result structure for all kinds + const resultForAllKinds = {} + for (const kindName in Library.kindToModel) { + resultForAllKinds[kindName] = { + items: [], + total_items: 0, + offset: parsedOffset, + } + } + + // get the paginated MusicLibraryItems + const paginatedLibraryItems = await MusicLibraryItem.find(baseQuery) + .limit(limit) + .skip(parsedOffset) + .sort({ created_at: -1 }) + .lean() + + // group MusicLibraryItems and collect item_ids by kind + const libraryItemsGroupedByKind = {} // contain MusicLibraryItem objects + const itemIdsToFetchByKind = {} // contain arrays of item_id + + for (const kindName in Library.kindToModel) { + libraryItemsGroupedByKind[kindName] = [] + itemIdsToFetchByKind[kindName] = [] + } + + paginatedLibraryItems.forEach((libItem) => { + if ( + Library.kindToModel[libItem.kind] && + libraryItemsGroupedByKind[libItem.kind] + ) { + libraryItemsGroupedByKind[libItem.kind].push(libItem) + itemIdsToFetchByKind[libItem.kind].push(libItem.item_id) + } else { + console.warn(`Unknown or unhandled kind found: ${libItem.kind}`) + } + }) + + // fetch the actual item data for each kind in parallel + const detailFetchPromises = Object.keys(itemIdsToFetchByKind).map( + async (currentKind) => { + const itemIds = itemIdsToFetchByKind[currentKind] + + if (itemIds.length === 0) { + return // no items of this kind on the current page + } + + const Model = Library.kindToModel[currentKind] + + // the check for Library.kindToModel[currentKind] was already done when populating itemIdsToFetchByKind + // so Model should be defined here if itemIds.length > 0. + const actualItems = await Model.find({ + _id: { $in: itemIds }, + }).lean() + const actualItemsMap = new Map( + actualItems.map((item) => [item._id.toString(), item]), + ) + + // enrich items for this kind and add to the final result structure + resultForAllKinds[currentKind].items = libraryItemsGroupedByKind[ + currentKind + ] + .map((libraryItem) => { + const actualItem = actualItemsMap.get( + libraryItem.item_id.toString(), + ) + if (actualItem) { + return { + ...actualItem, + liked: true, + liked_at: libraryItem.created_at, + library_item_id: libraryItem._id, + } + } + console.warn( + `Actual item not found for kind ${currentKind} with ID ${libraryItem.item_id} in fetchAllKindsData`, + ) + return null + }) + .filter((item) => item !== null) + }, + ) + + // fetch total counts for all kinds for the user in parallel + const totalCountsPromise = MusicLibraryItem.aggregate([ + { $match: baseQuery }, + { $group: { _id: "$kind", count: { $sum: 1 } } }, + ]).exec() + + // wait for all detail fetches and the count aggregation + await Promise.all([...detailFetchPromises, totalCountsPromise]) + + // populate total_items from the resolved count aggregation + const totalCountsResult = await totalCountsPromise + + totalCountsResult.forEach((countEntry) => { + if (resultForAllKinds[countEntry._id]) { + resultForAllKinds[countEntry._id].total_items = countEntry.count + } + }) + + return resultForAllKinds +} + +export default async ({ user_id, kind, limit = 100, offset = 0 } = {}) => { + if (typeof kind === "string" && Library.kindToModel[kind]) { + return await fetchSingleKindData(user_id, kind, limit, offset) + } else { + return await fetchAllKindsData(user_id, limit, offset) + } +} diff --git a/packages/server/services/music/classes/library/methods/isFavorite.js b/packages/server/services/music/classes/library/methods/isFavorite.js new file mode 100644 index 00000000..c184eb80 --- /dev/null +++ b/packages/server/services/music/classes/library/methods/isFavorite.js @@ -0,0 +1,45 @@ +import { MusicLibraryItem } from "@db_models" + +export default async (user_id, item_id, kind) => { + if (!user_id) { + throw new OperationError(400, "Missing user_id") + } + + if (!item_id) { + throw new OperationError(400, "Missing item_id") + } + + if (Array.isArray(item_id)) { + const libraryItems = await MusicLibraryItem.find({ + user_id: user_id, + item_id: { $in: item_id }, + kind: kind, + }) + .lean() + .catch(() => { + return [] + }) + + return item_id.map((id) => { + const libItem = libraryItems.find( + (item) => item.item_id.toString() === id.toString(), + ) + + return { + item_id: id, + liked: !!libItem, + created_at: libItem?.created_at, + } + }) + } else { + let libraryItem = await MusicLibraryItem.findOne({ + user_id: user_id, + item_id: item_id, + kind: kind, + }).catch(() => null) + + return { + liked: !!libraryItem, + } + } +} diff --git a/packages/server/services/music/classes/library/methods/toggleFavorite.js b/packages/server/services/music/classes/library/methods/toggleFavorite.js new file mode 100644 index 00000000..9586cb87 --- /dev/null +++ b/packages/server/services/music/classes/library/methods/toggleFavorite.js @@ -0,0 +1,59 @@ +import Library from ".." + +import { MusicLibraryItem } from "@db_models" + +export default async (user_id, item_id, kind, to) => { + if (!user_id || !item_id || !kind) { + throw new OperationError(400, "Missing user_id, item_id or kind") + } + + kind = String(kind).toLowerCase() + + const availableKinds = Object.keys(Library.kindToModel) + + if (!availableKinds.includes(kind)) { + throw new OperationError(400, `Invalid kind: ${kind}`) + } + + const itemModel = Library.kindToModel[kind] + + // check if exists + const itemObj = await itemModel.findOne({ _id: item_id }).catch(() => null) + + if (!itemObj) { + throw new OperationError(404, `Item not found`) + } + + // find library item + let libraryItem = await MusicLibraryItem.findOne({ + user_id: user_id, + item_id: item_id, + kind: kind, + }).catch(() => null) + + if (typeof to === "undefined") { + to = !!!libraryItem + } + + if (to == true && !libraryItem) { + libraryItem = await MusicLibraryItem.create({ + user_id: user_id, + item_id: item_id, + kind: kind, + created_at: Date.now(), + }) + } + + if (to == false && libraryItem) { + await MusicLibraryItem.deleteOne({ + _id: libraryItem._id.toString(), + }) + libraryItem = null + } + + return { + liked: !!libraryItem, + item_id: item_id, + library_item_id: libraryItem ? libraryItem._id : null, + } +} diff --git a/packages/server/services/music/classes/radio/index.js b/packages/server/services/music/classes/radio/index.js new file mode 100644 index 00000000..f06698a5 --- /dev/null +++ b/packages/server/services/music/classes/radio/index.js @@ -0,0 +1,108 @@ +import { RadioProfile } from "@db_models" + +async function scanKeysWithPagination(pattern, count = 10, cursor = "0") { + const result = await global.redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + count, + ) + + return result[1] +} + +export default class Radio { + static async list({ limit = 50, offset = 0 } = {}) { + let result = await scanKeysWithPagination( + `radio-*`, + limit, + String(offset), + ) + + return await Radio.data(result.map((key) => key.split("radio-")[1])) + } + + static async data(ids) { + if (typeof ids === "string") { + ids = [ids] + } + + const results = [] + + let profiles = await RadioProfile.find({ + _id: { $in: ids }, + }) + + for await (const id of ids) { + let data = await redis.hgetall(`radio-${id}`) + + if (!data) { + continue + } + + let profile = profiles.find( + (profile) => profile._id.toString() === id, + ) + + if (!profile) { + continue + } + + profile = profile.toObject() + + data.now_playing = JSON.parse(data.now_playing) + data.online = ToBoolean(data.online) + data.listeners = parseInt(data.listeners) + + results.push({ ...data, ...profile }) + } + + return results + } + + static async trendings() { + const stationsWithListeners = [] + + let cursor = "0" + + do { + const scanResult = await global.redis.scan( + cursor, + "MATCH", + "radio-*", + "COUNT", + 100, + ) + cursor = scanResult[0] + const keys = scanResult[1] + + for (const key of keys) { + const id = key.split("radio-")[1] + const listenersStr = await global.redis.hget(key, "listeners") + + if (listenersStr !== null) { + const listeners = parseInt(listenersStr, 10) + if (!isNaN(listeners)) { + stationsWithListeners.push({ id, listeners }) + } + } + } + } while (cursor !== "0") + + // Sort stations by listeners in descending order + stationsWithListeners.sort((a, b) => b.listeners - a.listeners) + + // Get the IDs of the top 4 stations + const stationsIds = stationsWithListeners + .slice(0, 4) + .map((station) => station.id) + + // If no stations found or no stations with valid listener counts, return an empty array + if (stationsIds.length === 0) { + return [] + } + + return await Radio.data(stationsIds) + } +} diff --git a/packages/server/services/music/classes/release/index.js b/packages/server/services/music/classes/release/index.js index b4740bbb..32d1f9e3 100644 --- a/packages/server/services/music/classes/release/index.js +++ b/packages/server/services/music/classes/release/index.js @@ -35,6 +35,13 @@ export default class Release { onlyList: true, }) + release.total_duration = tracks.reduce((acc, track) => { + if (track.metadata?.duration) { + return acc + parseFloat(track.metadata.duration) + } + + return acc + }, 0) release.total_items = totalTracks release.items = tracks @@ -123,7 +130,7 @@ export default class Release { const items = release.items ?? release.list - const items_ids = items.map((item) => item._id) + const items_ids = items.map((item) => item._id.toString()) // delete all releated tracks await Track.deleteMany({ diff --git a/packages/server/services/music/classes/track/index.js b/packages/server/services/music/classes/track/index.js index 750dea16..ab0583de 100644 --- a/packages/server/services/music/classes/track/index.js +++ b/packages/server/services/music/classes/track/index.js @@ -1,7 +1,5 @@ export default class Track { - static create = require("./methods/create").default - static delete = require("./methods/delete").default - static get = require("./methods/get").default - static toggleFavourite = require("./methods/toggleFavourite").default - static isFavourite = require("./methods/isFavourite").default -} \ No newline at end of file + static create = require("./methods/create").default + static delete = require("./methods/delete").default + static get = require("./methods/get").default +} diff --git a/packages/server/services/music/classes/track/methods/create.js b/packages/server/services/music/classes/track/methods/create.js index 86d7daae..1df4f788 100644 --- a/packages/server/services/music/classes/track/methods/create.js +++ b/packages/server/services/music/classes/track/methods/create.js @@ -70,6 +70,7 @@ export default async (payload = {}) => { "https://storage.ragestudio.net/comty-static-assets/default_song.png", source: payload.source, metadata: metadata, + public: payload.public ?? true, } if (Array.isArray(payload.artists)) { @@ -81,6 +82,7 @@ export default async (payload = {}) => { publisher: { user_id: payload.user_id, }, + created_at: new Date(), }) await track.save() diff --git a/packages/server/services/music/classes/track/methods/get.js b/packages/server/services/music/classes/track/methods/get.js index 751976bc..e3f327ec 100644 --- a/packages/server/services/music/classes/track/methods/get.js +++ b/packages/server/services/music/classes/track/methods/get.js @@ -1,4 +1,5 @@ -import { Track, TrackLike } from "@db_models" +import { Track } from "@db_models" +import Library from "@classes/library" async function fullfillData(list, { user_id = null }) { if (!Array.isArray(list)) { @@ -11,19 +12,20 @@ async function fullfillData(list, { user_id = null }) { // if user_id is provided, fetch likes if (user_id) { - const tracksLikes = await TrackLike.find({ - user_id: user_id, - track_id: { $in: trackIds }, - }) + const tracksLikes = await Library.isFavorite( + user_id, + trackIds, + "tracks", + ) list = list.map(async (track) => { const trackLike = tracksLikes.find((trackLike) => { - return trackLike.track_id.toString() === track._id.toString() + return trackLike.item_id.toString() === track._id.toString() }) if (trackLike) { track.liked_at = trackLike.created_at - track.liked = true + track.liked = trackLike.liked } return track diff --git a/packages/server/services/music/classes/track/methods/isFavourite.js b/packages/server/services/music/classes/track/methods/isFavourite.js deleted file mode 100644 index 15620b96..00000000 --- a/packages/server/services/music/classes/track/methods/isFavourite.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Track, TrackLike } from "@db_models" - -export default async (user_id, track_id, to) => { - if (!user_id) { - throw new OperationError(400, "Missing user_id") - } - - if (!track_id) { - throw new OperationError(400, "Missing track_id") - } - - const track = await Track.findById(track_id).catch(() => null) - - if (!track) { - throw new OperationError(404, "Track not found") - } - - let trackLike = await TrackLike.findOne({ - user_id: user_id, - track_id: track_id, - }).catch(() => null) - - return { - liked: !!trackLike - } -} \ No newline at end of file diff --git a/packages/server/services/music/classes/track/methods/modify.js b/packages/server/services/music/classes/track/methods/modify.js index ba7eea50..c1fa6db3 100644 --- a/packages/server/services/music/classes/track/methods/modify.js +++ b/packages/server/services/music/classes/track/methods/modify.js @@ -1,6 +1,6 @@ import { Track } from "@db_models" -const allowedFields = ["title", "artist", "album", "cover"] +const allowedFields = ["title", "artist", "album", "cover", "public"] export default async (track_id, payload) => { if (!track_id) { diff --git a/packages/server/services/music/classes/track/methods/toggleFavourite.js b/packages/server/services/music/classes/track/methods/toggleFavourite.js deleted file mode 100644 index 4c7051a3..00000000 --- a/packages/server/services/music/classes/track/methods/toggleFavourite.js +++ /dev/null @@ -1,65 +0,0 @@ -import { Track, TrackLike } from "@db_models" - -export default async (user_id, track_id, to) => { - if (!user_id) { - throw new OperationError(400, "Missing user_id") - } - - if (!track_id) { - throw new OperationError(400, "Missing track_id") - } - - const track = await Track.findById(track_id) - - if (!track) { - throw new OperationError(404, "Track not found") - } - - let trackLike = await TrackLike.findOne({ - user_id: user_id, - track_id: track_id, - }).catch(() => null) - - if (typeof to === "undefined") { - to = !!!trackLike - } - - if (to) { - if (!trackLike) { - trackLike = new TrackLike({ - user_id: user_id, - track_id: track_id, - created_at: Date.now(), - }) - - await trackLike.save() - } - } else { - if (trackLike) { - await TrackLike.deleteOne({ - user_id: user_id, - track_id: track_id, - }) - - trackLike = null - } - } - - if (global.websockets) { - const targetSocket = - await global.websockets.find.clientsByUserId(user_id) - - if (targetSocket) { - await targetSocket.emit("music:track:toggle:like", { - track_id: track_id, - action: trackLike ? "liked" : "unliked", - }) - } - } - - return { - liked: trackLike ? true : false, - track_like_id: trackLike ? trackLike._id : null, - track_id: track._id.toString(), - } -} diff --git a/packages/server/services/music/routes/music/feed/get.js b/packages/server/services/music/routes/music/feed/get.js index c7214737..1ac4a18b 100644 --- a/packages/server/services/music/routes/music/feed/get.js +++ b/packages/server/services/music/routes/music/feed/get.js @@ -1,12 +1,14 @@ -import { MusicRelease, Track } from "@db_models" +import { MusicRelease } from "@db_models" export default async (req) => { - const { limit = 10, trim = 0, order = "desc" } = req.query + const { limit = 10, page = 0, order = "desc" } = req.query const searchQuery = {} const total_length = await MusicRelease.countDocuments(searchQuery) + const trim = limit * page + let result = await MusicRelease.find({ ...searchQuery, public: true, @@ -17,7 +19,7 @@ export default async (req) => { return { total_length: total_length, - has_more: total_length > trim + result.length, + has_more: total_length > trim + limit, items: result, } } diff --git a/packages/server/services/music/routes/music/feed/my/get.js b/packages/server/services/music/routes/music/feed/my/get.js deleted file mode 100644 index cc946bb8..00000000 --- a/packages/server/services/music/routes/music/feed/my/get.js +++ /dev/null @@ -1,16 +0,0 @@ -export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const { keywords, limit = 10, offset = 0 } = req.query - - const user_id = req.auth.session.user_id - - let total_length = 0 - let result = [] - - return { - total_length: total_length, - items: result, - } - }, -} diff --git a/packages/server/services/music/routes/music/lyrics/[track_id]/get.js b/packages/server/services/music/routes/music/lyrics/[track_id]/get.js deleted file mode 100644 index 50a01167..00000000 --- a/packages/server/services/music/routes/music/lyrics/[track_id]/get.js +++ /dev/null @@ -1,80 +0,0 @@ -import { TrackLyric } from "@db_models" -import axios from "axios" - -function parseTimeToMs(timeStr) { - const [minutes, seconds, milliseconds] = timeStr.split(":") - - return Number(minutes) * 60 * 1000 + Number(seconds) * 1000 + Number(milliseconds) -} - -async function remoteLcrToSyncedLyrics(lrcUrl) { - const { data } = await axios.get(lrcUrl) - - let syncedLyrics = data - - syncedLyrics = syncedLyrics.split("\n") - - syncedLyrics = syncedLyrics.map((line) => { - const syncedLine = {} - - //syncedLine.time = line.match(/\[.*\]/)[0] - syncedLine.time = line.split(" ")[0] - syncedLine.text = line.replace(syncedLine.time, "").trim() - - if (syncedLine.text === "") { - delete syncedLine.text - syncedLine.break = true - } - - syncedLine.time = syncedLine.time.replace(/\[|\]/g, "") - syncedLine.time = syncedLine.time.replace(".", ":") - - return syncedLine - }) - - syncedLyrics = syncedLyrics.map((syncedLine, index) => { - const nextLine = syncedLyrics[index + 1] - - syncedLine.startTimeMs = parseTimeToMs(syncedLine.time) - syncedLine.endTimeMs = nextLine ? parseTimeToMs(nextLine.time) : parseTimeToMs(syncedLyrics[syncedLyrics.length - 1].time) - - return syncedLine - }) - - return syncedLyrics -} - -export default async (req) => { - const { track_id } = req.params - let { translate_lang = "original" } = req.query - - let trackLyrics = await TrackLyric.findOne({ - track_id - }) - - if (!trackLyrics) { - throw new OperationError(404, "Track lyric not found") - } - - trackLyrics = trackLyrics.toObject() - - if (typeof trackLyrics.lrc === "object") { - trackLyrics.translated_lang = translate_lang - - if (!trackLyrics.lrc[translate_lang]) { - translate_lang = "original" - } - - if (trackLyrics.lrc[translate_lang]) { - trackLyrics.synced_lyrics = await remoteLcrToSyncedLyrics(trackLyrics.lrc[translate_lang]) - } - - trackLyrics.available_langs = Object.keys(trackLyrics.lrc) - } - - if (trackLyrics.sync_audio_at) { - trackLyrics.sync_audio_at_ms = parseTimeToMs(trackLyrics.sync_audio_at) - } - - return trackLyrics -} \ No newline at end of file diff --git a/packages/server/services/music/routes/music/my/folder/get.js b/packages/server/services/music/routes/music/my/folder/get.js deleted file mode 100644 index c7ccb1ad..00000000 --- a/packages/server/services/music/routes/music/my/folder/get.js +++ /dev/null @@ -1,93 +0,0 @@ -import { TrackLike } from "@db_models" - -import TrackClass from "@classes/track" - -const HANDLERS = { - track: { - model: TrackLike, - class: TrackClass, - type: "tracks", - idField: "track_id", - }, - // release: { - // model: ReleaseLike, - // class: ReleaseClass, - // type: 'releases', - // idField: 'release_id' - // }, - // playlist: { - // model: PlaylistLike, - // class: PlaylistClass, - // type: 'playlists', - // idField: 'playlist_id' - // }, -} - -async function getLikedItemsFromHandler(config, userId, pagination) { - try { - // obtain ids data and total items - const [total, likes] = await Promise.all([ - config.model.countDocuments({ user_id: userId }), - config.model - .find({ user_id: userId }) - .sort({ created_at: -1 }) - .limit(pagination.limit) - .skip(pagination.offset), - ]) - - const likedAtMap = new Map() - const itemIds = [] - - for (const like of likes) { - const itemId = like[config.idField] - - likedAtMap.set(itemId, like.created_at) - itemIds.push(itemId) - } - - // fetch track data - let processedItems = await config.class.get(itemIds, { - onlyList: true, - minimalData: true, - }) - - // mix with likes data - processedItems = processedItems.map((item) => { - item.liked = true - item.liked_at = likedAtMap.get(item._id.toString()) - return item - }) - - return { - items: processedItems, - total_items: total, - } - } catch (error) { - console.error(`Error processing ${config.type}:`, error) - return { items: [], total_items: 0 } - } -} - -// -// A endpoint to fetch track & playlists & releases likes -// -export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const userId = req.auth.session.user_id - const { limit = 50, offset = 0 } = req.query - - const activeHandlers = Object.values(HANDLERS) - - const results = await Promise.all( - activeHandlers.map((handler) => - getLikedItemsFromHandler(handler, userId, { limit, offset }), - ), - ) - - return activeHandlers.reduce((response, handler, index) => { - response[handler.type] = results[index] - return response - }, {}) - }, -} diff --git a/packages/server/services/music/routes/music/my/library/favorite/get.js b/packages/server/services/music/routes/music/my/library/favorite/get.js new file mode 100644 index 00000000..d7037ef8 --- /dev/null +++ b/packages/server/services/music/routes/music/my/library/favorite/get.js @@ -0,0 +1,16 @@ +import Library from "@classes/library" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const { kind, item_id } = req.query + + if (!kind || !item_id) { + throw new OperationError( + "Missing parameters. Required: {kind, item_id}", + ) + } + + return await Library.isFavorite(req.auth.session.user_id, item_id, kind) + }, +} diff --git a/packages/server/services/music/routes/music/my/library/favorite/put.js b/packages/server/services/music/routes/music/my/library/favorite/put.js new file mode 100644 index 00000000..937d84c4 --- /dev/null +++ b/packages/server/services/music/routes/music/my/library/favorite/put.js @@ -0,0 +1,21 @@ +import Library from "@classes/library" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const { kind, item_id, to } = req.body + + if (!kind || !item_id) { + throw new OperationError( + "Missing parameters. Required: {kind, item_id}", + ) + } + + return await Library.toggleFavorite( + req.auth.session.user_id, + item_id, + kind, + to, + ) + }, +} diff --git a/packages/server/services/music/routes/music/my/library/get.js b/packages/server/services/music/routes/music/my/library/get.js new file mode 100644 index 00000000..a073ce2f --- /dev/null +++ b/packages/server/services/music/routes/music/my/library/get.js @@ -0,0 +1,16 @@ +import Library from "@classes/library" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const userId = req.auth.session.user_id + const { limit = 50, offset = 0, kind } = req.query + + return await Library.getUserLibrary({ + user_id: userId, + limit: limit, + offset: offset, + kind: kind, + }) + }, +} diff --git a/packages/server/services/music/routes/music/releases/self/get.js b/packages/server/services/music/routes/music/my/releases/get.js similarity index 100% rename from packages/server/services/music/routes/music/releases/self/get.js rename to packages/server/services/music/routes/music/my/releases/get.js diff --git a/packages/server/services/music/routes/music/radio/list/get.js b/packages/server/services/music/routes/music/radio/list/get.js index 186bf1ed..2dafb6b4 100644 --- a/packages/server/services/music/routes/music/radio/list/get.js +++ b/packages/server/services/music/routes/music/radio/list/get.js @@ -1,41 +1,12 @@ -import { RadioProfile } from "@db_models" - -async function scanKeysWithPagination(pattern, count = 10, cursor = "0") { - const result = await redis.scan(cursor, "MATCH", pattern, "COUNT", count) - - return result[1] -} - -async function getHashData(hashKey) { - const hashData = await redis.hgetall(hashKey) - return hashData -} +import Radio from "@classes/radio" export default async (req) => { const { limit = 50, offset = 0 } = req.query - let result = await scanKeysWithPagination(`radio-*`, limit, String(offset)) - - const radioIds = result.map((key) => key.split("radio-")[1]) - - const radioProfiles = await RadioProfile.find({ - _id: { $in: radioIds }, + let result = await Radio.list({ + limit: limit, + offset: offset, }) - result = await Promise.all( - result.map(async (key) => { - let data = await getHashData(key) - - const profile = radioProfiles - .find((profile) => profile._id.toString() === data.radio_id) - .toObject() - - data.now_playing = JSON.parse(data.now_playing) - data.online = ToBoolean(data.online) - - return { ...data, ...profile } - }), - ) - return result } diff --git a/packages/server/services/music/routes/music/radio/trendings/get.js b/packages/server/services/music/routes/music/radio/trendings/get.js new file mode 100644 index 00000000..061031a2 --- /dev/null +++ b/packages/server/services/music/routes/music/radio/trendings/get.js @@ -0,0 +1,7 @@ +import Radio from "@classes/radio" + +export default async () => { + return { + items: await Radio.trendings(), + } +} diff --git a/packages/server/services/music/routes/music/recently/get.js b/packages/server/services/music/routes/music/recently/get.js index baeee052..eb7fc0d5 100644 --- a/packages/server/services/music/routes/music/recently/get.js +++ b/packages/server/services/music/routes/music/recently/get.js @@ -3,48 +3,57 @@ import { RecentActivity } from "@db_models" import TrackClass from "@classes/track" export default { - middlewares: [ - "withAuthentication", - ], - fn: async (req, res) => { - const user_id = req.auth.session.user_id + middlewares: ["withAuthentication"], + fn: async (req, res) => { + const user_id = req.auth.session.user_id - let activities = await RecentActivity.find({ - user_id: user_id, - type: "track_played" - }) - .limit(req.query.limit ?? 20) - .sort({ created_at: -1 }) + let activities = await RecentActivity.find({ + user_id: user_id, + type: "track_played", + }) + .limit(req.query.limit ?? 10) + .sort({ created_at: -1 }) - // filter tracks has different service than comtymusic - activities = activities.map((activity) => { - if (activity.payload.service && activity.payload.service !== "default") { - return null - } + // filter tracks has different service than comtymusic + activities = activities.map((activity) => { + if ( + activity.payload.service && + activity.payload.service !== "default" + ) { + return null + } - return activity - }) + return activity + }) - // filter null & undefined tracks - activities = activities.filter((activity) => { - return activity - }) + // filter null & undefined tracks + activities = activities.filter((activity) => { + return activity + }) - // filter undefined tracks_ids - activities = activities.filter((activity) => { - return activity.payload && activity.payload.track_id - }) + // filter undefined tracks_ids + activities = activities.filter((activity) => { + return activity.payload && activity.payload.track_id + }) - // map track objects to track ids - let tracks_ids = activities.map((activity) => { - return activity.payload.track_id - }) + // map track objects to track ids + let tracks_ids = activities.map((activity) => { + return activity.payload.track_id + }) - const tracks = await TrackClass.get(tracks_ids, { - user_id, - onlyList: true - }) + let tracks = await TrackClass.get(tracks_ids, { + user_id: user_id, + onlyList: true, + }) - return tracks - } -} \ No newline at end of file + // sort tracks by track_ids + tracks = tracks.sort((a, b) => { + const aIndex = tracks_ids.indexOf(a._id.toString()) + const bIndex = tracks_ids.indexOf(b._id.toString()) + + return aIndex - bIndex + }) + + return tracks + }, +} diff --git a/packages/server/services/music/routes/music/releases/get.js b/packages/server/services/music/routes/music/releases/get.js new file mode 100644 index 00000000..61d89ec0 --- /dev/null +++ b/packages/server/services/music/routes/music/releases/get.js @@ -0,0 +1,27 @@ +import { MusicRelease } from "@db_models" + +export default async (req) => { + const { limit = 50, page = 0, user_id } = req.query + + const trim = limit * page + + const query = { + public: true, + } + + if (user_id) { + query.user_id = user_id + } + + const total_items = await MusicRelease.countDocuments(query) + + const items = await MusicRelease.find(query) + .limit(limit) + .skip(trim) + .sort({ _id: -1 }) + + return { + total_items: total_items, + items: items, + } +} diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/favourite/post.js b/packages/server/services/music/routes/music/tracks/[track_id]/favourite/post.js deleted file mode 100644 index 553ae294..00000000 --- a/packages/server/services/music/routes/music/tracks/[track_id]/favourite/post.js +++ /dev/null @@ -1,17 +0,0 @@ -import TrackClass from "@classes/track" - -export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const { track_id } = req.params - const { to } = req.body - - const track = await TrackClass.toggleFavourite( - req.auth.session.user_id, - track_id, - to, - ) - - return track - } -} \ No newline at end of file diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/is_favourite/get.js b/packages/server/services/music/routes/music/tracks/[track_id]/is_favourite/get.js deleted file mode 100644 index 6ee9e5df..00000000 --- a/packages/server/services/music/routes/music/tracks/[track_id]/is_favourite/get.js +++ /dev/null @@ -1,15 +0,0 @@ -import TrackClass from "@classes/track" - -export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - const { track_id } = req.params - - const likeStatus = await TrackClass.isFavourite( - req.auth.session.user_id, - track_id, - ) - - return likeStatus - } -} \ No newline at end of file diff --git a/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js new file mode 100644 index 00000000..39f35f1a --- /dev/null +++ b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/get.js @@ -0,0 +1,103 @@ +import { TrackLyric } from "@db_models" +import axios from "axios" + +function parseTimeToMs(timeStr) { + const [minutes, seconds, milliseconds] = timeStr.split(":") + + return ( + Number(minutes) * 60 * 1000 + + Number(seconds) * 1000 + + Number(milliseconds) + ) +} + +async function remoteLcrToSyncedLyrics(lrcUrl) { + const { data } = await axios.get(lrcUrl) + + let syncedLyrics = data + + syncedLyrics = syncedLyrics.split("\n") + + syncedLyrics = syncedLyrics.map((line) => { + const syncedLine = {} + + //syncedLine.time = line.match(/\[.*\]/)[0] + syncedLine.time = line.split(" ")[0] + syncedLine.text = line.replace(syncedLine.time, "").trim() + + if (syncedLine.text === "") { + delete syncedLine.text + syncedLine.break = true + } + + syncedLine.time = syncedLine.time.replace(/\[|\]/g, "") + syncedLine.time = syncedLine.time.replace(".", ":") + + return syncedLine + }) + + syncedLyrics = syncedLyrics.map((syncedLine, index) => { + const nextLine = syncedLyrics[index + 1] + + syncedLine.startTimeMs = parseTimeToMs(syncedLine.time) + syncedLine.endTimeMs = nextLine + ? parseTimeToMs(nextLine.time) + : parseTimeToMs(syncedLyrics[syncedLyrics.length - 1].time) + + return syncedLine + }) + + return syncedLyrics +} + +export default async (req) => { + const { track_id } = req.params + let { translate_lang = "original" } = req.query + + let result = await TrackLyric.findOne({ + track_id, + }) + + if (!result) { + throw new OperationError(404, "Track lyric not found") + } + + result = result.toObject() + + result.translated_lang = translate_lang + result.available_langs = [] + + const lrc = result.lrc_v2 ?? result.lrc + + result.isLyricsV2 = !!result.lrc_v2 + + if (typeof lrc === "object") { + result.available_langs = Object.keys(lrc) + + if (!lrc[translate_lang]) { + translate_lang = "original" + } + + if (lrc[translate_lang]) { + if (result.isLyricsV2 === true) { + result.synced_lyrics = await axios.get(lrc[translate_lang]) + + result.synced_lyrics = result.synced_lyrics.data + } else { + result.synced_lyrics = await remoteLcrToSyncedLyrics( + result.lrc[translate_lang], + ) + } + } + } + + if (result.sync_audio_at) { + result.sync_audio_at_ms = parseTimeToMs(result.sync_audio_at) + } + + result.lrc + delete result.lrc_v2 + delete result.__v + + return result +} diff --git a/packages/server/services/music/routes/music/lyrics/[track_id]/put.js b/packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js similarity index 100% rename from packages/server/services/music/routes/music/lyrics/[track_id]/put.js rename to packages/server/services/music/routes/music/tracks/[track_id]/lyrics/put.js diff --git a/packages/server/services/music/routes/music/tracks/get.js b/packages/server/services/music/routes/music/tracks/get.js new file mode 100644 index 00000000..3dda149a --- /dev/null +++ b/packages/server/services/music/routes/music/tracks/get.js @@ -0,0 +1,29 @@ +import { Track } from "@db_models" + +export default async (req) => { + const { limit = 50, page = 0, user_id } = req.query + + const trim = limit * page + + const query = { + public: true, + } + + if (user_id) { + query.publisher = { + user_id: user_id, + } + } + + const total_items = await Track.countDocuments(query) + + const items = await Track.find(query) + .limit(limit) + .skip(trim) + .sort({ _id: -1 }) + + return { + total_items: total_items, + items: items, + } +} diff --git a/packages/server/services/posts/package.json b/packages/server/services/posts/package.json index 925e670e..39954b92 100644 --- a/packages/server/services/posts/package.json +++ b/packages/server/services/posts/package.json @@ -1,7 +1,7 @@ { "name": "posts", "dependencies": { - "linebridge": "^1.0.0-a3", + "linebridge": "^1.0.0-alpha.2", "moment-timezone": "^0.5.45" } } diff --git a/packages/server/services/posts/posts.service.js b/packages/server/services/posts/posts.service.js index 1a226219..bf067cd3 100644 --- a/packages/server/services/posts/posts.service.js +++ b/packages/server/services/posts/posts.service.js @@ -1,4 +1,4 @@ -//import { Server } from "../../../../linebridge/server/src" +//import { Server } from "../../../../linebridge/server/dist" import { Server } from "linebridge" import DbManager from "@shared-classes/DbManager" @@ -10,11 +10,13 @@ import SharedMiddlewares from "@shared-middlewares" export default class API extends Server { static refName = "posts" - static websockets = true static listenPort = process.env.HTTP_LISTEN_PORT ?? 3001 - static useMiddlewares = ["logs"] + + static websockets = true static bypassCors = true + static useMiddlewares = ["logs"] + middlewares = { ...SharedMiddlewares, } diff --git a/packages/server/services/search/collectors/tracks.js b/packages/server/services/search/collectors/tracks.js index daa0a55d..dd499f13 100644 --- a/packages/server/services/search/collectors/tracks.js +++ b/packages/server/services/search/collectors/tracks.js @@ -5,7 +5,14 @@ export default { model: Track, query: (keywords) => { return { - $or: [{ title: new RegExp(keywords, "i") }], + $or: [ + { + title: new RegExp(keywords, "i"), + }, + { + artist: new RegExp(keywords, "i"), + }, + ], } }, } diff --git a/packages/server/services/search/routes/search/get.js b/packages/server/services/search/routes/search/get.js index 3d20ef6f..fedce63b 100644 --- a/packages/server/services/search/routes/search/get.js +++ b/packages/server/services/search/routes/search/get.js @@ -56,6 +56,17 @@ export default { const totalItems = await collection.model.countDocuments(query) + if (typeof collection.aggregation === "function") { + const aggregation = await collection.model.aggregate( + collection.aggregation(keywords), + ) + + results[collection.key].items = aggregation + results[collection.key].total_items = aggregation.length + + return aggregation + } + let result = await collection.model .find(query) .limit(limit) diff --git a/packages/server/services/users/classes/users/method/data.js b/packages/server/services/users/classes/users/method/data.js index ef62902a..9b4f8e09 100644 --- a/packages/server/services/users/classes/users/method/data.js +++ b/packages/server/services/users/classes/users/method/data.js @@ -1,7 +1,7 @@ import { User, UserFollow } from "@db_models" export default async (payload = {}) => { - const { user_id, from_user_id, basic } = payload + const { user_id, from_user_id, basic = true, add } = payload if (!user_id) { throw new OperationError(400, "Missing user_id") @@ -20,13 +20,22 @@ export default async (payload = {}) => { usersData = usersData.map((user) => user.toObject()) } else { - const userData = await User.findOne({ _id: user_id }) + let query = User.findOne({ _id: user_id }) - if (!userData) { + if (Array.isArray(add) && add.length > 0) { + const fieldsToSelect = add.map((field) => `+${field}`).join(" ") + query = query.select(fieldsToSelect) + } + + usersData = await query + + if (!usersData) { throw new OperationError(404, "User not found") } - usersData = [userData.toObject()] + usersData = usersData.toObject() + + usersData = [usersData] } if (from_user_id && !basic) { diff --git a/packages/server/services/users/routes/users/[user_id]/public-key/get.js b/packages/server/services/users/routes/users/[user_id]/public-key/get.js new file mode 100644 index 00000000..15d50b99 --- /dev/null +++ b/packages/server/services/users/routes/users/[user_id]/public-key/get.js @@ -0,0 +1,24 @@ +import { UserPublicKey } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const targetUserId = req.params.user_id + + const publicKeyRecord = await UserPublicKey.findOne({ + user_id: targetUserId, + }) + + if (!publicKeyRecord) { + return { + exists: false, + public_key: null, + } + } + + return { + exists: true, + public_key: publicKeyRecord.public_key, + } + }, +} diff --git a/packages/server/services/users/routes/users/search/get.js b/packages/server/services/users/routes/users/search/get.js deleted file mode 100644 index 2faf1a3b..00000000 --- a/packages/server/services/users/routes/users/search/get.js +++ /dev/null @@ -1,44 +0,0 @@ -import { User } from "@db_models" - -const ALLOWED_FIELDS = [ - "username", - "publicName", - "id", -] - -export default { - middlewares: ["withOptionalAuthentication"], - fn: async (req, res) => { - const { keywords, limit = 50 } = req.query - - let filters = {} - - if (keywords) { - keywords.split(";").forEach((pair) => { - const [field, value] = pair.split(":") - - if (value === "" || value === " ") { - return - } - - // Verifica que el campo esté en los permitidos y que tenga un valor - if (ALLOWED_FIELDS.includes(field) && value) { - // Si el campo es "id", se busca coincidencia exacta - if (field === "id") { - filters[field] = value - } else { - // Para otros campos, usa $regex para coincidencias parciales - filters[field] = { $regex: `\\b${value}`, $options: "i" } - } - } - }) - } - - console.log(filters) - - let users = await User.find(filters) - .limit(limit) - - return users - } -} \ No newline at end of file diff --git a/packages/server/services/users/routes/users/self/get.js b/packages/server/services/users/routes/users/self/get.js index b5056338..5cb1f441 100644 --- a/packages/server/services/users/routes/users/self/get.js +++ b/packages/server/services/users/routes/users/self/get.js @@ -1,10 +1,11 @@ import Users from "@classes/users" export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - return await Users.data({ - user_id: req.auth.session.user_id, - }) - } -} \ No newline at end of file + middlewares: ["withAuthentication"], + fn: async (req) => { + return await Users.data({ + user_id: req.auth.session.user_id, + add: ["email"], + }) + }, +} diff --git a/packages/server/services/users/routes/users/self/keypair/get.js b/packages/server/services/users/routes/users/self/keypair/get.js new file mode 100644 index 00000000..35416987 --- /dev/null +++ b/packages/server/services/users/routes/users/self/keypair/get.js @@ -0,0 +1,12 @@ +import { UserDHKeyPair } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const userId = req.auth.session.user_id + + return await UserDHKeyPair.findOne({ + user_id: userId, + }) + }, +} diff --git a/packages/server/services/users/routes/users/self/keypair/post.js b/packages/server/services/users/routes/users/self/keypair/post.js new file mode 100644 index 00000000..97e528d6 --- /dev/null +++ b/packages/server/services/users/routes/users/self/keypair/post.js @@ -0,0 +1,28 @@ +import { UserDHKeyPair } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const userId = req.auth.session.user_id + const { str } = req.body + + if (!str) { + throw new Error("DH key pair string is missing `str:string`") + } + + let record = await UserDHKeyPair.findOne({ + user_id: userId, + }) + + if (record) { + throw new OperationError(400, "DH key pair already exists") + } + + record = await UserDHKeyPair.create({ + user_id: userId, + str: str, + }) + + return record + }, +} diff --git a/packages/server/services/users/routes/users/self/public-key/post.js b/packages/server/services/users/routes/users/self/public-key/post.js new file mode 100644 index 00000000..542a58fc --- /dev/null +++ b/packages/server/services/users/routes/users/self/public-key/post.js @@ -0,0 +1,36 @@ +import { UserPublicKey } from "@db_models" + +export default { + middlewares: ["withAuthentication"], + fn: async (req) => { + const userId = req.auth.session.user_id + const { public_key } = req.body + + if (!public_key) { + throw new OperationError(400, "Public key is required") + } + + // Buscar o crear registro de clave pública + let record = await UserPublicKey.findOne({ user_id: userId }) + + if (!record) { + // Crear nuevo registro + record = await UserPublicKey.create({ + user_id: userId, + public_key: public_key, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + }) + } else { + // Actualizar registro existente + record.public_key = public_key + record.updated_at = new Date().getTime() + await record.save() + } + + return { + success: true, + message: "Public key updated successfully", + } + }, +}