@@ -141,40 +144,7 @@ const PlayerController = React.forwardRef((props, ref) => {
{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}
+
-
-
-
- })
- }
-
+
+
+
+ )
+ })}
+
-
-
-
-
}
- 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}
-
- :
- }
-
-
-
-
-
- {
- 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"}
+ />
+
+
+
+
+ Save
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+ )}
+
+ {rtsp && (
+
+
+ RTSP [tcp]
+
+
+
+ This protocol has the lowest possible latency and
+ the best quality. A compatible player is required.
+
+
+
+
+ )}
+
+ {rtspt && (
+
+
+ RTSPT [vrchat]
+
+
+
+ This protocol has the lowest possible latency and
+ the best quality available. Only works for VRChat
+ video players.
+
+
+
+
+ )}
+
+ {html && (
+
+
+ HTML Viewer
+
+
+
+ Share a link to easily view your stream on any
+ device with a web browser.
+
+
+
+
+ )}
+
+ )
+}
+
+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
-
-
-
-
-
-
-
- Change profile name
-
-
-
-
-
-
- )
-}
-
-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
-
-
- {
- 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 (
+
+
+
+ {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}
+
+
+
+
+
-
-
+
+
-
-
+
+
-
- {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",
+ }
+ },
+}