feat(medias): support reactions (#3146)

This commit is contained in:
pochoclin
2025-09-25 15:35:47 -04:00
committed by GitHub
parent 3a3e87c7be
commit 4f74b5d998
15 changed files with 276 additions and 28 deletions

View File

@@ -14,7 +14,7 @@ import { Table, TableBody, TableCell, TableRow } from "@popcorntime/ui/component
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@popcorntime/ui/components/tabs";
import { timeDisplay } from "@popcorntime/ui/lib/time";
import { cn } from "@popcorntime/ui/lib/utils";
import { Calendar, Clock, ExternalLink, Star, X } from "lucide-react";
import { Calendar, Clock, ExternalLink, Star, ThumbsDown, ThumbsUp, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -24,7 +24,7 @@ import { useCountry } from "@/hooks/useCountry";
import { useTauri } from "@/hooks/useTauri";
import { NotFoundRoute } from "@/routes/not-found";
import { useGlobalStore } from "@/stores/global";
import type { Media } from "@/tauri/types";
import type { Media, UserReactionType } from "@/tauri/types";
function MediaContentSkeleton() {
return (
@@ -45,6 +45,22 @@ function MediaContent() {
const { t } = useTranslation();
const officialLocales = useMemo(() => [...getLocalesForCountry(country)], [country]);
const setMediaReaction = useCallback(
(reaction: UserReactionType | null) => {
if (!media?.id) return;
setMedia(prev => (prev ? { ...prev, reaction } : prev));
try {
void api.setMediaReaction({
mediaId: media.id,
reaction,
});
} catch (error) {
console.error("Failed to set media reaction:", error);
}
},
[media?.id, api.setMediaReaction]
);
const fetch = useCallback(
async (slug: string) => {
setIsLoading(true);
@@ -221,18 +237,18 @@ function MediaContent() {
</div>
<div className="absolute right-0 bottom-0 left-0 px-8">
<div className="flex h-full items-stretch gap-6">
<div className="flex h-full items-stretch gap-6 space-y-2">
<MediaPosterAsPicture
loading="lazy"
title={media.title}
posterId={posterId}
placeholder={placeholderImg}
className="w-32 rounded-md"
className="w-44 rounded-md"
/>
<div className="flex flex-1 flex-col pb-4">
<h1 className="mb-3 line-clamp-1 text-4xl leading-tight font-bold">{media.title}</h1>
<div className="flex flex-1 flex-col pb-4 gap-2">
<h1 className="line-clamp-1 text-4xl leading-tight font-bold">{media.title}</h1>
<div className="mb-4 flex flex-wrap items-center gap-6">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 fill-current text-yellow-400" />
{media.ranking?.score && (
@@ -256,7 +272,7 @@ function MediaContent() {
)}
</div>
<div className="mb-4 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2">
<Badge variant="default" className="font-medium capitalize backdrop-blur-sm">
{media.__typename === "Movie" ? t("media.movie") : t("media.tv-show")}
</Badge>
@@ -272,12 +288,29 @@ function MediaContent() {
))}
</div>
<div className="flex gap-2">
<Button
variant={media.reaction === "LIKE" ? "accent" : "ghost"}
onClick={() => setMediaReaction(media.reaction === "LIKE" ? null : "LIKE")}
size="iconXl"
>
<ThumbsUp />
</Button>
<Button
variant={media.reaction === "DISLIKE" ? "accent" : "ghost"}
onClick={() => setMediaReaction(media.reaction === "DISLIKE" ? null : "DISLIKE")}
size="iconXl"
>
<ThumbsDown />
</Button>
</div>
{bestProvider && (
<div className="mt-auto flex flex-col space-y-2 space-x-0 sm:flex-row sm:space-y-0 sm:space-x-2 rtl:space-x-reverse">
<Link
className={cn(
buttonVariants({ variant: "default", size: "xl" }),
"group bg-primary/40 dark:bg-primary/20 hover:bg-primary/90 hover:text-secondary flex w-full items-center justify-center gap-x-2 px-4 font-extrabold sm:w-auto sm:max-w-sm"
"group text-foreground/80 bg-primary/40 dark:bg-primary/20 hover:bg-primary/90 hover:text-secondary flex w-full items-center justify-center gap-x-2 px-4 font-extrabold sm:w-auto sm:max-w-sm"
)}
to={`https://go.popcorntime.app/${bestProvider.urlHash}?country=${country?.toUpperCase()}`}
target="_blank"

View File

@@ -55,6 +55,14 @@ async setFavoritesProvider(params: SetFavoriteProviderInput) : Promise<Result<Se
else return { status: "error", error: e as any };
}
},
async setMediaReaction(params: SetReactionInput) : Promise<Result<SetReactionMutation | null, { message: string; code: Code }>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_media_reaction", { params }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async showMainWindow() : Promise<void> {
await TAURI_INVOKE("show_main_window");
},
@@ -136,7 +144,7 @@ export type MediaSearchConnection = { nodes: MediaSearch[]; pageInfo: PageInfo }
export type MediaSimilar = { title: string; overview: string | null; kind: MediaKind; slug: string; poster: string | null; year: number | null }
export type MediaVideo = { source: VideoSource; video_id: string }
export type MeliSearchProvider = { providerId: string; priceTypes: WatchPriceType[] }
export type Movie = { runtime: string; id: number; __typename: string; title: string; slug: string; overview: string | null; tagline: string | null; languages: Language[]; poster: string | null; backdrop: string | null; released: string | null; year: number | null; country: Country | null; tags: Tag[]; trailers: string[]; genres: Genre[]; classification: string | null; countries: Country[]; kind: MediaKind; videos: MediaVideo[]; ratings: ExternalRating[]; ranking: Ranking | null; pochoclinReview: PochoclinReview | null; similars: MediaSimilar[]; similarsFree: MediaSimilar[]; charts: MediaCharts[]; availabilities: Availability[]; talents: People[] }
export type Movie = { runtime: string; id: number; __typename: string; title: string; slug: string; overview: string | null; tagline: string | null; languages: Language[]; poster: string | null; backdrop: string | null; released: string | null; year: number | null; country: Country | null; tags: Tag[]; trailers: string[]; genres: Genre[]; classification: string | null; countries: Country[]; kind: MediaKind; videos: MediaVideo[]; ratings: ExternalRating[]; ranking: Ranking | null; pochoclinReview: PochoclinReview | null; similars: MediaSimilar[]; similarsFree: MediaSimilar[]; charts: MediaCharts[]; availabilities: Availability[]; talents: People[]; reaction: UserReactionType | null }
export type PageInfo = { endCursor: string | null; hasNextPage: boolean }
export type People = { id: number; rank: number; name: string; role: string | null; roleType: RoleType }
export type PochoclinReview = { review: string; excerpt: string }
@@ -154,12 +162,15 @@ export type SessionServerReady = { authorization_url: string }
export type SessionUpdate = null
export type SetFavoriteProviderInput = { country: Country; providerKey: string; favorite: boolean }
export type SetFavoriteProviderMutation = { setFavoriteProvider: boolean }
export type SetReactionInput = { mediaId: number; reaction: UserReactionType | null }
export type SetReactionMutation = { setReaction: boolean }
export type SortKey = "ID" | "RELEASED_AT" | "CREATED_AT" | "UPDATED_AT" | "POSITION"
export type Tag = string
export type Tvshow = { inProduction: boolean; id: number; __typename: string; title: string; slug: string; overview: string | null; tagline: string | null; languages: Language[]; poster: string | null; backdrop: string | null; released: string | null; year: number | null; country: Country | null; tags: Tag[]; trailers: string[]; genres: Genre[]; classification: string | null; countries: Country[]; kind: MediaKind; videos: MediaVideo[]; ratings: ExternalRating[]; ranking: Ranking | null; pochoclinReview: PochoclinReview | null; similars: MediaSimilar[]; similarsFree: MediaSimilar[]; charts: MediaCharts[]; availabilities: Availability[]; talents: People[] }
export type Tvshow = { inProduction: boolean; id: number; __typename: string; title: string; slug: string; overview: string | null; tagline: string | null; languages: Language[]; poster: string | null; backdrop: string | null; released: string | null; year: number | null; country: Country | null; tags: Tag[]; trailers: string[]; genres: Genre[]; classification: string | null; countries: Country[]; kind: MediaKind; videos: MediaVideo[]; ratings: ExternalRating[]; ranking: Ranking | null; pochoclinReview: PochoclinReview | null; similars: MediaSimilar[]; similarsFree: MediaSimilar[]; charts: MediaCharts[]; availabilities: Availability[]; talents: People[]; reaction: UserReactionType | null }
export type UpdatePreferencesInput = { country: Country; language: Language }
export type UpdatePreferencesMutation = { updatePreferences: UserPreferences | null }
export type UserPreferences = { language: Language; country: Country }
export type UserReactionType = "LIKE" | "DISLIKE"
export type VideoSource = "RUMBLE" | "YOUTUBE"
export type WatchPriceType = "RENT" | "BUY" | "FLATRATE" | "FREE" | "CINEMA"

View File

@@ -247,6 +247,7 @@ type Movie implements Media {
updatedAt: String!
runtime: String!
videos: [MediaVideo!]!
reaction: UserReactionType
countries: [Country!]!
pochoclinReview(language: Language): PochoclinReview
similars(country: Country!, language: Language, arguments: SearchArguments): [MediaSearch!]!
@@ -349,6 +350,7 @@ type QueryRoot {
count(country: Country!): Int!
providers(country: Country!, query: String): [Provider!]!
preferences: UserPreferences
reactions(after: String = null, before: String = null, first: Int, last: Int): UserReactionConnection!
}
type Ranking {
@@ -423,6 +425,7 @@ type TVShow implements Media {
inProduction: Boolean!
lastAirDate: String
videos: [MediaVideo!]!
reaction: UserReactionType
countries: [Country!]!
pochoclinReview(language: Language): PochoclinReview
similars(country: Country!, language: Language, arguments: SearchArguments): [MediaSearch!]!
@@ -451,6 +454,40 @@ type UserPreferences {
createdAt: DateTime!
}
type UserReaction {
mediaId: Int!
reaction: UserReactionType!
}
type UserReactionConnection {
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
"""
A list of edges.
"""
edges: [UserReactionEdge!]!
"""
A list of nodes.
"""
nodes: [UserReaction!]!
}
"""
An edge in a connection.
"""
type UserReactionEdge {
"""
The item at the end of the edge
"""
node: UserReaction!
"""
A cursor for use in pagination
"""
cursor: String!
}
enum UserReactionType {
LIKE
DISLIKE

View File

@@ -8,6 +8,7 @@ pub mod consts;
pub mod media;
pub mod preferences;
pub mod providers;
pub mod reactions;
pub mod search;
impl_scalar!(Date, schema::Date);
@@ -64,5 +65,12 @@ impl schema::variable::Variable for Language {
cynic::variables::VariableType::Named(<schema::Language as cynic::schema::NamedType>::NAME);
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageInfo {
pub end_cursor: Option<String>,
pub has_next_page: bool,
}
#[cynic::schema("popcorntime")]
mod schema {}

View File

@@ -1,4 +1,4 @@
use crate::{Country, Date, Language, schema};
use crate::{Country, Date, Language, reactions::UserReactionType, schema};
use serde::{Deserialize, Serialize};
#[derive(cynic::QueryVariables, Debug, specta::Type, Deserialize)]
@@ -35,6 +35,7 @@ pub enum Media {
#[serde(rename_all = "camelCase")]
pub struct Tvshow {
pub in_production: bool,
// same as movie -- cynic doesn't support inheritance
pub id: i32,
#[serde(rename = "__typename")]
pub __typename: String,
@@ -70,6 +71,7 @@ pub struct Tvshow {
#[arguments(country: $country)]
pub availabilities: Vec<Availability>,
pub talents: Vec<People>,
pub reaction: Option<UserReactionType>,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
@@ -77,6 +79,8 @@ pub struct Tvshow {
#[serde(rename_all = "camelCase")]
pub struct Movie {
pub runtime: String,
// same as tvshow -- cynic doesn't support inheritance
// the cynic(spread) require same type
pub id: i32,
#[serde(rename = "__typename")]
pub __typename: String,
@@ -112,6 +116,7 @@ pub struct Movie {
#[arguments(country: $country)]
pub availabilities: Vec<Availability>,
pub talents: Vec<People>,
pub reaction: Option<UserReactionType>,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
@@ -275,11 +280,10 @@ pub struct Tag(pub String);
#[cfg(test)]
mod tests {
use super::*;
use cynic::QueryBuilder;
#[test]
fn media_query_gql_output() {
use cynic::QueryBuilder;
let operation = MediaOutput::build(MediaInput {
country: Country("US".to_string()),
language: Some(Language("en".to_string())),

View File

@@ -0,0 +1,105 @@
use crate::{PageInfo, schema};
use serde::{Deserialize, Serialize};
#[derive(cynic::Enum, Clone, Copy, Debug, specta::Type)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[cynic(graphql_type = "UserReactionType")]
pub enum UserReactionType {
Like,
Dislike,
}
#[derive(cynic::QueryVariables, Debug, specta::Type, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetReactionInput {
pub media_id: i32,
pub reaction: Option<UserReactionType>,
}
#[derive(cynic::QueryVariables, Debug, specta::Type, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserReactionsInput<'a> {
#[specta(optional)]
pub after: Option<&'a str>,
#[specta(optional)]
pub before: Option<&'a str>,
#[specta(optional)]
pub first: Option<i32>,
#[specta(optional)]
pub last: Option<i32>,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[cynic(graphql_type = "QueryRoot", variables = "UserReactionsInput")]
#[serde(rename_all = "camelCase")]
pub struct UserReactions {
#[arguments(after: $after, before: $before, first: $first, last: $last)]
pub reactions: UserReactionConnection,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserReactionConnection {
pub nodes: Vec<UserReaction>,
pub page_info: PageInfo,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserReaction {
pub media_id: i32,
pub reaction: UserReactionType,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[cynic(graphql_type = "MutationRoot", variables = "SetReactionInput")]
#[serde(rename_all = "camelCase")]
pub struct SetReactionMutation {
#[arguments(mediaId: $media_id, reaction: $reaction)]
pub set_reaction: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use cynic::{MutationBuilder, QueryBuilder};
#[test]
fn reactions_query_gql_output() {
let operation = UserReactions::build(UserReactionsInput {
first: Some(10),
after: None,
before: None,
last: None,
});
insta::assert_snapshot!(operation.query);
}
#[test]
fn set_reaction_like_mutation_gql_output() {
let operation = SetReactionMutation::build(SetReactionInput {
media_id: 123,
reaction: Some(UserReactionType::Like),
});
insta::assert_snapshot!(operation.query);
}
#[test]
fn remove_favorite_mutation_gql_output() {
let operation = SetReactionMutation::build(SetReactionInput {
media_id: 123,
reaction: Some(UserReactionType::Dislike),
});
insta::assert_snapshot!(operation.query);
}
#[test]
fn delete_favorite_mutation_gql_output() {
let operation = SetReactionMutation::build(SetReactionInput {
media_id: 123,
reaction: None,
});
insta::assert_snapshot!(operation.query);
}
}

View File

@@ -1,7 +1,6 @@
use crate::{
Country, Date, DateTime, Language,
media::Genre,
media::{MediaKind, WatchPriceType},
Country, Date, DateTime, Language, PageInfo,
media::{Genre, MediaKind, WatchPriceType},
schema,
};
use serde::{Deserialize, Serialize};
@@ -70,13 +69,6 @@ pub struct MediaSearchConnection {
pub page_info: PageInfo,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageInfo {
pub end_cursor: Option<String>,
pub has_next_page: bool,
}
#[derive(cynic::QueryFragment, Debug, specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaSearch {

View File

@@ -88,6 +88,7 @@ query MediaOutput($country: Country!, $slug: String!, $language: Language) {
role
roleType
}
reaction
}
... on TVShow {
inProduction
@@ -172,6 +173,7 @@ query MediaOutput($country: Country!, $slug: String!, $language: Language) {
role
roleType
}
reaction
}
}
}

View File

@@ -0,0 +1,7 @@
---
source: crates/popcorntime-graphql-client/src/reactions.rs
expression: operation.query
---
mutation SetReactionMutation($mediaId: Int!, $reaction: UserReactionType) {
setReaction(mediaId: $mediaId, reaction: $reaction)
}

View File

@@ -0,0 +1,16 @@
---
source: crates/popcorntime-graphql-client/src/reactions.rs
expression: operation.query
---
query UserReactions($after: String, $before: String, $first: Int, $last: Int) {
reactions(after: $after, before: $before, first: $first, last: $last) {
nodes {
mediaId
reaction
}
pageInfo {
endCursor
hasNextPage
}
}
}

View File

@@ -0,0 +1,7 @@
---
source: crates/popcorntime-graphql-client/src/reactions.rs
expression: operation.query
---
mutation SetReactionMutation($mediaId: Int!, $reaction: UserReactionType) {
setReaction(mediaId: $mediaId, reaction: $reaction)
}

View File

@@ -0,0 +1,7 @@
---
source: crates/popcorntime-graphql-client/src/reactions.rs
expression: operation.query
---
mutation SetReactionMutation($mediaId: Int!, $reaction: UserReactionType) {
setReaction(mediaId: $mediaId, reaction: $reaction)
}

View File

@@ -1,6 +1,8 @@
use crate::error::Error;
use cynic::{MutationBuilder, QueryBuilder};
use popcorntime_graphql_client::{client::ApiClient, media, preferences, providers, search};
use popcorntime_graphql_client::{
client::ApiClient, media, preferences, providers, reactions, search,
};
use popcorntime_session::AuthorizationService;
use tauri::State;
use tracing::instrument;
@@ -65,8 +67,9 @@ pub async fn media<'a>(
) -> Result<Option<media::MediaOutput>, Error> {
auth_service.validate().await?;
// cache is disabled because this query include user reaction that can change
api_client
.query(media::MediaOutput::build(params), false)
.query(media::MediaOutput::build(params), true)
.await
.map(|res| res.data)
.map_err(Into::into)
@@ -106,3 +109,20 @@ pub async fn set_favorites_provider<'a>(
.map(|res| res.data)
.map_err(Into::into)
}
#[tauri::command(async)]
#[specta::specta]
#[instrument(skip(api_client, auth_service), err(Debug))]
pub async fn set_media_reaction(
api_client: State<'_, ApiClient>,
auth_service: State<'_, AuthorizationService>,
params: reactions::SetReactionInput,
) -> Result<Option<reactions::SetReactionMutation>, Error> {
auth_service.validate().await?;
api_client
.query(reactions::SetReactionMutation::build(params), true)
.await
.map(|res| res.data)
.map_err(Into::into)
}

View File

@@ -20,6 +20,7 @@ fn main() {
popcorntime_tauri::graphql::media,
popcorntime_tauri::graphql::providers,
popcorntime_tauri::graphql::set_favorites_provider,
popcorntime_tauri::graphql::set_media_reaction,
popcorntime_tauri::window::show_main_window,
popcorntime_tauri::session::is_onboarded,
popcorntime_tauri::session::set_onboarded,

View File

@@ -91,14 +91,12 @@ export function MediaPosterAsPicture({
}) {
if (!posterId) {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={placeholder}
alt={title}
loading={loading}
className={cn("w-full bg-cover", className)}
/>
</div>
);
}