ADD - implement FlipTok creator platform

This commit is contained in:
Leon.Schmidt
2026-08-09 01:50:37 +02:00
parent eefe3b5331
commit 2ca96504d3
26 changed files with 4523 additions and 54 deletions
+44
View File
@@ -30,6 +30,7 @@ import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
import { useDarkChatStore } from '@/stores/darkchat'
import { useFlipTokStore } from '@/stores/fliptok'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
@@ -59,6 +60,8 @@ type AppMessage = {
| MarketplaceEventData
| MessagesEventData
| DarkChatEventData
| FlipTokVerificationData
| FlipTokNotificationData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -114,6 +117,20 @@ type CalendarReminderData = {
text?: string
title?: string
}
type FlipTokVerificationData = {
profileId: number
verified: boolean
}
type FlipTokNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?: 'like' | 'comment' | 'follow' | 'verified'
text?: string
title?: string
videoId?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -129,6 +146,7 @@ const banking = useBankingStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const fliptok = useFlipTokStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
@@ -276,6 +294,32 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'marketplace:changed' && event.data.data) {
const data = event.data.data as MarketplaceEventData
if (data.counts) marketplace.setCounts(data.counts)
} else if (
event.data?.type === 'fliptok:verification-changed' &&
event.data.data
) {
const data = event.data.data as FlipTokVerificationData
fliptok.applyVerification(Number(data.profileId), data.verified === true)
} else if (event.data?.type === 'fliptok:new' && event.data.data) {
const data = event.data.data as FlipTokNotificationData
const notification: PhoneNotificationInput = {
appId: 'fliptok',
subtitle: data.actor,
text: data.text ?? phone.t('Apps.fliptok.notifications.default'),
title: data.title ?? phone.t('Apps.fliptok.name'),
}
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
if (phone.isOpen) void fliptok.loadActivities()
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="FlipTok">
<defs>
<linearGradient id="flip-bg" x1="18" y1="10" x2="112" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#7471f2"/>
<stop offset="1" stop-color="#3532a8"/>
</linearGradient>
<linearGradient id="flip-card" x1="48" y1="30" x2="98" y2="106" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff"/>
<stop offset="1" stop-color="#e8e7ff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#flip-bg)"/>
<rect x="23" y="25" width="55" height="76" rx="14" transform="rotate(-9 23 25)" fill="#ffffff" fill-opacity=".23" stroke="#ffffff" stroke-opacity=".46" stroke-width="3"/>
<rect x="46" y="23" width="61" height="82" rx="16" fill="url(#flip-card)"/>
<path d="M69 50.5c0-3 3.3-4.8 5.8-3.1l21 14c2.2 1.5 2.2 4.7 0 6.2l-21 14c-2.5 1.7-5.8-.1-5.8-3.1v-28Z" fill="#403db7"/>
<path d="M24 89c7 18 24 27 44 27 13 0 25-4 34-12" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/>
<path d="m94 101 11 1-2 11" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+40 -5
View File
@@ -23,6 +23,7 @@ let renderFrameId: number | undefined
let lastRenderAt = 0
let recorder: MediaRecorder | null = null
let stream: MediaStream | null = null
let microphoneStream: MediaStream | null = null
let chunks: RecordingChunk[] = []
let lastChunkAt = 0
let lastChunkTimecode: number | null = null
@@ -95,7 +96,9 @@ function resetRecording(): void {
function stopTracks(): void {
stream?.getTracks().forEach((track) => track.stop())
microphoneStream?.getTracks().forEach((track) => track.stop())
stream = null
microphoneStream = null
}
function cleanupRecording(): void {
@@ -109,7 +112,7 @@ function cleanupRecording(): void {
postRecordState(false)
}
function startRecording(data: Record<string, unknown>): void {
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (recorder) return
if (typeof MediaRecorder === 'undefined') {
window.postMessage(
@@ -127,13 +130,45 @@ function startRecording(data: Record<string, unknown>): void {
}
startRenderLoop()
resetRecording()
stream = canvasRef.value?.captureStream(captureFps) ?? null
if (!stream) {
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
if (!videoStream) {
cleanupRecording()
return
}
if (data.microphoneEnabled === true) {
try {
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
} catch {
videoStream.getTracks().forEach((track) => track.stop())
cleanupRecording()
window.postMessage(
{
data: { error: 'microphone_unavailable', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
}
stream = new MediaStream([
...videoStream.getVideoTracks(),
...(microphoneStream?.getAudioTracks() ?? []),
])
const mimeType = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
].find((type) => MediaRecorder.isTypeSupported(type))
recorder = new MediaRecorder(stream, {
mimeType: 'video/webm',
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
recorder.ondataavailable = (event) => {
@@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void {
type?: string
}
if (message.type === 'camera:recordStart') {
startRecording(message.data ?? {})
void startRecording(message.data ?? {})
} else if (message.type === 'camera:recordStop') {
void stopRecording(message.data ?? {})
} else if (message.type === 'camera:recordCancel') {
+8 -1
View File
@@ -118,7 +118,14 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail'])
).toEqual([
'fliptok',
'local-pages',
'phone',
'darkchat',
'banking',
'mail',
])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+15
View File
@@ -52,6 +52,7 @@ import bankingIcon from '@/assets/img/app-icons/banking.svg'
import garageIcon from '@/assets/img/app-icons/garage.svg'
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flipTokIcon from '@/assets/img/app-icons/fliptok.svg'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -59,6 +60,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/FlipTokApp.vue')),
),
dockOrder: null,
gridOrder: 22,
icon: markRaw(Blocks),
iconClass: 'app-icon--fliptok',
iconImage: flipTokIcon,
id: 'fliptok',
labelKey: 'Apps.fliptok.name',
route: '/apps/fliptok',
},
{
category: 'productivity',
component: markRaw(
+133
View File
@@ -0,0 +1,133 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFlipTokStore } from '@/stores/fliptok'
import type {
FlipTokActivity,
FlipTokComment,
FlipTokProfile,
FlipTokVideo,
} from '@/types/fliptok'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const profile: FlipTokProfile = {
account_type: 'person',
bio: '',
display_name: 'Nova',
followers: 1,
following: 2,
handle: 'nova',
id: 7,
is_following: false,
is_owner: true,
verified: false,
video_count: 1,
}
const video: FlipTokVideo = {
caption: 'Los Santos',
comment_count: 1,
comments_enabled: true,
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'video-1',
is_following: false,
is_liked: false,
is_owner: true,
is_saved: false,
like_count: 2,
location: '',
cover_time_ms: 0,
music_artist: '',
music_title: '',
music_track: '',
music_url: '',
music_volume: 0,
original_volume: 100,
profile_id: 7,
share_count: 0,
trim_end_ms: null,
trim_start_ms: 0,
url: 'https://example.com/video.webm',
verified: false,
view_count: 3,
}
const comment: FlipTokComment = {
body: 'Nice',
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'comment-1',
profile_id: 7,
verified: false,
}
const activity: FlipTokActivity = {
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'activity-1',
kind: 'follow',
profile_id: 7,
read_at: null,
verified: false,
video_id: null,
}
describe('FlipTok verification updates', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('updates the badge everywhere the profile is already visible', () => {
const store = useFlipTokStore()
store.profile = { ...profile }
store.feed = [{ ...video }]
store.searchResults = [{ ...video }]
store.comments = [{ ...comment }]
store.activities = [{ ...activity }]
store.applyVerification(7, true)
expect(store.profile.verified).toBe(true)
expect(store.feed[0].verified).toBe(true)
expect(store.searchResults[0].verified).toBe(true)
expect(store.comments[0].verified).toBe(true)
expect(store.activities[0].verified).toBe(true)
})
it('does not alter another profile', () => {
const store = useFlipTokStore()
store.feed = [{ ...video }]
store.applyVerification(99, true)
expect(store.feed[0].verified).toBe(false)
})
it('removes a blocked creator from every visible surface', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFlipTokStore()
store.feed = [{ ...video }]
store.searchResults = [{ ...video }]
store.profileVideos = [{ ...video }]
store.comments = [{ ...comment }]
store.activities = [{ ...activity }]
store.viewedProfile = { ...profile }
expect(await store.blockProfile(7)).toBe(true)
expect(store.feed).toEqual([])
expect(store.searchResults).toEqual([])
expect(store.profileVideos).toEqual([])
expect(store.comments).toEqual([])
expect(store.activities).toEqual([])
expect(store.viewedProfile).toBeNull()
})
})
+208
View File
@@ -0,0 +1,208 @@
import { defineStore } from 'pinia'
import type {
FlipTokActivity,
FlipTokComment,
FlipTokMusicTrack,
FlipTokPage,
FlipTokProfile,
FlipTokProfilePage,
FlipTokReport,
FlipTokVideo,
} from '@/types/fliptok'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useFlipTokStore = defineStore('fliptok', {
state: () => ({
activities: [] as FlipTokActivity[],
comments: [] as FlipTokComment[],
feed: [] as FlipTokVideo[],
isAdmin: false,
loading: false,
musicTracks: [] as FlipTokMusicTrack[],
mode: 'for-you' as 'for-you' | 'following',
profile: null as FlipTokProfile | null,
profileVideos: [] as FlipTokVideo[],
reports: [] as FlipTokReport[],
searchResults: [] as FlipTokVideo[],
viewedProfile: null as FlipTokProfile | null,
}),
actions: {
applyVerification(profileId: number, verified: boolean): void {
if (this.profile?.id === profileId) this.profile.verified = verified
if (this.viewedProfile?.id === profileId)
this.viewedProfile.verified = verified
this.feed
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.searchResults
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.profileVideos
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.comments
.filter((comment) => comment.profile_id === profileId)
.forEach((comment) => {
comment.verified = verified
})
this.activities
.filter((activity) => activity.profile_id === profileId)
.forEach((activity) => {
activity.verified = verified
})
},
async bootstrap(): Promise<boolean> {
this.loading = true
const response = await nuiCall<{
feed: FlipTokPage
isAdmin: boolean
musicTracks: FlipTokMusicTrack[]
profile: FlipTokProfile
}>('fliptok:bootstrap')
this.loading = false
if (!response.success || !response.data) return false
this.profile = response.data.profile
this.feed = response.data.feed.items
this.isAdmin = response.data.isAdmin === true
this.musicTracks = response.data.musicTracks ?? []
return true
},
async loadFeed(mode?: 'for-you' | 'following'): Promise<boolean> {
mode ??= this.mode
this.mode = mode
this.loading = true
const response = await nuiCall<FlipTokPage>('fliptok:feed', {
mode,
offset: 0,
})
this.loading = false
if (!response.success || !response.data) return false
this.feed = response.data.items
return true
},
async discover(search: string): Promise<FlipTokVideo[]> {
const response = await nuiCall<FlipTokVideo[]>('fliptok:discover', {
search,
})
this.searchResults =
response.success && response.data ? response.data : []
return this.searchResults
},
async react(video: FlipTokVideo, kind: 'like' | 'save'): Promise<void> {
const key = kind === 'like' ? 'is_liked' : 'is_saved'
const next = !video[key]
video[key] = next
if (kind === 'like') video.like_count += next ? 1 : -1
const response = await nuiCall('fliptok:react', {
active: next,
id: video.id,
kind,
})
if (!response.success) {
video[key] = !next
if (kind === 'like') video.like_count += next ? -1 : 1
}
},
async follow(video: FlipTokVideo): Promise<void> {
const next = !video.is_following
const response = await nuiCall('fliptok:follow', {
active: next,
profileId: video.profile_id,
})
if (response.success)
this.feed
.filter((item) => item.profile_id === video.profile_id)
.forEach((item) => {
item.is_following = next
})
},
async followProfile(profile: FlipTokProfile): Promise<void> {
const next = !profile.is_following
const response = await nuiCall('fliptok:follow', {
active: next,
profileId: profile.id,
})
if (!response.success) return
profile.is_following = next
profile.followers += next ? 1 : -1
this.feed
.filter((item) => item.profile_id === profile.id)
.forEach((item) => {
item.is_following = next
})
},
async loadProfile(query: {
handle?: string
profileId?: number
}): Promise<boolean> {
const response = await nuiCall<FlipTokProfilePage>(
'fliptok:profile',
query,
)
if (!response.success || !response.data) return false
this.viewedProfile = response.data.profile
this.profileVideos = response.data.videos
return true
},
showOwnProfile(): void {
this.viewedProfile = null
this.profileVideos = this.feed.filter((item) => item.is_owner)
},
async blockProfile(profileId: number): Promise<boolean> {
const response = await nuiCall('fliptok:block', { profileId })
if (!response.success) return false
this.feed = this.feed.filter((video) => video.profile_id !== profileId)
this.searchResults = this.searchResults.filter(
(video) => video.profile_id !== profileId,
)
this.comments = this.comments.filter(
(comment) => comment.profile_id !== profileId,
)
this.activities = this.activities.filter(
(activity) => activity.profile_id !== profileId,
)
this.profileVideos = this.profileVideos.filter(
(video) => video.profile_id !== profileId,
)
if (this.viewedProfile?.id === profileId) this.viewedProfile = null
return true
},
async loadComments(id: string): Promise<void> {
const response = await nuiCall<FlipTokComment[]>('fliptok:comments', {
id,
})
this.comments = response.success && response.data ? response.data : []
},
async comment(id: string, body: string): Promise<NuiResponse> {
return nuiCall('fliptok:comment', { body, id })
},
async loadActivities(): Promise<void> {
const response = await nuiCall<FlipTokActivity[]>('fliptok:activities')
this.activities = response.success && response.data ? response.data : []
if (response.success) await nuiCall('fliptok:mark-activities')
},
async loadReports(): Promise<boolean> {
const response = await nuiCall<FlipTokReport[]>('fliptok:admin-reports')
this.reports = response.success && response.data ? response.data : []
return response.success
},
async resolveReport(
id: string,
action: 'dismiss' | 'remove',
): Promise<boolean> {
const response = await nuiCall('fliptok:admin-resolve-report', {
action,
id,
})
if (response.success) await this.loadReports()
return response.success
},
},
})
+120
View File
@@ -40,6 +40,121 @@ const namespaceQueues = new Map<string, Promise<void>>()
const defaultLocales: LocaleTree = {
Apps: {
fliptok: {
name: 'FlipTok',
loading: 'Loading FlipTok',
following: 'Following',
forYou: 'For You',
verified: 'Verified account',
originalSound: 'original sound',
save: 'Save',
home: 'Home',
discover: 'Discover',
create: 'Create',
activity: 'Activity',
profile: 'Profile',
emptyFeed: 'No videos yet',
emptyFeedBody: 'Follow creators or post the first FlipTok.',
searchPlaceholder: 'Search creators and videos',
noActivity: 'No activity yet',
followers: 'Followers',
videos: 'Videos',
emptyBio: 'No bio yet.',
editProfile: 'Edit profile',
newVideo: 'New FlipTok',
chooseVideo: 'Choose a video',
chooseVideoHint: 'Select one from Gallery',
changeVideo: 'Change',
captionPlaceholder: 'Write a caption...',
location: 'Add location',
whoCanWatch: 'Who can watch',
public: 'Everyone',
followersOnly: 'Followers',
private: 'Only me',
allowComments: 'Allow comments',
saveDraft: 'Drafts',
publishing: 'Posting...',
post: 'Post',
draftSaved: 'Draft saved.',
published: 'Your FlipTok is live.',
linkCopied: 'Video link copied.',
reported: 'Report submitted.',
blocked: 'Creator blocked.',
comments: 'Comments',
noComments: 'No comments yet',
addComment: 'Add comment...',
report: 'Report video',
reportReason: 'Reason',
reportDetails: 'Additional details (optional)',
submitReport: 'Submit report',
reportReasons: {
spam: 'Spam or misleading',
harassment: 'Harassment or bullying',
dangerous: 'Dangerous activity',
illegal: 'Illegal content',
other: 'Something else',
},
block: 'Block creator',
follow: 'Follow',
unfollow: 'Following',
backToProfile: 'Back',
sounds: 'Sound',
chooseSound: 'Choose music',
originalOnly: 'Original sound only',
noMusic: 'No music tracks are configured.',
trimAndCover: 'Trim & cover',
trimStart: 'Start',
trimEnd: 'End',
coverFrame: 'Cover',
originalVolume: 'Original sound',
musicVolume: 'Music',
moderation: 'Moderation',
reports: 'Open reports',
noReports: 'No open reports',
removeVideo: 'Remove video',
dismissReport: 'Dismiss',
cancel: 'Cancel',
done: 'Done',
displayName: 'Name',
username: 'Username',
bio: 'Bio',
accountType: 'Account type',
accountTypes: {
person: 'Person',
business: 'Business',
organization: 'Organization',
media: 'Media',
event: 'Event',
},
activityKinds: {
like: 'liked your video',
comment: 'commented on your video',
follow: 'started following you',
verified: 'verification changed',
},
notifications: {
like: '{actor} liked your video.',
comment: '{actor} commented on your video.',
follow: '{actor} started following you.',
verified: 'Your FlipTok account is now verified.',
default: 'You have new FlipTok activity.',
},
errors: {
invalid_video: 'Check the video details.',
invalid_media: 'Choose a video from this phone.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
invalid_profile: 'Check your profile details.',
handle_taken: 'This username is already taken.',
video_not_found: 'This video is unavailable.',
rate_limited: 'Too many actions. Try again shortly.',
not_authenticated: 'Sign in to iFruit first.',
blocked: 'This account is blocked.',
not_authorized: 'You do not have moderation access.',
report_not_found: 'This report is no longer open.',
default: 'FlipTok could not complete the request.',
},
},
darkchat: {
name: 'DarkChat',
newMessage: 'New DarkChat message from {sender}',
@@ -1014,6 +1129,8 @@ const defaultLocales: LocaleTree = {
portrait: 'Switch to portrait',
photo: 'Photo',
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
uploading: '{count} uploading',
@@ -1031,6 +1148,8 @@ const defaultLocales: LocaleTree = {
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Camera uploads are not configured.',
microphone_unavailable:
'Allow microphone access or mute the microphone before recording.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
@@ -1488,6 +1607,7 @@ const defaultLocales: LocaleTree = {
send: 'Send',
start: 'Start',
stop: 'Stop',
use: 'Use',
},
Notifications: { now: 'now' },
LockScreen: {
+1
View File
@@ -26,6 +26,7 @@ export type PhoneAppId =
| 'neon-drop'
| 'citymarkt'
| 'local-pages'
| 'fliptok'
export type LaunchablePhoneAppId = PhoneAppId
+97
View File
@@ -0,0 +1,97 @@
export type FlipTokProfile = {
account_type: 'person' | 'business' | 'organization' | 'media' | 'event'
bio: string
display_name: string
followers: number
following: number
handle: string
id: number
is_following: boolean
is_owner: boolean
verified: boolean
video_count: number
}
export type FlipTokVideo = {
caption: string
comment_count: number
comments_enabled: boolean
created_at: number
display_name: string
handle: string
id: string
is_following: boolean
is_liked: boolean
is_owner: boolean
is_saved: boolean
like_count: number
location: string
cover_time_ms: number
music_artist: string
music_title: string
music_track: string
music_url: string
music_volume: number
original_volume: number
profile_id: number
share_count: number
trim_end_ms: number | null
trim_start_ms: number
url: string
verified: boolean
view_count: number
}
export type FlipTokMusicTrack = {
artist: string
id: string
title: string
url: string
}
export type FlipTokReport = {
caption: string
created_at: number
creator_display_name: string
creator_handle: string
details: string
id: string
reason: 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other'
reporter_display_name: string
reporter_handle: string
url: string
video_id: string
}
export type FlipTokProfilePage = {
profile: FlipTokProfile
videos: FlipTokVideo[]
}
export type FlipTokComment = {
body: string
created_at: number
display_name: string
handle: string
id: string
profile_id: number
verified: boolean
}
export type FlipTokActivity = {
created_at: number
display_name: string
handle: string
id: string
kind: 'like' | 'comment' | 'follow' | 'verified'
profile_id: number
read_at: string | null
verified: boolean
video_id: string | null
}
export type FlipTokPage = {
hasMore: boolean
items: FlipTokVideo[]
offset: number
}
+1
View File
@@ -68,6 +68,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
'neon-drop': { enabled: true, sounds: true },
citymarkt: { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
+115 -40
View File
@@ -1,14 +1,10 @@
<script setup lang="ts">
import {
kFab,
kNavbar,
kPage,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { kFab, kNavbar, kPage, kSegmented, kSegmentedButton } from 'konsta/vue'
import {
ArrowLeft,
Images,
Mic,
MicOff,
RefreshCw,
RotateCcwSquare,
Video,
@@ -45,6 +41,7 @@ const requestedMessageMedia = computed<MediaType | null>(() => {
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
const flashEnabled = ref(false)
const microphoneEnabled = ref(true)
const frontCamera = ref(false)
const shutterActive = ref(false)
const focused = ref(true)
@@ -63,6 +60,8 @@ let recordingTimer: number | undefined
let gameView: GameView | null = null
let renderFrameId: number | undefined
let resizeObserver: ResizeObserver | null = null
let wheelDelta = 0
let wheelResetTimer: number | undefined
const pendingCount = computed(
() =>
@@ -88,6 +87,12 @@ const flashColors = computed(() => ({
? 'text-yellow-500 dark:text-yellow-300'
: controlColors.textIos,
}))
const microphoneColors = computed(() => ({
...controlColors,
textIos: microphoneEnabled.value
? 'text-white'
: 'text-red-400',
}))
function correlationId(): string {
return `${Date.now()}-${crypto.randomUUID()}`
@@ -156,15 +161,31 @@ async function requestPhoto(): Promise<void> {
function startRecording(): void {
if (savingVideo.value) return
if (isDevelopment) {
recording.value = true
recordingStartedAt.value = Date.now()
updateRecordingTimer()
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = window.setInterval(updateRecordingTimer, 250)
return
}
window.postMessage(
{
data: { bitrateKbps: videoBitrateKbps.value },
data: {
bitrateKbps: videoBitrateKbps.value,
microphoneEnabled: microphoneEnabled.value,
},
type: 'camera:recordStart',
},
'*',
)
}
function toggleMicrophone(): void {
if (recording.value || savingVideo.value) return
microphoneEnabled.value = !microphoneEnabled.value
}
function stopRecording(): void {
if (!recording.value || savingVideo.value) return
const id = correlationId()
@@ -172,6 +193,8 @@ function stopRecording(): void {
if (isDevelopment) {
recording.value = false
savingVideo.value = true
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = undefined
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
@@ -247,6 +270,26 @@ function setZoom(zoom: (typeof zoomLevels)[number]): void {
void nuiCall('camera:setZoom', { zoom })
}
function zoomWithWheel(event: WheelEvent): void {
wheelDelta += event.deltaY
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
wheelResetTimer = window.setTimeout(() => {
wheelDelta = 0
wheelResetTimer = undefined
}, 140)
if (Math.abs(wheelDelta) < 35) return
const currentIndex = zoomLevels.indexOf(selectedZoom.value)
const nextIndex = Math.min(
zoomLevels.length - 1,
Math.max(0, currentIndex + (wheelDelta < 0 ? 1 : -1)),
)
wheelDelta = 0
const nextZoom = zoomLevels[nextIndex]
if (nextZoom !== undefined && nextZoom !== selectedZoom.value)
setZoom(nextZoom)
}
function resizeGameView(entry?: ResizeObserverEntry): void {
if (!gameCanvas.value || !gameView) return
const width = entry?.contentRect.width ?? gameCanvas.value.offsetWidth
@@ -379,6 +422,7 @@ onBeforeUnmount(() => {
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('message', onMessage)
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
@@ -402,7 +446,7 @@ onBeforeUnmount(() => {
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
:aria-label="phone.t('Apps.camera.name')"
>
<div class="camera-viewport">
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
<canvas
v-if="!isDevelopment"
ref="gameCanvas"
@@ -423,29 +467,53 @@ onBeforeUnmount(() => {
</div>
<header class="camera-topbar">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-if="!requestedMessageMedia"
component="button"
type="button"
class="camera-control"
:colors="flashColors"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
<template #icon>
<Zap v-if="flashEnabled" :size="19" />
<ZapOff v-else :size="19" />
</template>
</k-fab>
<div class="camera-topbar-actions">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-else
component="button"
type="button"
class="camera-control"
:colors="flashColors"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
<template #icon>
<Zap v-if="flashEnabled" :size="19" />
<ZapOff v-else :size="19" />
</template>
</k-fab>
<k-fab
v-if="mode === 'video'"
component="button"
type="button"
class="camera-control"
:colors="microphoneColors"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
microphoneEnabled
? 'Apps.camera.microphoneOn'
: 'Apps.camera.microphoneOff',
)
"
:aria-pressed="microphoneEnabled"
@click="toggleMicrophone"
>
<template #icon>
<Mic v-if="microphoneEnabled" :size="19" />
<MicOff v-else :size="19" />
</template>
</k-fab>
</div>
<span
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
@@ -455,11 +523,7 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<span v-else class="camera-focus-pill">
{{
phone.t(focused ? 'Apps.camera.focusHelp' : 'Apps.camera.returnHelp')
}}
</span>
<span v-else class="camera-topbar-spacer" aria-hidden="true"></span>
<k-fab
component="button"
type="button"
@@ -595,7 +659,7 @@ onBeforeUnmount(() => {
<style scoped>
.camera-page {
position: relative;
overflow: hidden;
overflow: clip;
background: #000;
color: #fff;
}
@@ -684,9 +748,20 @@ onBeforeUnmount(() => {
left: 18px;
right: 18px;
display: grid;
grid-template-columns: 44px 1fr 44px;
grid-template-columns: auto minmax(0, 1fr) 44px;
align-items: center;
gap: 10px;
gap: 8px;
}
.camera-topbar-actions {
display: flex;
gap: 8px;
}
.camera-topbar-spacer {
min-width: 0;
}
.camera-topbar .camera-control {
width: 44px;
height: 44px;
}
.camera-control {
--color-primary: transparent;
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -45,7 +45,9 @@ const requestedMessageMedia = computed<GalleryFilter | null>(() => {
return value === 'photo' || value === 'video' ? value : null
})
const multipleSelection = computed(
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
() =>
requestedMessageMedia.value !== null &&
(messageMedia.request?.maxSelection ?? 1) > 1,
)
const selectedMediaIds = ref<number[]>([])
const media = ref<PhoneMedia[]>([])
@@ -210,14 +212,14 @@ function openMedia(entry: PhoneMedia): void {
if (multipleSelection.value) {
const index = selectedMediaIds.value.indexOf(entry.id)
if (index >= 0) selectedMediaIds.value.splice(index, 1)
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
else if (
selectedMediaIds.value.length <
(messageMedia.request?.maxSelection ?? 1)
) {
selectedMediaIds.value.push(entry.id)
}
return
}
const returnPath = messageMedia.complete(entry)
if (returnPath) void router.replace(returnPath)
return
}
landscapeViewer.value = false
phone.setCameraLandscape(false)
@@ -226,6 +228,12 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function completeSingleSelection(): void {
if (!selected.value) return
const returnPath = messageMedia.complete(selected.value)
if (returnPath) void router.replace(returnPath)
}
function completeMultipleSelection(): void {
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
const entry = media.value.find((item) => item.id === id)
@@ -405,7 +413,9 @@ onBeforeUnmount(() => {
v-for="entry in media"
:key="entry.id"
class="gallery-tile"
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
:class="{
'gallery-tile--selected': selectedMediaIds.includes(entry.id),
}"
type="button"
:aria-label="
phone.t(
@@ -499,6 +509,14 @@ onBeforeUnmount(() => {
</template>
<template #right>
<k-link
v-if="requestedMessageMedia"
component="button"
@click="completeSingleSelection"
>
{{ phone.t('Common.use') }}
</k-link>
<k-link
v-else
component="button"
icon-only
class="text-red-500"
+318
View File
@@ -23,6 +23,126 @@ let draft = null
let mockBankBalance = 24787
let mockCashBalance = 2350
let nextBankTransactionId = 7
const flipTokProfile = {
id: 1,
handle: 'skyline',
display_name: 'Skyline',
bio: 'Life around Los Santos.',
account_type: 'media',
verified: true,
is_following: false,
is_owner: true,
followers: 18400,
following: 128,
video_count: 2,
}
const flipTokMusicTracks = [
{
id: 'night-drive',
title: 'Night Drive',
artist: 'Los Santos Radio',
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
},
]
let flipTokVideos = [
{
id: 'fliptok-1',
profile_id: 2,
handle: 'novals',
display_name: 'Nova',
verified: true,
caption: 'A quiet minute above Vinewood. #LosSantos',
location: 'Vinewood Hills',
trim_start_ms: 0,
trim_end_ms: null,
cover_time_ms: 1200,
original_volume: 100,
music_volume: 0,
music_track: '',
music_title: '',
music_artist: '',
music_url: '',
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
comments_enabled: true,
is_liked: false,
is_saved: false,
is_following: false,
is_owner: false,
like_count: 12840,
comment_count: 384,
view_count: 245100,
share_count: 932,
created_at: Date.now() - 3600000,
},
{
id: 'fliptok-2',
profile_id: 1,
handle: 'skyline',
display_name: 'Skyline',
verified: true,
caption: 'Tonight in the city.',
location: 'Downtown Los Santos',
trim_start_ms: 800,
trim_end_ms: 12000,
cover_time_ms: 2200,
original_volume: 70,
music_volume: 25,
music_track: 'night-drive',
music_title: 'Night Drive',
music_artist: 'Los Santos Radio',
music_url: flipTokMusicTracks[0].url,
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
comments_enabled: true,
is_liked: true,
is_saved: true,
is_following: false,
is_owner: true,
like_count: 4921,
comment_count: 97,
view_count: 88300,
share_count: 220,
created_at: Date.now() - 7200000,
},
]
let flipTokComments = [
{
id: 'comment-1',
profile_id: 2,
handle: 'nova',
display_name: 'Nova',
verified: true,
body: 'This view is perfect.',
created_at: Date.now() - 300000,
},
]
let flipTokActivities = [
{
id: 'activity-1',
profile_id: 2,
handle: 'nova',
display_name: 'Nova',
verified: true,
kind: 'like',
video_id: 'fliptok-2',
read_at: null,
created_at: Date.now() - 240000,
},
]
let flipTokReports = [
{
id: 'report-1',
video_id: 'fliptok-1',
reason: 'dangerous',
details: 'Please review the driving shown in this clip.',
created_at: Date.now() - 600000,
caption: flipTokVideos[0].caption,
url: flipTokVideos[0].url,
reporter_handle: 'skyline',
reporter_display_name: 'Skyline',
creator_handle: 'novals',
creator_display_name: 'Nova',
},
]
const mockBankTransactions = [
{
id: 1,
@@ -1052,6 +1172,204 @@ app.post('/api/:endpoint', (request, response) => {
playerName: 'Alex Morgan',
transactions: mockBankTransactions,
})
if (endpoint === 'fliptok:bootstrap') {
response.json({
success: true,
data: {
profile: flipTokProfile,
feed: { items: flipTokVideos, offset: 0, hasMore: false },
isAdmin: true,
musicTracks: flipTokMusicTracks,
},
})
return
}
if (endpoint === 'fliptok:feed') {
const items =
request.body.mode === 'following'
? flipTokVideos.filter((video) => video.is_following)
: flipTokVideos
response.json({ success: true, data: { items, offset: 0, hasMore: false } })
return
}
if (endpoint === 'fliptok:discover') {
const search = String(request.body.search ?? '').toLowerCase()
response.json({
success: true,
data: flipTokVideos.filter((video) =>
`${video.handle} ${video.display_name} ${video.caption}`
.toLowerCase()
.includes(search),
),
})
return
}
if (endpoint === 'fliptok:react') {
const video = flipTokVideos.find((item) => item.id === request.body.id)
if (video) {
const key = request.body.kind === 'like' ? 'is_liked' : 'is_saved'
video[key] = request.body.active
}
response.json({ success: true })
return
}
if (endpoint === 'fliptok:follow') {
flipTokVideos
.filter((video) => video.profile_id === request.body.profileId)
.forEach((video) => {
video.is_following = request.body.active
})
response.json({ success: true })
return
}
if (endpoint === 'fliptok:share') {
const video = flipTokVideos.find((item) => item.id === request.body.id)
if (video) video.share_count += 1
response.json({ success: true })
return
}
if (endpoint === 'fliptok:comments') {
response.json({ success: true, data: flipTokComments })
return
}
if (endpoint === 'fliptok:comment') {
flipTokComments.unshift({
id: `comment-${Date.now()}`,
profile_id: 1,
handle: flipTokProfile.handle,
display_name: flipTokProfile.display_name,
verified: flipTokProfile.verified,
body: request.body.body,
created_at: Date.now(),
})
response.json({ success: true })
return
}
if (endpoint === 'fliptok:activities') {
response.json({ success: true, data: flipTokActivities })
return
}
if (endpoint === 'fliptok:profile') {
const profileId = Number(request.body.profileId || 0)
const handle = String(request.body.handle || '').toLowerCase()
const own = profileId === flipTokProfile.id || handle === flipTokProfile.handle
const videos = own
? flipTokVideos.filter((video) => video.profile_id === flipTokProfile.id)
: flipTokVideos.filter((video) =>
profileId ? video.profile_id === profileId : video.handle === handle,
)
const first = videos[0]
if (!first && !own) {
response.json({ success: false, error: 'profile_not_found' })
return
}
response.json({
success: true,
data: {
profile: own
? { ...flipTokProfile }
: {
id: first.profile_id,
handle: first.handle,
display_name: first.display_name,
bio: 'Creator in Los Santos.',
account_type: 'person',
verified: first.verified,
is_following: first.is_following,
is_owner: false,
followers: 12840,
following: 91,
video_count: videos.length,
},
videos,
},
})
return
}
if (endpoint === 'fliptok:block') {
const profileId = Number(request.body.profileId)
flipTokVideos = flipTokVideos.filter((video) => video.profile_id !== profileId)
flipTokComments = flipTokComments.filter((comment) => comment.profile_id !== profileId)
flipTokActivities = flipTokActivities.filter((activity) => activity.profile_id !== profileId)
response.json({ success: true })
return
}
if (endpoint === 'fliptok:admin-reports') {
response.json({ success: true, data: flipTokReports })
return
}
if (endpoint === 'fliptok:admin-resolve-report') {
const selected = flipTokReports.find((report) => report.id === request.body.id)
if (selected && request.body.action === 'remove')
flipTokVideos = flipTokVideos.filter((video) => video.id !== selected.video_id)
flipTokReports = flipTokReports.filter((report) =>
request.body.action === 'remove'
? report.video_id !== selected?.video_id
: report.id !== request.body.id,
)
response.json({ success: true })
return
}
if (endpoint === 'fliptok:update-profile') {
Object.assign(flipTokProfile, {
handle: request.body.handle,
display_name: request.body.displayName,
bio: request.body.bio,
account_type: request.body.accountType,
})
response.json({ success: true, data: flipTokProfile })
return
}
if (endpoint === 'fliptok:publish') {
const media = mockMedia.find(
(item) => item.id === request.body.mediaId && item.mediaType === 'video',
)
if (!media) {
response.json({ success: false, error: 'invalid_media' })
return
}
flipTokVideos.unshift({
id: `fliptok-${Date.now()}`,
profile_id: 1,
handle: flipTokProfile.handle,
display_name: flipTokProfile.display_name,
verified: flipTokProfile.verified,
caption: request.body.caption,
location: request.body.location,
trim_start_ms: request.body.trimStartMs || 0,
trim_end_ms: request.body.trimEndMs || null,
cover_time_ms: request.body.coverTimeMs || 0,
original_volume: request.body.originalVolume ?? 100,
music_volume: request.body.musicVolume || 0,
music_track: request.body.musicTrack || '',
music_title:
flipTokMusicTracks.find((track) => track.id === request.body.musicTrack)
?.title || '',
music_artist:
flipTokMusicTracks.find((track) => track.id === request.body.musicTrack)
?.artist || '',
music_url:
flipTokMusicTracks.find((track) => track.id === request.body.musicTrack)
?.url || '',
url: media.url,
comments_enabled: request.body.commentsEnabled,
is_liked: false,
is_saved: false,
is_following: false,
is_owner: true,
like_count: 0,
comment_count: 0,
view_count: 0,
share_count: 0,
created_at: Date.now(),
})
response.json({ success: true, data: { id: flipTokVideos[0].id } })
return
}
if (endpoint.startsWith('fliptok:')) {
response.json({ success: true })
return
}
if (endpoint === 'banking:overview') {
response.json({ success: true, data: bankingOverview() })
return