@@ -420,6 +491,20 @@ export default function SearchView({
setQueueTracks([]);
playTrack(trackObj);
}}
+ onPlayVideo={(hit) => {
+ const videoTrack: Track = {
+ id: hit.id || 0,
+ title: hit.title || "",
+ itemType: "video",
+ imageId: hit.image,
+ duration: hit.duration || 0,
+ artist: hit.artistName
+ ? { id: 0, name: hit.artistName }
+ : undefined,
+ };
+ setQueueTracks([]);
+ playTrack(videoTrack);
+ }}
onAlbumClick={(hit) => {
if (hit.id)
navigateToAlbum(hit.id, {
@@ -468,6 +553,13 @@ export default function SearchView({
/>
)}
+ {/* Videos tab */}
+ {activeTab === "videos" && results.videos.length > 0 && (
+
+ {results.videos.map(renderVideoCard)}
+
+ )}
+
{/* Playlists tab */}
{activeTab === "playlists" && results.playlists.length > 0 && (
@@ -593,6 +685,7 @@ export default function SearchView({
function TopHitsList({
topHits,
onPlayTrack,
+ onPlayVideo,
onTrackAlbumClick,
onAlbumClick,
onArtistClick,
@@ -601,6 +694,7 @@ function TopHitsList({
}: {
topHits: DirectHitItem[];
onPlayTrack: (hit: DirectHitItem) => void;
+ onPlayVideo: (hit: DirectHitItem) => void;
onTrackAlbumClick: (hit: DirectHitItem) => void;
onAlbumClick: (hit: DirectHitItem) => void;
onArtistClick: (hit: DirectHitItem) => void;
@@ -719,6 +813,64 @@ function TopHitsList({
);
}
+ if (hit.hitType === "VIDEOS") {
+ const videoMedia: MediaItemType = {
+ type: "video",
+ id: hit.id || 0,
+ title: hit.title || "",
+ imageId: hit.image,
+ artist: hit.artistName,
+ duration: hit.duration,
+ };
+ return (
+
onPlayVideo(hit)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onMediaContextMenu(videoMedia, { x: e.clientX, y: e.clientY });
+ }}
+ >
+
{
+ e.stopPropagation();
+ onPlayVideo(hit);
+ }}
+ >
+
+
+
+
+
+ {hit.title}
+
+
+ Video · {hit.artistName || "Unknown Artist"}
+
+
+
{
+ e.stopPropagation();
+ onMediaContextMenu(videoMedia, { x: e.clientX, y: e.clientY });
+ }}
+ >
+
+
+
+ );
+ }
if (hit.hitType === "ALBUMS") {
return (
element (videoPlayingAtom),
+ // not the audio pipeline (isPlayingAtom); reflect whichever is live so the
+ // pause/resume icon is correct for both. For audio sources currentVideo is null,
+ // so this is identical to the previous isPlaying-only behavior.
+ const effectivePlaying = currentVideo ? videoPlaying : isPlaying;
const fromThisSource =
playbackSource?.type === sourceType && playbackSource?.id === sourceId;
const buttonState = fromThisSource
- ? isPlaying
+ ? effectivePlaying
? "pause"
: "resume"
: "play";
const handleClick = async () => {
if (fromThisSource) {
- if (isPlaying) {
- await pauseTrack();
- } else {
- await resumeTrack();
- }
+ await togglePlayPause();
return;
}
onPlay();
diff --git a/src/components/TrackContextMenu.tsx b/src/components/TrackContextMenu.tsx
index 2ab87d8f..93d4f4ef 100644
--- a/src/components/TrackContextMenu.tsx
+++ b/src/components/TrackContextMenu.tsx
@@ -15,7 +15,7 @@ import { useNavigation } from "../hooks/useNavigation";
import { usePlaylists } from "../hooks/usePlaylists";
import { useContextMenu } from "../hooks/useContextMenu";
import { getTidalImageUrl, getTrackDisplayTitle, type Track } from "../types";
-import { getTrackShareUrl } from "../utils/itemHelpers";
+import { getTrackShareUrl, getVideoShareUrl } from "../utils/itemHelpers";
import { isTrackUnavailable } from "../lib/trackAvailability";
import AddToPlaylistMenu from "./AddToPlaylistMenu";
import MenuPortal from "./MenuPortal";
@@ -175,13 +175,17 @@ export default function TrackContextMenu({
const handleShare = useCallback(async () => {
try {
- await navigator.clipboard.writeText(getTrackShareUrl(track.id));
+ const url =
+ track.itemType === "video"
+ ? getVideoShareUrl(track.id)
+ : getTrackShareUrl(track.id);
+ await navigator.clipboard.writeText(url);
showToast("Copied share link to clipboard");
} catch {
showToast("Failed to copy link", "error");
}
onClose();
- }, [track.id, showToast, onClose]);
+ }, [track.id, track.itemType, showToast, onClose]);
const menuItemClass =
"w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hl-faint transition-colors text-left text-[14px] text-th-text-secondary hover:text-th-text-primary";
@@ -239,22 +243,23 @@ export default function TrackContextMenu({
- {/* Go to track radio (hidden if mixes is populated but TRACK_MIX is absent) */}
- {(!track.mixes || !!track.mixes?.TRACK_MIX) && (
- <>
-
-
-
-
- {radioLoading ? "Loading radio…" : "Go to track radio"}
-
-
- >
- )}
+ {/* Go to track radio (not for videos; hidden if mixes is populated but TRACK_MIX is absent) */}
+ {track.itemType !== "video" &&
+ (!track.mixes || !!track.mixes?.TRACK_MIX) && (
+ <>
+
+
+
+
+ {radioLoading ? "Loading radio…" : "Go to track radio"}
+
+
+ >
+ )}
{/* Share */}
diff --git a/src/components/TrackList.tsx b/src/components/TrackList.tsx
index 38cd89dc..c3a007d8 100644
--- a/src/components/TrackList.tsx
+++ b/src/components/TrackList.tsx
@@ -6,6 +6,7 @@ import {
ListPlus,
ChevronUp,
ChevronDown,
+ Video,
} from "lucide-react";
import { type Track, getTidalImageUrl, getTrackDisplayTitle } from "../types";
import ExplicitBadge from "./ExplicitBadge";
@@ -27,7 +28,7 @@ import {
isPlayingAtom,
allowExplicitAtom,
} from "../atoms/playback";
-import { favoriteTrackIdsAtom } from "../atoms/favorites";
+import { favoriteTrackIdsAtom, favoriteVideoIdsAtom } from "../atoms/favorites";
import { useNavigation } from "../hooks/useNavigation";
import { useFavorites } from "../hooks/useFavorites";
import { useToast } from "../contexts/ToastContext";
@@ -133,10 +134,18 @@ const TrackRow = memo(function TrackRow({
onAddToCurrentPlaylist,
}: TrackRowProps) {
const favoriteTrackIds = useAtomValue(favoriteTrackIdsAtom);
+ const favoriteVideoIds = useAtomValue(favoriteVideoIdsAtom);
const { navigateToAlbum } = useNavigation();
- const { addFavoriteTrack, removeFavoriteTrack } = useFavorites();
+ const {
+ addFavoriteTrack,
+ removeFavoriteTrack,
+ addFavoriteVideo,
+ removeFavoriteVideo,
+ } = useFavorites();
const { showToast } = useToast();
+ const isVideo = track.itemType === "video";
+
const [playlistMenuOpen, setPlaylistMenuOpen] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const [contextMenuCursorPos, setContextMenuCursorPos] = useState<
@@ -166,16 +175,25 @@ const TrackRow = memo(function TrackRow({
const playing = useAtomValue(isPlayingHereAtom);
// Don't grey out the actively-playing row even if its metadata has been
- // refreshed to streamReady:false — audio is the source of truth.
- const isUnavailable = isTrackUnavailable(track) && !isActive;
+ // refreshed to streamReady:false — audio is the source of truth. Videos are
+ // never gated on audio stream flags.
+ const isUnavailable = !isVideo && isTrackUnavailable(track) && !isActive;
const isInactive = isBlocked || isUnavailable;
- const isFav = favoriteTrackIds.has(track.id);
+ const isFav = isVideo
+ ? favoriteVideoIds.has(track.id)
+ : favoriteTrackIds.has(track.id);
const toggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
- if (isFav) {
+ if (isVideo) {
+ if (isFav) {
+ await removeFavoriteVideo(track.id);
+ } else {
+ await addFavoriteVideo(track.id);
+ }
+ } else if (isFav) {
await removeFavoriteTrack(track.id);
} else {
await addFavoriteTrack(track.id, track);
@@ -265,10 +283,18 @@ const TrackRow = memo(function TrackRow({
{showCover && (
+ {isVideo && (
+
+
+
+ )}
)}
@@ -280,6 +306,11 @@ const TrackRow = memo(function TrackRow({
>
{getTrackDisplayTitle(track)}
+ {isVideo && (
+
+ VIDEO
+
+ )}
{track.explicit && }
{!showArtist && (
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx
new file mode 100644
index 00000000..f09dbff9
--- /dev/null
+++ b/src/components/VideoPlayer.tsx
@@ -0,0 +1,724 @@
+import {
+ Play,
+ Pause,
+ X,
+ Maximize2,
+ Minimize2,
+ Settings,
+ Loader2,
+ Heart,
+ SkipBack,
+ SkipForward,
+ Shuffle,
+ Repeat,
+ MoreHorizontal,
+} from "lucide-react";
+import {
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+ memo,
+ type RefObject,
+} from "react";
+import { useAtomValue, useSetAtom, useAtom, useStore } from "jotai";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import {
+ currentVideoAtom,
+ videoStreamAtom,
+ videoPlayingAtom,
+ videoFullscreenAtom,
+ videoExpandedAtom,
+} from "../atoms/video";
+import { useVideoPlayback, type VideoQuality } from "../hooks/useVideoPlayback";
+import { usePlaybackActions } from "../hooks/usePlaybackActions";
+import { useFavorites } from "../hooks/useFavorites";
+import {
+ volumeAtom,
+ shuffleAtom,
+ repeatAtom,
+ currentTrackAtom,
+} from "../atoms/playback";
+import { favoriteVideoIdsAtom } from "../atoms/favorites";
+import { getTidalImageUrl } from "../types";
+import { formatTime } from "../lib/format";
+import { videoElementRef } from "../lib/videoElement";
+import ExplicitBadge from "./ExplicitBadge";
+import VolumeSlider from "./VolumeSlider";
+import TrackContextMenu from "./TrackContextMenu";
+
+const QUALITIES: VideoQuality[] = ["HIGH", "MEDIUM", "LOW"];
+const QUALITY_LABEL: Record
= {
+ HIGH: "High",
+ MEDIUM: "Medium",
+ LOW: "Low",
+};
+
+// ─── HLS attach: native-first, hls.js fallback ──────────────────────────────
+
+/** Returns a cleanup fn. Tries native HLS; falls back to hls.js (MSE). */
+function attachHls(
+ video: HTMLVideoElement,
+ url: string,
+ onFatal: () => void,
+): () => void {
+ let destroyed = false;
+ let hlsInstance: { destroy: () => void } | null = null;
+
+ const canNative =
+ video.canPlayType("application/vnd.apple.mpegurl") !== "" ||
+ video.canPlayType("application/x-mpegURL") !== "";
+
+ const startFallback = () => {
+ if (destroyed) return;
+ import("hls.js")
+ .then(({ default: Hls }) => {
+ if (destroyed) return;
+ if (!Hls.isSupported()) {
+ onFatal();
+ return;
+ }
+ const hls = new Hls({
+ enableWorker: true,
+ // Cold-start optimistically so the auto start-level selection lands on the
+ // top rendition instead of the conservative 500 kbps default.
+ abrEwmaDefaultEstimate: 10_000_000,
+ });
+ hlsInstance = hls;
+ hls.on(Hls.Events.MANIFEST_PARSED, (_e, data) => {
+ // Begin at the top rendition of the requested quality ladder (TIDAL caps
+ // the ladder by the videoquality we ask for), so playback starts at the
+ // SELECTED quality rather than ramping up. ABR stays enabled, so it still
+ // adapts DOWN when real bandwidth can't sustain it.
+ if (data.levels && data.levels.length > 0) {
+ hls.startLevel = data.levels.length - 1;
+ }
+ });
+ hls.on(Hls.Events.ERROR, (_e, data) => {
+ if (data.fatal) onFatal();
+ });
+ hls.loadSource(url);
+ hls.attachMedia(video);
+ })
+ .catch(() => {
+ if (!destroyed) onFatal();
+ });
+ };
+
+ if (canNative) {
+ // Native path: set src directly; on a load error, fall back to hls.js.
+ const onError = () => {
+ video.removeEventListener("error", onError);
+ startFallback();
+ };
+ video.addEventListener("error", onError);
+ video.src = url;
+ return () => {
+ destroyed = true;
+ video.removeEventListener("error", onError);
+ hlsInstance?.destroy();
+ video.removeAttribute("src");
+ video.load();
+ };
+ }
+
+ startFallback();
+ return () => {
+ destroyed = true;
+ hlsInstance?.destroy();
+ video.removeAttribute("src");
+ video.load();
+ };
+}
+
+// ─── Scrubber (bound to the element, not playbackPosition.ts) ────────
+
+const VideoScrubber = memo(function VideoScrubber({
+ videoRef,
+ resetHideTimer,
+ isDraggingRef,
+}: {
+ videoRef: RefObject;
+ resetHideTimer: () => void;
+ isDraggingRef: RefObject;
+}) {
+ const [position, setPosition] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [dragging, setDragging] = useState(false);
+ const [hovering, setHovering] = useState(false);
+ const trackRef = useRef(null);
+
+ // Poll the element's currentTime via rAF, but throttle the React state update to
+ // ~6-7 Hz. Updating every frame (60 Hz) re-renders + repaints the progress bar each
+ // frame, which at 4K fullscreen contends with video compositing and causes stutter.
+ // A progress bar has no need for 60 Hz; ~150 ms is visually identical and far cheaper.
+ useEffect(() => {
+ let raf: number;
+ let last = 0;
+ const tick = (now: number) => {
+ const v = videoRef.current;
+ if (v && !isDraggingRef.current && now - last >= 150) {
+ last = now;
+ setPosition(v.currentTime);
+ if (v.duration && !Number.isNaN(v.duration)) setDuration(v.duration);
+ }
+ raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [videoRef, isDraggingRef]);
+
+ const seekToClientX = useCallback(
+ (clientX: number) => {
+ const track = trackRef.current;
+ const v = videoRef.current;
+ if (!track || !v || !duration) return;
+ const rect = track.getBoundingClientRect();
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
+ const target = ratio * duration;
+ setPosition(target);
+ v.currentTime = target;
+ },
+ [videoRef, duration],
+ );
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ setDragging(true);
+ isDraggingRef.current = true;
+ seekToClientX(e.clientX);
+
+ const onMove = (me: MouseEvent) => seekToClientX(me.clientX);
+ const onUp = () => {
+ setDragging(false);
+ isDraggingRef.current = false;
+ resetHideTimer();
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ },
+ [seekToClientX, isDraggingRef, resetHideTimer],
+ );
+
+ const progress = duration > 0 ? (position / duration) * 100 : 0;
+
+ return (
+
+
+ {formatTime(position)}
+
+
setHovering(true)}
+ onMouseLeave={() => {
+ if (!dragging) setHovering(false);
+ }}
+ className="scrubber flex-1 relative cursor-pointer h-[17px] flex items-center"
+ >
+
+
+
+
+ {formatTime(duration)}
+
+
+ );
+});
+
+// ─── VideoPlayer ─────────────────────────────────────────────────────────────
+
+export default function VideoPlayer() {
+ const store = useStore();
+ const video = useAtomValue(currentVideoAtom);
+ const stream = useAtomValue(videoStreamAtom);
+ const setVideoPlaying = useSetAtom(videoPlayingAtom);
+ const fullscreen = useAtomValue(videoFullscreenAtom);
+ const setFullscreen = useSetAtom(videoFullscreenAtom);
+ const expanded = useAtomValue(videoExpandedAtom);
+ const volume = useAtomValue(volumeAtom);
+ const favoriteVideoIds = useAtomValue(favoriteVideoIdsAtom);
+ const isShuffle = useAtomValue(shuffleAtom);
+ const [repeatMode, setRepeatMode] = useAtom(repeatAtom);
+ const currentTrack = useAtomValue(currentTrackAtom);
+ const { closeVideo, minimizeVideo, setVideoQuality } = useVideoPlayback();
+ const { playNext, playPrevious, toggleShuffle } = usePlaybackActions();
+ const { addFavoriteVideo, removeFavoriteVideo } = useFavorites();
+
+ const videoRef = useRef(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [failed, setFailed] = useState(false);
+ const [quality, setQuality] = useState("HIGH");
+ const [qualityMenuOpen, setQualityMenuOpen] = useState(false);
+ // VideoPlayer persists across videos (it never unmounts), but every new video
+ // starts at HIGH (startVideoSession's default). Reset the quality label when the
+ // video changes so it never shows the previous video's selection (which also made
+ // re-selecting that quality a no-op via the `q === quality` guard). Use React's
+ // render-phase "adjust state on change" pattern (guarded by a tracked id) rather
+ // than a setState-in-effect, which would trip react-hooks/set-state-in-effect.
+ const [qualityForVideoId, setQualityForVideoId] = useState(video?.id);
+ if (video?.id !== qualityForVideoId) {
+ setQualityForVideoId(video?.id);
+ setQuality("HIGH");
+ }
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuAnchorRef = useRef(null);
+
+ // Attach the HLS stream (native-first, hls.js fallback). Autoplay must wait until
+ // the source is actually attached: attachHls's hls.js path is async (dynamic import),
+ // so calling play() synchronously here races the attach and no-ops, parking the video
+ // on the first frame until a manual toggle. Start on `canplay` instead — once per
+ // video, so a quality re-attach doesn't override a user's paused state.
+ const autoplayedIdRef = useRef(null);
+ useEffect(() => {
+ const v = videoRef.current;
+ if (!v || !stream?.url) return;
+ setLoading(true);
+ setFailed(false);
+ const detach = attachHls(v, stream.url, () => setFailed(true));
+ const onCanPlay = () => {
+ v.removeEventListener("canplay", onCanPlay);
+ if (video?.id != null && autoplayedIdRef.current !== video.id) {
+ autoplayedIdRef.current = video.id;
+ v.play().catch(() => {});
+ }
+ };
+ v.addEventListener("canplay", onCanPlay);
+ // WebKitGTK's MSE video sink can leave the first frame frozen when playback
+ // begins on a stable rendition (audio + clock advance, but nothing repaints
+ // until a seek). Do a one-shot micro-seek the moment playback starts near the
+ // top — the same flush a manual seek performs — to force the sink to paint.
+ // Skipped once we're past the start (e.g. a quality switch restoring position),
+ // where the restore seek already flushes the sink.
+ let nudged = false;
+ const onPlayingNudge = () => {
+ if (nudged) return;
+ nudged = true;
+ v.removeEventListener("playing", onPlayingNudge);
+ if (v.currentTime < 0.05) v.currentTime = 0.05;
+ };
+ v.addEventListener("playing", onPlayingNudge);
+ return () => {
+ v.removeEventListener("canplay", onCanPlay);
+ v.removeEventListener("playing", onPlayingNudge);
+ detach();
+ };
+ }, [stream?.url, video?.id]);
+
+ // Publish the live element so the player bar can drive it when minimized.
+ useEffect(() => {
+ videoElementRef.current = videoRef.current;
+ return () => {
+ videoElementRef.current = null;
+ };
+ }, []);
+
+ // Drive the element's audio from the shared volume atom (the GStreamer
+ // pipeline is stopped during video, so the slider only reaches us here).
+ useEffect(() => {
+ if (videoRef.current) videoRef.current.volume = volume;
+ }, [volume]);
+
+ // Mirror element play state into local + atom.
+ useEffect(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ const onPlay = () => {
+ setIsPlaying(true);
+ setVideoPlaying(true);
+ };
+ const onPause = () => {
+ setIsPlaying(false);
+ setVideoPlaying(false);
+ };
+ const onPlaying = () => setLoading(false);
+ const onWaiting = () => setLoading(true);
+ const onError = () => setFailed(true);
+ const onEnded = async () => {
+ const endedId = store.get(currentVideoAtom)?.id;
+ const endedStream = store.get(videoStreamAtom);
+ await playNext();
+ // 'ended' fires no 'pause', so videoPlaying stays true; if playNext found
+ // nothing to advance to (empty queue, no repeat/autoplay), the same video is
+ // still current and the element is parked at its end. Clear the overlay so it
+ // doesn't sit frozen on the last frame under a stuck Pause icon. Guard against
+ // a same-id RESTART (e.g. repeat-all looping a lone video), which re-seeds a
+ // fresh stream via startVideoSession — the stream reference changing means
+ // playback advanced, so leave the overlay up. (Repeat-one rewinds in place and
+ // clears `ended` synchronously, so `el.ended` already excludes it.)
+ const el = videoRef.current;
+ if (
+ el?.ended &&
+ store.get(currentVideoAtom)?.id === endedId &&
+ store.get(videoStreamAtom) === endedStream
+ ) {
+ closeVideo();
+ }
+ };
+ v.addEventListener("play", onPlay);
+ v.addEventListener("pause", onPause);
+ v.addEventListener("playing", onPlaying);
+ v.addEventListener("waiting", onWaiting);
+ v.addEventListener("error", onError);
+ v.addEventListener("ended", onEnded);
+ return () => {
+ v.removeEventListener("play", onPlay);
+ v.removeEventListener("pause", onPause);
+ v.removeEventListener("playing", onPlaying);
+ v.removeEventListener("waiting", onWaiting);
+ v.removeEventListener("error", onError);
+ v.removeEventListener("ended", onEnded);
+ };
+ }, [setVideoPlaying, playNext, closeVideo, store]);
+
+ const togglePlay = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (v.paused) v.play().catch(() => {});
+ else v.pause();
+ }, []);
+
+ const toggleFullscreen = useCallback(() => {
+ const next = !fullscreen;
+ setFullscreen(next);
+ getCurrentWindow()
+ .setFullscreen(next)
+ .catch(() => {});
+ }, [fullscreen, setFullscreen]);
+
+ const handleQualityChange = useCallback(
+ async (q: VideoQuality) => {
+ setQualityMenuOpen(false);
+ if (q === quality) return;
+ setQuality(q);
+ const v = videoRef.current;
+ const resumeAt = v?.currentTime ?? 0;
+ const wasPlaying = v ? !v.paused : true;
+ const newStream = await setVideoQuality(q);
+ // The stream atom change re-runs the attach effect; restore position once
+ // the new source can seek.
+ if (newStream && v) {
+ const onLoaded = () => {
+ v.currentTime = resumeAt;
+ if (wasPlaying) v.play().catch(() => {});
+ v.removeEventListener("loadedmetadata", onLoaded);
+ };
+ v.addEventListener("loadedmetadata", onLoaded);
+ }
+ },
+ [quality, setVideoQuality],
+ );
+
+ // Auto-hide controls on mouse idle (mirrors MaximizedPlayer).
+ const [controlsVisible, setControlsVisible] = useState(true);
+ const controlsVisibleRef = useRef(true);
+ const hideTimerRef = useRef>(undefined);
+ const isDraggingRef = useRef(false);
+ const lastMousePos = useRef({ x: 0, y: 0 });
+
+ const resetHideTimer = useCallback((e?: React.MouseEvent) => {
+ if (e) {
+ const { clientX, clientY } = e;
+ const last = lastMousePos.current;
+ if (clientX === last.x && clientY === last.y) return;
+ lastMousePos.current = { x: clientX, y: clientY };
+ }
+ if (!controlsVisibleRef.current) {
+ controlsVisibleRef.current = true;
+ setControlsVisible(true);
+ }
+ clearTimeout(hideTimerRef.current);
+ hideTimerRef.current = setTimeout(() => {
+ if (!isDraggingRef.current) {
+ controlsVisibleRef.current = false;
+ setControlsVisible(false);
+ }
+ }, 3000);
+ }, []);
+
+ useEffect(() => {
+ resetHideTimer();
+ return () => clearTimeout(hideTimerRef.current);
+ }, [resetHideTimer]);
+
+ // ESC closes (exits fullscreen first if active) — but only when the overlay is
+ // actually on screen. A minimized/background video must survive an ESC meant for
+ // dismissing a menu, search box, or other UI.
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ if (!expanded) return;
+ if (fullscreen) {
+ setFullscreen(false);
+ getCurrentWindow()
+ .setFullscreen(false)
+ .catch(() => {});
+ return;
+ }
+ closeVideo();
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [expanded, fullscreen, setFullscreen, closeVideo]);
+
+ if (!video) return null;
+
+ const artistName =
+ video.artist?.name ||
+ video.artists?.map((a) => a.name).join(", ") ||
+ "";
+
+ const isFavorite = favoriteVideoIds.has(video.id);
+
+ return (
+
+
+
+ {/* Loading / error overlay */}
+ {(loading || failed) && (
+
+ {failed ? (
+
+ Unable to play this video
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Top bar — title + close */}
+
+
+
+
+ {video.title}
+
+ {video.explicit && }
+
+ {artistName && (
+
+ {artistName}
+
+ )}
+
+
+
+
+
+
+ {/* Bottom bar — transport + scrubber */}
+
+
+
+ {/* Left: favorite + more options */}
+
+
+ isFavorite
+ ? removeFavoriteVideo(video.id)
+ : addFavoriteVideo(video.id)
+ }
+ className={`transition-colors duration-150 ${
+ isFavorite
+ ? "text-th-accent"
+ : "text-th-text-faint hover:text-th-text-primary"
+ }`}
+ title={isFavorite ? "Remove from favorites" : "Add to favorites"}
+ >
+
+
+
+ {currentTrack && (
+ <>
+ setMenuOpen(true)}
+ className="text-th-text-faint hover:text-th-text-primary transition-colors duration-150 active:scale-90"
+ title="More options"
+ >
+
+
+ {menuOpen && (
+ setMenuOpen(false)}
+ />
+ )}
+ >
+ )}
+
+
+ {/* Center: transport */}
+
+
+
+
+
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
playNext({ explicit: true })}
+ className="text-th-text-secondary hover:text-th-text-primary transition-colors duration-150 active:scale-90"
+ title="Next"
+ >
+
+
+
setRepeatMode((repeatMode + 1) % 3)}
+ className={`relative transition-colors duration-150 ${
+ repeatMode > 0
+ ? "text-th-accent"
+ : "text-th-text-secondary hover:text-th-text-primary"
+ }`}
+ title="Repeat"
+ >
+
+ {repeatMode === 2 && (
+
+ 1
+
+ )}
+
+
+
+ {/* Right: volume + quality + fullscreen */}
+
+
+
+ {/* Quality selector */}
+
+
setQualityMenuOpen((v) => !v)}
+ className={`flex items-center gap-1.5 text-[13px] transition-colors duration-150 ${
+ qualityMenuOpen
+ ? "text-th-accent"
+ : "text-th-text-faint hover:text-th-text-primary"
+ }`}
+ title="Quality"
+ >
+
+ {QUALITY_LABEL[quality]}
+
+ {qualityMenuOpen && (
+
+ {QUALITIES.map((q) => (
+ handleQualityChange(q)}
+ className={`w-full text-left px-3 py-1.5 text-[13px] transition-colors ${
+ q === quality
+ ? "text-th-accent"
+ : "text-th-text-secondary hover:text-th-text-primary hover:bg-th-hl-faint"
+ }`}
+ >
+ {QUALITY_LABEL[q]}
+
+ ))}
+
+ )}
+
+
+
+ {fullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ViewAllPage.tsx b/src/components/ViewAllPage.tsx
index e43f1e36..6e77fb99 100644
--- a/src/components/ViewAllPage.tsx
+++ b/src/components/ViewAllPage.tsx
@@ -49,6 +49,9 @@ export default function ViewAllPage({
navigateToMix,
} = useNavigation();
const {
+ favoriteVideoIds,
+ addFavoriteVideo,
+ removeFavoriteVideo,
favoriteAlbumIds,
addFavoriteAlbum,
removeFavoriteAlbum,
@@ -155,6 +158,11 @@ export default function ViewAllPage({
}, [artistId, hasMore, handleLoadMore]);
const handleItemClick = (item: any) => {
+ const mediaItem = buildMediaItem(item);
+ if (mediaItem?.type === "video") {
+ playMedia(mediaItem);
+ return;
+ }
if (isTrackItem(item)) {
const allTrackItems = items.filter((t) => isTrackItem(t));
playFromSource(item, allTrackItems, {
@@ -202,6 +210,16 @@ export default function ViewAllPage({
const hasMixes = items.length > 0 && items.every((item) => isMixItem(item));
const getFavoriteProps = (item: any) => {
+ if (buildMediaItem(item)?.type === "video" && item.id) {
+ return {
+ isFavorited: favoriteVideoIds.has(item.id),
+ onFavoriteToggle: (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (favoriteVideoIds.has(item.id)) removeFavoriteVideo(item.id);
+ else addFavoriteVideo(item.id);
+ },
+ };
+ }
if (isArtistItem(item) && item.id) {
return {
isFavorited: followedArtistIds.has(item.id),
diff --git a/src/components/VolumeSlider.tsx b/src/components/VolumeSlider.tsx
index 9ebb4727..0d8b321a 100644
--- a/src/components/VolumeSlider.tsx
+++ b/src/components/VolumeSlider.tsx
@@ -2,6 +2,7 @@ import { memo, useEffect, useRef } from "react";
import { useAtomValue } from "jotai";
import { Volume2, VolumeX, Volume1 } from "lucide-react";
import { volumeAtom, bitPerfectAtom } from "../atoms/playback";
+import { currentVideoAtom } from "../atoms/video";
import { usePlaybackActions } from "../hooks/usePlaybackActions";
interface VolumeSliderProps {
@@ -20,17 +21,21 @@ const VolumeSlider = memo(function VolumeSlider({
}: VolumeSliderProps) {
const volume = useAtomValue(volumeAtom);
const bitPerfect = useAtomValue(bitPerfectAtom);
+ const currentVideo = useAtomValue(currentVideoAtom);
const { setVolume } = usePlaybackActions();
- const displayVolume = bitPerfect ? 1 : volume;
+ // Bit-perfect locks the slider at unity — but only for audio. Video audio is
+ // lossy and plays through the element, so it stays controllable.
+ const locked = bitPerfect && !currentVideo;
+ const displayVolume = locked ? 1 : volume;
const containerRef = useRef(null);
const volumeRef = useRef(volume);
- const bitPerfectRef = useRef(bitPerfect);
+ const lockedRef = useRef(locked);
useEffect(() => {
volumeRef.current = volume;
- bitPerfectRef.current = bitPerfect;
+ lockedRef.current = locked;
});
useEffect(() => {
@@ -38,7 +43,7 @@ const VolumeSlider = memo(function VolumeSlider({
if (!el) return;
const WHEEL_STEP = 0.05;
const handleWheel = (e: WheelEvent) => {
- if (bitPerfectRef.current) return;
+ if (lockedRef.current) return;
e.preventDefault();
const delta = e.deltaY < 0 ? WHEEL_STEP : -WHEEL_STEP;
const next = Math.min(1, Math.max(0, volumeRef.current + delta));
@@ -50,7 +55,7 @@ const VolumeSlider = memo(function VolumeSlider({
}, [setVolume]);
const handleVolumeChange = (e: React.ChangeEvent) => {
- if (bitPerfect) return;
+ if (locked) return;
setVolume(parseFloat(e.target.value));
};
@@ -60,24 +65,24 @@ const VolumeSlider = memo(function VolumeSlider({
return (
{
- if (bitPerfect) return;
+ if (locked) return;
setVolume(volume > 0 ? 0 : 1);
}}
className={`flex-shrink-0 transition-colors duration-150 ${
- bitPerfect
+ locked
? "text-th-text-faint cursor-not-allowed"
: "text-th-text-secondary hover:text-th-text-primary"
}`}
- disabled={bitPerfect}
+ disabled={locked}
>
diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts
index be361a0f..ffea4f46 100644
--- a/src/hooks/useFavorites.ts
+++ b/src/hooks/useFavorites.ts
@@ -3,6 +3,7 @@ import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { invoke } from "@tauri-apps/api/core";
import {
favoriteTrackIdsAtom,
+ favoriteVideoIdsAtom,
favoriteAlbumIdsAtom,
favoritePlaylistUuidsAtom,
followedArtistIdsAtom,
@@ -34,6 +35,7 @@ import type {
export function useFavorites() {
const [favoriteTrackIds, setFavoriteTrackIds] = useAtom(favoriteTrackIdsAtom);
+ const [favoriteVideoIds, setFavoriteVideoIds] = useAtom(favoriteVideoIdsAtom);
const [favoriteAlbumIds, setFavoriteAlbumIds] = useAtom(favoriteAlbumIdsAtom);
const [favoritePlaylistUuids, setFavoritePlaylistUuids] = useAtom(
favoritePlaylistUuidsAtom,
@@ -98,6 +100,52 @@ export function useFavorites() {
[authTokens?.user_id, setFavoriteTrackIds],
);
+ // ==================== Videos ====================
+
+ const addFavoriteVideo = useCallback(
+ async (videoId: number): Promise
=> {
+ if (!authTokens?.user_id) throw new Error("Not authenticated");
+ setFavoriteVideoIds((prev: Set) => new Set([...prev, videoId]));
+ try {
+ await invoke("add_favorite_video", {
+ userId: authTokens.user_id,
+ videoId,
+ });
+ } catch (error: any) {
+ setFavoriteVideoIds((prev: Set) => {
+ const next = new Set(prev);
+ next.delete(videoId);
+ return next;
+ });
+ console.error("Failed to favorite video:", error);
+ throw error;
+ }
+ },
+ [authTokens?.user_id, setFavoriteVideoIds],
+ );
+
+ const removeFavoriteVideo = useCallback(
+ async (videoId: number): Promise => {
+ if (!authTokens?.user_id) throw new Error("Not authenticated");
+ setFavoriteVideoIds((prev: Set) => {
+ const next = new Set(prev);
+ next.delete(videoId);
+ return next;
+ });
+ try {
+ await invoke("remove_favorite_video", {
+ userId: authTokens.user_id,
+ videoId,
+ });
+ } catch (error: any) {
+ setFavoriteVideoIds((prev: Set) => new Set([...prev, videoId]));
+ console.error("Failed to remove favorite video:", error);
+ throw error;
+ }
+ },
+ [authTokens?.user_id, setFavoriteVideoIds],
+ );
+
// ==================== Albums ====================
const addFavoriteAlbum = useCallback(
@@ -341,6 +389,9 @@ export function useFavorites() {
favoriteTrackIds,
addFavoriteTrack,
removeFavoriteTrack,
+ favoriteVideoIds,
+ addFavoriteVideo,
+ removeFavoriteVideo,
favoriteAlbumIds,
addFavoriteAlbum,
removeFavoriteAlbum,
diff --git a/src/hooks/useGaplessPrefetch.ts b/src/hooks/useGaplessPrefetch.ts
index eaf33d21..1ed904c7 100644
--- a/src/hooks/useGaplessPrefetch.ts
+++ b/src/hooks/useGaplessPrefetch.ts
@@ -13,6 +13,7 @@ import {
gaplessAtom,
useTrackGainAtom,
} from "../atoms/playback";
+import { currentVideoAtom } from "../atoms/video";
import type { Track, StreamInfo } from "../types";
export type PendingNext = {
@@ -52,6 +53,7 @@ export function useGaplessPrefetch(
store.get(gaplessAtom) &&
!store.get(exclusiveModeAtom) &&
!store.get(bitPerfectAtom) &&
+ !store.get(currentVideoAtom) && // a video is the current item → not audio-gapless
!!store.get(currentTrackAtom);
if (!enabled) {
await clearSlot();
diff --git a/src/hooks/useMediaPlay.ts b/src/hooks/useMediaPlay.ts
index b3a7e887..343ad71f 100644
--- a/src/hooks/useMediaPlay.ts
+++ b/src/hooks/useMediaPlay.ts
@@ -50,6 +50,20 @@ export function useMediaPlay() {
return;
}
lastInvokeRef.current = now;
+ // Video plays through the queue dispatch as a single-item video so
+ // currentTrackAtom is set consistently with the audio path.
+ if (item.type === "video") {
+ setQueueTracks([]); // single video, no following queue
+ playTrack({
+ id: item.id,
+ title: item.title,
+ itemType: "video",
+ imageId: item.imageId,
+ duration: item.duration,
+ artist: item.artist ? { id: 0, name: item.artist } : undefined,
+ } as Track);
+ return;
+ }
try {
const tracks = await fetchMediaTracks(item);
if (tracks.length > 0) {
diff --git a/src/hooks/useMiniplayerEmitter.ts b/src/hooks/useMiniplayerEmitter.ts
index b0cd978e..66111833 100644
--- a/src/hooks/useMiniplayerEmitter.ts
+++ b/src/hooks/useMiniplayerEmitter.ts
@@ -185,6 +185,7 @@ export function useMiniplayerEmitter() {
const {
pauseTrack,
resumeTrack,
+ togglePlayPause,
playNext,
playPrevious,
toggleShuffle,
@@ -219,9 +220,7 @@ export function useMiniplayerEmitter() {
const { action, value } = event.payload;
switch (action) {
case "toggle-play": {
- const playing = store.get(isPlayingAtom);
- if (playing) await pauseTrack();
- else await resumeTrack();
+ await togglePlayPause();
break;
}
case "play-next":
@@ -360,6 +359,7 @@ export function useMiniplayerEmitter() {
store,
pauseTrack,
resumeTrack,
+ togglePlayPause,
playNext,
playPrevious,
toggleShuffle,
diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts
index 0d397c2e..934f6d21 100644
--- a/src/hooks/useNavigation.ts
+++ b/src/hooks/useNavigation.ts
@@ -46,6 +46,7 @@ export function useNavigation() {
description?: string;
creatorName?: string;
numberOfTracks?: number;
+ numberOfVideos?: number;
isUserPlaylist?: boolean;
},
) => {
diff --git a/src/hooks/usePlaybackActions.ts b/src/hooks/usePlaybackActions.ts
index 2035e6be..23489c84 100644
--- a/src/hooks/usePlaybackActions.ts
+++ b/src/hooks/usePlaybackActions.ts
@@ -33,6 +33,12 @@ import {
consecutiveFailCountAtom,
userPausedAtom,
} from "../atoms/playback";
+import {
+ currentVideoAtom,
+ videoStreamAtom,
+ videoPlayingAtom,
+ videoExpandedAtom,
+} from "../atoms/video";
import { getMixItems, checkNetworkError } from "../api/tidal";
import { useToast } from "../contexts/ToastContext";
import { stampQid, stampQids, ensureQid } from "../lib/qid";
@@ -46,6 +52,8 @@ import {
isUnplayableError,
} from "../lib/trackAvailability";
import { pickGaplessNext } from "../lib/gaplessPredict";
+import { startVideoSession } from "../lib/videoSession";
+import { videoElementRef } from "../lib/videoElement";
import type {
Track,
StreamInfo,
@@ -191,6 +199,46 @@ export function usePlaybackActions() {
return { ok: false, reason: "transient" };
}
lastPlayInvokeRef.current = now;
+ // VIDEO queue item → render in the player; keep it in the queue so
+ // prev/next/history work uniformly. Do NOT touch the audio pipeline beyond stop.
+ if (track.itemType === "video") {
+ store.set(userPausedAtom, false);
+ const v = ensureQid(normalizeTrack(track));
+ const previousTrack = store.get(currentTrackAtom);
+ if (previousTrack && !opts?.skipHistoryPush) {
+ const h = [...store.get(historyAtom), previousTrack];
+ store.set(
+ historyAtom,
+ h.length > MAX_HISTORY_TRACKS
+ ? h.slice(h.length - MAX_HISTORY_TRACKS)
+ : h,
+ );
+ }
+ (v as any)._playingFrom = store.get(playbackSourceAtom);
+ (v as any)._contextFrom = store.get(contextSourceAtom);
+ store.set(currentTrackAtom, v);
+ try {
+ await startVideoSession(store, {
+ id: v.id,
+ title: v.title,
+ imageId: v.imageId,
+ artist: v.artist?.name ?? v.artists?.[0]?.name,
+ duration: v.duration,
+ });
+ return { ok: true };
+ } catch (e) {
+ console.error("Failed to play video:", e);
+ return { ok: false, reason: "transient" };
+ }
+ }
+ // Mutual exclusion: starting AUDIO stops any background video
+ // (mirrors useVideoPlayback.closeVideo's atom clears).
+ if (store.get(currentVideoAtom)) {
+ store.set(videoPlayingAtom, false);
+ store.set(videoStreamAtom, null);
+ store.set(currentVideoAtom, null);
+ store.set(videoExpandedAtom, false);
+ }
store.set(userPausedAtom, false);
const generation = ++playGenerationRef.current;
const stamped = ensureQid(normalizeTrack(track));
@@ -278,6 +326,11 @@ export function usePlaybackActions() {
);
const pauseTrack = useCallback(async () => {
+ // Video is current → pause the shared , never the audio pipeline.
+ if (store.get(currentVideoAtom)) {
+ videoElementRef.current?.pause();
+ return;
+ }
store.set(userPausedAtom, true);
try {
await invoke("pause_track");
@@ -289,6 +342,29 @@ export function usePlaybackActions() {
const resumeTrack = useCallback(async () => {
store.set(userPausedAtom, false);
+ // Video is current → play the shared .
+ if (store.get(currentVideoAtom)) {
+ videoElementRef.current?.play().catch(() => {});
+ return;
+ }
+ // Restored video (post-relaunch / after closeVideo): the current track is a
+ // video but there is no live session — start one instead of hitting the audio
+ // backend with a video id (which 404s).
+ const restored = store.get(currentTrackAtom);
+ if (restored?.itemType === "video") {
+ try {
+ await startVideoSession(store, {
+ id: restored.id,
+ title: restored.title,
+ imageId: restored.imageId,
+ artist: restored.artist?.name ?? restored.artists?.[0]?.name,
+ duration: restored.duration,
+ });
+ } catch (e) {
+ console.error("Failed to resume video:", e);
+ }
+ return;
+ }
try {
const track = store.get(currentTrackAtom);
if (!track) return;
@@ -336,6 +412,21 @@ export function usePlaybackActions() {
}
}, [store, showToast]);
+ const togglePlayPause = useCallback(async () => {
+ // For a LIVE video, decide from the element itself — videoPlayingAtom trails the
+ // play/pause DOM events, so a rapid double-press off the atom could land
+ // on the wrong state. Fall back to the atom only when the element isn't mounted
+ // yet, then to the audio isPlaying state.
+ const el = store.get(currentVideoAtom) ? videoElementRef.current : null;
+ const playing = el
+ ? !el.paused
+ : store.get(currentVideoAtom)
+ ? store.get(videoPlayingAtom)
+ : store.get(isPlayingAtom);
+ if (playing) await pauseTrack();
+ else await resumeTrack();
+ }, [store, pauseTrack, resumeTrack]);
+
/** Peek the next track for gapless registration. Returns null unless the next track
* is the AVAILABLE head of manual/context queue AND its _source matches the current
* playback source (so gapless never changes the "Playing from" context wrongly).
@@ -393,8 +484,15 @@ export function usePlaybackActions() {
const setVolume = useCallback(
async (level: number) => {
- if (store.get(bitPerfectAtom)) return;
- store.set(volumeAtom, level);
+ const bitPerfect = store.get(bitPerfectAtom);
+ const isVideo = !!store.get(currentVideoAtom);
+ // Bit-perfect audio stays locked at unity; video audio is lossy, so the
+ // slider must still work for it.
+ if (bitPerfect && !isVideo) return;
+ store.set(volumeAtom, level); // the element reads this atom
+ // Never push a non-unity level to the GStreamer pipeline while bit-perfect
+ // is on — that would attenuate (and un-bit-perfect) audio playback.
+ if (bitPerfect) return;
try {
await invoke("set_volume", { level });
} catch (error) {
@@ -654,6 +752,28 @@ export function usePlaybackActions() {
if (repeatMode === 2 && !options?.explicit) {
const current = store.get(currentTrackAtom);
if (current) {
+ if (current.itemType === "video") {
+ // In-place loop: the element already holds the stream, so just
+ // rewind — the autoplay guard would otherwise block a re-attach.
+ const v = videoElementRef.current;
+ if (v) {
+ v.currentTime = 0;
+ v.play().catch(() => {});
+ } else {
+ try {
+ await startVideoSession(store, {
+ id: current.id,
+ title: current.title,
+ imageId: current.imageId,
+ artist: current.artist?.name ?? current.artists?.[0]?.name,
+ duration: current.duration,
+ });
+ } catch (e) {
+ console.error("Failed to repeat video:", e);
+ }
+ }
+ return;
+ }
// In-place replay: currentTrackAtom is unchanged, so no track-change
// reset fires. Gate interpolation so the bar doesn't keep climbing
// past the old track's end during the reload.
@@ -952,6 +1072,15 @@ export function usePlaybackActions() {
// Stop old pipeline to prevent stale track-finished events
await invoke("stop_track").catch(() => {});
+ // Mutual exclusion: clear any background video. If the resolved previous
+ // item is itself a video, startVideoSession (below) re-establishes these.
+ if (store.get(currentVideoAtom)) {
+ store.set(videoPlayingAtom, false);
+ store.set(videoStreamAtom, null);
+ store.set(currentVideoAtom, null);
+ store.set(videoExpandedAtom, false);
+ }
+
const history = store.get(historyAtom);
if (history.length > 0) {
const newHistory = [...history];
@@ -1002,6 +1131,22 @@ export function usePlaybackActions() {
markPlaybackLoading(true);
store.set(currentTrackAtom, prevTrack);
+ if (prevTrack.itemType === "video") {
+ markPlaybackLoading(false);
+ try {
+ await startVideoSession(store, {
+ id: prevTrack.id,
+ title: prevTrack.title,
+ imageId: prevTrack.imageId,
+ artist: prevTrack.artist?.name ?? prevTrack.artists?.[0]?.name,
+ duration: prevTrack.duration,
+ });
+ } catch (e) {
+ console.error("Failed to play previous video:", e);
+ }
+ return;
+ }
+
try {
preloadImage(getTidalImageUrl(prevTrack.album?.cover, 640));
preloadImage(getTidalImageUrl(prevTrack.album?.cover, 1280));
@@ -1109,6 +1254,23 @@ export function usePlaybackActions() {
markPlaybackLoading(true);
store.set(currentTrackAtom, prevTrack);
+ if (prevTrack.itemType === "video") {
+ markPlaybackLoading(false);
+ try {
+ await startVideoSession(store, {
+ id: prevTrack.id,
+ title: prevTrack.title,
+ imageId: prevTrack.imageId,
+ artist:
+ prevTrack.artist?.name ?? prevTrack.artists?.[0]?.name,
+ duration: prevTrack.duration,
+ });
+ } catch (e) {
+ console.error("Failed to play previous video:", e);
+ }
+ return;
+ }
+
try {
const info = await invokePlayWithRetry(
prevTrack.id,
@@ -1364,6 +1526,7 @@ export function usePlaybackActions() {
playTrack,
pauseTrack,
resumeTrack,
+ togglePlayPause,
setVolume,
setVolumeNormalization,
setBitPerfect,
diff --git a/src/hooks/useVideoPlayback.ts b/src/hooks/useVideoPlayback.ts
new file mode 100644
index 00000000..5d488ea6
--- /dev/null
+++ b/src/hooks/useVideoPlayback.ts
@@ -0,0 +1,99 @@
+import { useCallback } from "react";
+import { useStore } from "jotai";
+import { invoke } from "@tauri-apps/api/core";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import {
+ currentVideoAtom,
+ videoPlayingAtom,
+ videoStreamAtom,
+ videoFullscreenAtom,
+ videoExpandedAtom,
+} from "../atoms/video";
+import { startVideoSession } from "../lib/videoSession";
+import type { VideoStreamInfo } from "../types";
+
+export type VideoQuality = "HIGH" | "MEDIUM" | "LOW";
+
+interface PlayVideoInput {
+ id: number;
+ title?: string;
+ imageId?: string;
+ artist?: string;
+ duration?: number;
+}
+
+export function useVideoPlayback() {
+ const store = useStore();
+
+ /** Open the video takeover: pause audio, resolve metadata + HLS stream, set state. */
+ const playVideo = useCallback(
+ async (input: PlayVideoInput, quality: VideoQuality = "HIGH") => {
+ try {
+ await startVideoSession(store, input, quality);
+ } catch (err) {
+ console.error("Failed to load video:", err);
+ // startVideoSession has already dismissed the overlay on failure.
+ throw err;
+ }
+ },
+ [store],
+ );
+
+ /** Re-resolve the stream at a new quality (caller restores playback position). */
+ const setVideoQuality = useCallback(
+ async (quality: VideoQuality): Promise => {
+ const current = store.get(currentVideoAtom);
+ if (!current) return null;
+ try {
+ const stream = await invoke("get_video_stream_info", {
+ videoId: current.id,
+ videoQuality: quality,
+ });
+ store.set(videoStreamAtom, stream);
+ return stream;
+ } catch (err) {
+ console.error("Failed to switch video quality:", err);
+ return null;
+ }
+ },
+ [store],
+ );
+
+ /** Minimize the overlay to the player bar; the video keeps playing. */
+ const minimizeVideo = useCallback(() => {
+ store.set(videoExpandedAtom, false);
+ // Leaving OS fullscreen up with no overlay would strand the window; drop it.
+ if (store.get(videoFullscreenAtom)) {
+ store.set(videoFullscreenAtom, false);
+ getCurrentWindow()
+ .setFullscreen(false)
+ .catch(() => {});
+ }
+ }, [store]);
+
+ /** Re-open the full overlay (the video never stopped). */
+ const expandVideo = useCallback(() => {
+ store.set(videoExpandedAtom, true);
+ }, [store]);
+
+ const closeVideo = useCallback(() => {
+ store.set(videoPlayingAtom, false);
+ store.set(videoStreamAtom, null);
+ store.set(currentVideoAtom, null);
+ store.set(videoExpandedAtom, false);
+ if (store.get(videoFullscreenAtom)) {
+ store.set(videoFullscreenAtom, false);
+ getCurrentWindow()
+ .setFullscreen(false)
+ .catch(() => {});
+ }
+ }, [store]);
+
+ return {
+ playVideo,
+ setVideoQuality,
+ minimizeVideo,
+ expandVideo,
+ closeVideo,
+ };
+}
diff --git a/src/lib/gaplessPredict.ts b/src/lib/gaplessPredict.ts
index 297a4869..bf3af3be 100644
--- a/src/lib/gaplessPredict.ts
+++ b/src/lib/gaplessPredict.ts
@@ -23,6 +23,7 @@ export function pickGaplessNext(args: {
if (repeat === 2) return null; // repeat-one → EOS→playNext path
const head = manualHead ?? contextHead ?? null;
if (!head || isTrackUnavailable(head)) return null; // unavailable head → playNext drains it
+ if (head.itemType === "video") return null; // video → EOS→playNext path, no gapless
const headSource = (head as QueuedTrack)._source;
if (headSource && headSource.id !== currentSourceId) return null; // source switch → not gapless in v1
return head;
diff --git a/src/lib/trackAvailability.ts b/src/lib/trackAvailability.ts
index f0a3e036..e743abbe 100644
--- a/src/lib/trackAvailability.ts
+++ b/src/lib/trackAvailability.ts
@@ -8,6 +8,7 @@ import { getApiStatus } from "./errorUtils";
*/
export function isTrackUnavailable(track: Track | null | undefined): boolean {
if (!track) return false;
+ if (track.itemType === "video") return false; // videos aren't audio-stream-gated
if (track.streamReady === false) return true;
if (track.allowStreaming === false) return true;
if (track.streamStartDate) {
diff --git a/src/lib/videoElement.ts b/src/lib/videoElement.ts
new file mode 100644
index 00000000..a9baf54b
--- /dev/null
+++ b/src/lib/videoElement.ts
@@ -0,0 +1,5 @@
+/** Shared handle to the live element owned by VideoPlayer, so the
+ * PlayerBar can drive playback/seek while the overlay is minimized. */
+export const videoElementRef: { current: HTMLVideoElement | null } = {
+ current: null,
+};
diff --git a/src/lib/videoSession.ts b/src/lib/videoSession.ts
new file mode 100644
index 00000000..bed1e498
--- /dev/null
+++ b/src/lib/videoSession.ts
@@ -0,0 +1,63 @@
+import type { createStore } from "jotai";
+import { invoke } from "@tauri-apps/api/core";
+import { isPlayingAtom } from "../atoms/playback";
+import {
+ currentVideoAtom,
+ videoStreamAtom,
+ videoPlayingAtom,
+ videoExpandedAtom,
+} from "../atoms/video";
+import type { TidalVideo, VideoStreamInfo } from "../types";
+
+type Store = ReturnType;
+
+export interface VideoSessionInput {
+ id: number;
+ title?: string;
+ imageId?: string;
+ artist?: string;
+ duration?: number;
+}
+
+/** Stop audio and load `input` into the player (expanded). Shared by the
+ * standalone-card path (useVideoPlayback) and the queue path (playTrack). */
+export async function startVideoSession(
+ store: Store,
+ input: VideoSessionInput,
+ quality: "HIGH" | "MEDIUM" | "LOW" = "HIGH",
+): Promise {
+ store.set(isPlayingAtom, false);
+
+ // Seed a placeholder so the overlay/bar update instantly.
+ store.set(currentVideoAtom, {
+ id: input.id,
+ title: input.title ?? "",
+ duration: input.duration ?? 0,
+ imageId: input.imageId,
+ artist: input.artist ? { id: 0, name: input.artist } : undefined,
+ });
+ store.set(videoStreamAtom, null);
+ store.set(videoPlayingAtom, false);
+ store.set(videoExpandedAtom, true);
+
+ // Tear the audio pipeline down before the video's own audio begins.
+ await invoke("stop_track").catch(() => {});
+
+ try {
+ const [meta, stream] = await Promise.all([
+ invoke("get_video_metadata", { videoId: input.id }),
+ invoke("get_video_stream_info", {
+ videoId: input.id,
+ videoQuality: quality,
+ }),
+ ]);
+ store.set(currentVideoAtom, meta);
+ store.set(videoStreamAtom, stream);
+ } catch (err) {
+ // Resolution failed — dismiss the overlay rather than spin forever.
+ store.set(currentVideoAtom, null);
+ store.set(videoStreamAtom, null);
+ store.set(videoExpandedAtom, false);
+ throw err;
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index 9ec0f37a..49127459 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -23,6 +23,15 @@ export function getTidalImageUrl(
return `https://resources.tidal.com/images/${path}/${validSize}x${validSize}.jpg`;
}
+// MULTIPLE_TOP_PROMOTIONS ("Featured") images are landscape promo banners served
+// only at 550x400 (11:8) — the square sizes 403. Use this for those items.
+export function getTidalPromoImageUrl(imageId: string | undefined): string {
+ if (!imageId) return "";
+ if (imageId.startsWith("http")) return imageId;
+ const path = imageId.replace(/-/g, "/");
+ return `https://resources.tidal.com/images/${path}/550x400.jpg`;
+}
+
/**
* Artist pictures use a different size set than album covers — valid squares are
* 160 / 320 / 480 / 750 (there is no 640 or 1280, which 403). Snap accordingly.
@@ -108,6 +117,10 @@ export interface Track {
audioModes?: string[]; // "STEREO" | "DOLBY_ATMOS"
mediaMetadata?: MediaMetadata;
mixes?: { TRACK_MIX?: string; MASTER_TRACK_MIX?: string };
+ /** From `/playlists/{id}/items`: "track" | "video". */
+ itemType?: string;
+ /** Video thumbnail UUID (videos carry `imageId` instead of `album.cover`). */
+ imageId?: string;
_qid?: string;
}
@@ -192,6 +205,7 @@ export type SearchTab =
| "all"
| "tophits"
| "tracks"
+ | "videos"
| "playlists"
| "albums"
| "artists";
@@ -212,6 +226,7 @@ export type AppView =
description?: string;
creatorName?: string;
numberOfTracks?: number;
+ numberOfVideos?: number;
isUserPlaylist?: boolean;
};
}
@@ -266,6 +281,7 @@ export interface SearchResults {
albums: AlbumDetail[];
tracks: Track[];
playlists: Playlist[];
+ videos: TidalVideo[];
topHitType?: string;
topHits?: DirectHitItem[];
}
@@ -303,6 +319,7 @@ export interface Playlist {
description?: string;
image?: string;
numberOfTracks?: number;
+ numberOfVideos?: number;
creator?: { id: number; name?: string };
/** "USER" | "EDITORIAL" | "ARTIST" */
playlistType?: string;
@@ -380,6 +397,27 @@ export interface StreamInfo {
trackPeakAmplitude?: number;
}
+export interface VideoStreamInfo {
+ url: string;
+ videoQuality: string;
+ manifestMimeType: string;
+ videoId: number;
+}
+
+export interface TidalVideo {
+ id: number;
+ title: string;
+ duration: number;
+ imageId?: string;
+ vibrantColor?: string;
+ quality?: string;
+ type?: string;
+ explicit?: boolean;
+ adsPrePaywallOnly?: boolean;
+ artist?: { id: number; name: string; picture?: string };
+ artists?: { id: number; name: string; picture?: string }[];
+}
+
// ==================== v2 Home Feed MIX types ====================
/** @public */
@@ -575,7 +613,15 @@ export type MediaItemType =
image?: string;
subtitle?: string;
}
- | { type: "artist"; id: number; name: string; picture?: string };
+ | { type: "artist"; id: number; name: string; picture?: string }
+ | {
+ type: "video";
+ id: number;
+ title: string;
+ imageId?: string;
+ artist?: string;
+ duration?: number;
+ };
export interface FavoriteMix {
id: string;
diff --git a/src/utils/itemHelpers.ts b/src/utils/itemHelpers.ts
index 8d8d526f..64d44d92 100644
--- a/src/utils/itemHelpers.ts
+++ b/src/utils/itemHelpers.ts
@@ -1,4 +1,28 @@
-import { getTidalImageUrl, type MediaItemType, type StreamInfo } from "../types";
+import {
+ getTidalImageUrl,
+ type MediaItemType,
+ type StreamInfo,
+ type Track,
+ type TidalVideo,
+} from "../types";
+
+/**
+ * A favorite/search video as a queue-ready Track (itemType "video"), so it flows
+ * through playFromSource/playAllFromSource exactly like an audio track — giving
+ * video grids a full queue with working prev/next.
+ */
+export function videoToTrack(v: TidalVideo): Track {
+ return {
+ id: v.id,
+ title: v.title,
+ itemType: "video",
+ imageId: v.imageId,
+ duration: v.duration,
+ explicit: v.explicit,
+ artist: v.artist ?? v.artists?.[0],
+ artists: v.artists,
+ } as Track;
+}
/**
* Shared helpers for extracting data from raw Tidal API JSON items.
@@ -97,6 +121,16 @@ export function getItemTitle(item: any): string {
return "";
}
+export function playlistCountLabel(numberOfTracks?: number, numberOfVideos?: number): string {
+ const t = numberOfTracks ?? 0;
+ const v = numberOfVideos ?? 0;
+ const parts: string[] = [];
+ if (v > 0) parts.push(`${v} Video${v === 1 ? "" : "s"}`);
+ if (t > 0) parts.push(`${t} Track${t === 1 ? "" : "s"}`);
+ if (parts.length === 0) parts.push("0 Tracks");
+ return parts.join(" · ");
+}
+
export function getItemSubtitle(item: any, userId?: number): string {
if (isMagazineItem(item)) {
return item.data?.shortSubHeader ?? "";
@@ -119,8 +153,8 @@ export function getItemSubtitle(item: any, userId?: number): string {
? "By TIDAL"
: undefined;
const trackCount =
- item.numberOfTracks != null
- ? `${item.numberOfTracks} track${item.numberOfTracks !== 1 ? "s" : ""}`
+ item.numberOfTracks != null || item.numberOfVideos != null
+ ? playlistCountLabel(item.numberOfTracks, item.numberOfVideos)
: undefined;
const parts = [creatorLabel, trackCount].filter(Boolean);
if (parts.length > 0) return parts.join(" · ");
@@ -189,6 +223,12 @@ export function isDeepLinkItem(item: any): boolean {
return item?.type === "DEEP_LINK" || item?._itemType === "DEEP_LINK";
}
+export function isVideoItem(item: any, sectionType?: string): boolean {
+ const t = getItemType(item);
+ // v2 typed lists use "VIDEO"; the v1 catalog object uses "Music Video".
+ return sectionType === "VIDEO_LIST" || t === "VIDEO" || t === "Music Video";
+}
+
/** Detect the special "My Tracks" shortcut from Tidal's v2 feed. */
export function isMyTracksItem(item: any): boolean {
if (
@@ -227,6 +267,19 @@ export function buildMediaItem(
}
return null;
}
+ if (isVideoItem(item, sectionType)) {
+ if (item.id != null) {
+ return {
+ type: "video",
+ id: Number(item.id),
+ title: item.title || getItemTitle(item),
+ imageId: item.imageId,
+ artist: item.artist?.name || item.artists?.[0]?.name,
+ duration: item.duration,
+ };
+ }
+ return null;
+ }
if (isMixItem(item, sectionType)) {
const mixId = item.mixId || item.id?.toString();
if (mixId) {
@@ -268,6 +321,11 @@ export function buildMediaItem(
return null;
}
+/** Cover image UUID for a track — videos carry `imageId`, not `album.cover`. */
+export function trackCoverId(track: Track): string | undefined {
+ return track.itemType === "video" ? track.imageId : track.album?.cover;
+}
+
/** Return comma-separated artist names for a track (plain text, no links). */
export function getTrackArtistDisplay(track: {
artist?: { name?: string };
@@ -337,6 +395,11 @@ export function getTrackShareUrl(trackId: number): string {
return `${TIDAL_SHARE_BASE}/track/${trackId}/u`;
}
+/** Build a Tidal share URL for a music video. */
+export function getVideoShareUrl(videoId: number): string {
+ return `${TIDAL_SHARE_BASE}/video/${videoId}`;
+}
+
/** Build a Tidal share URL for a media item (album/playlist/mix/artist). */
export function getShareUrl(item: MediaItemType): string {
switch (item.type) {
@@ -348,6 +411,8 @@ export function getShareUrl(item: MediaItemType): string {
return `${TIDAL_SHARE_BASE}/mix/${item.mixId}`;
case "artist":
return `${TIDAL_SHARE_BASE}/artist/${item.id}`;
+ case "video":
+ return `${TIDAL_SHARE_BASE}/video/${item.id}`;
}
}