Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Swing Music</title>
<base href="./" />
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" onload="console.log('Cast SDK loaded')" onerror="console.error('Cast SDK failed to load')"></script>
</head>
<body>
<noscript>
Expand Down
3 changes: 3 additions & 0 deletions src/assets/icons/cast.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 60 additions & 2 deletions src/components/BottomBar/Right.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@
<div class="right-group">
<LyricsButton />
<Volume />
<button
class="cast"
v-if='cast.canCast'
:class="{
'cast-connected': cast.isConnected,
'cast-connecting': cast.isConnecting,
}"
:title="cast.isConnected
? 'Disconnect from cast device'
: cast.isConnecting ? 'Connecting...' : 'Cast to device'
"
@click="cast.toggleCast"
>
<CastSvg />
</button>
<button
class="repeat"
:class="{ 'repeat-disabled': settings.repeat == 'none' }"
Expand All @@ -24,18 +39,26 @@
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import useCast from '@/composables/useCast'

import RepeatOneSvg from '@/assets/icons/repeat-one.svg'
import RepeatAllSvg from '@/assets/icons/repeat.svg'
import ShuffleSvg from '@/assets/icons/shuffle.svg'
import CastSvg from '@/assets/icons/cast.svg'
import HeartSvg from '../shared/HeartSvg.vue'
import LyricsButton from '../shared/LyricsButton.vue'
import Volume from './Volume.vue'

const queue = useQueue()
const settings = useSettings()
const cast = useCast()

onMounted(() => {
cast.initCast()
})

defineProps<{
hideHeart?: boolean
Expand All @@ -50,7 +73,7 @@ defineEmits<{
.right-group {
display: grid;
justify-content: flex-end;
grid-template-columns: repeat(5, max-content);
grid-template-columns: repeat(6, max-content);
align-items: center;
height: 4rem;

Expand All @@ -72,7 +95,8 @@ defineEmits<{
}

.lyrics,
.repeat {
.repeat,
.cast {
svg {
transform: scale(0.75);
}
Expand All @@ -82,6 +106,40 @@ defineEmits<{
}
}

.cast {
&.cast-connected {
background-color: $primary !important;
border-color: $primary !important;

svg {
color: white;
}

&:hover {
background-color: $primary !important;
border-color: $primary !important;
opacity: 0.8;
}
}

&.cast-connecting {
svg {
opacity: 0.7;
}
}

&.cast-disabled {
svg {
opacity: 0.3;
}

&:hover {
background-color: transparent !important;
border-color: transparent !important;
}
}
}

button.repeat.repeat-disabled {
svg {
opacity: 0.25;
Expand Down
194 changes: 194 additions & 0 deletions src/composables/useCast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { ref, computed } from 'vue'
import useQueueStore from '@/stores/queue'
import { useToast, NotifType } from '@/stores/notification'
import { paths } from '@/config'

const isConnected = ref(false)
const isConnecting = ref(false)
const castSession = ref<cast.framework.CastSession | null>(null)

export default function useCast() {
const queue = useQueueStore()
const toast = useToast()

const isSupported = computed(() => {
const supported = typeof window !== 'undefined' && 'cast' in window && 'framework' in window.cast
console.log('Cast support check:', {
hasWindow: typeof window !== 'undefined',
hasCast: typeof window !== 'undefined' && 'cast' in window,
hasFramework: typeof window !== 'undefined' && 'cast' in window && 'framework' in window.cast,
supported
})
return supported
})

const canCast = computed(() => {
return isSupported.value && queue.currenttrack && !isConnecting.value
})

function initCast() {
console.log('initCast called, isSupported:', isSupported.value)

// Always set up the API availability callback first
window['__onGCastApiAvailable'] = (isAvailable: boolean) => {
console.log('Cast API available callback:', isAvailable)
if (isAvailable) {
console.log('Cast API is now available, initializing...')
initializeCastApi()
} else {
console.log('Cast API not available')
}
}

// Try to load Cast SDK if it's not already loaded
if (!window.cast) {
console.log('Cast SDK not found, attempting to load dynamically...')
loadCastSDK()
} else if (window.cast && window.cast.framework) {
console.log('Cast supported, initializing immediately')
initializeCastApi()
}
}

function loadCastSDK() {
// Check if script already exists
if (document.querySelector('script[src*="cast_sender.js"]')) {
console.log('Cast SDK script already exists')
return
}

console.error('Failed to load Cast SDK dynamically')
toast.showNotification('Cast SDK failed to load. Check your internet connection.', NotifType.Error)
}

function initializeCastApi() {
console.log('initializeCastApi called')
try {
const context = cast.framework.CastContext.getInstance()
console.log('CastContext obtained:', context)

context.setOptions({
receiverApplicationId: cast.framework.CastContext.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: cast.framework.AutoJoinPolicy.ORIGIN_SCOPED
})
console.log('Cast options set')

context.addEventListener(cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
console.log('Cast state changed:', event.castState)
switch (event.castState) {
case cast.framework.CastState.CONNECTED:
isConnected.value = true
isConnecting.value = false
castSession.value = context.getCurrentSession()
toast.showNotification('Connected to cast device', NotifType.Success)
break
case cast.framework.CastState.CONNECTING:
isConnecting.value = true
break
case cast.framework.CastState.NOT_CONNECTED:
isConnected.value = false
isConnecting.value = false
castSession.value = null
break
}
})
console.log('Cast event listeners added')
} catch (error) {
console.error('Failed to initialize Cast API:', error)
}
}

async function startCasting() {
console.log('startCasting called')
console.log('canCast:', canCast.value, 'window.cast:', !!window.cast)

if (!canCast.value || !window.cast) {
console.log('Cannot cast - either not supported or no current track')
toast.showNotification('Cast not available', NotifType.Info)
return
}

try {
console.log('Requesting cast session...')
const context = cast.framework.CastContext.getInstance()
await context.requestSession()
console.log('Cast session requested successfully')

// Load current track
if (queue.currenttrack && castSession.value) {
console.log('Loading current track to cast device')
loadCurrentTrack()
}
} catch (error) {
isConnecting.value = false
console.error('Cast request failed:', error)
if (error.code === 'cancel') {
console.log('User cancelled cast request')
return
}
toast.showNotification('No cast devices found on your network', NotifType.Info)
}
}

function loadCurrentTrack() {
if (!castSession.value || !queue.currenttrack) return

const track = queue.currenttrack
const mediaInfo = new chrome.cast.media.MediaInfo(
`${paths.api.files}/${track.trackhash}?filepath=${encodeURIComponent(track.filepath)}`,
'audio/mpeg'
)

mediaInfo.metadata = new chrome.cast.media.MusicTrackMediaMetadata()
mediaInfo.metadata.title = track.title
mediaInfo.metadata.artist = track.artists.map(a => a.name).join(', ')
mediaInfo.metadata.albumName = track.album

if (track.image) {
mediaInfo.metadata.images = [{
url: paths.images.thumb.medium + track.image
}]
}

const request = new chrome.cast.media.LoadRequest(mediaInfo)
request.autoplay = queue.playing

castSession.value.loadMedia(request)
.then(() => {
console.log('Media loaded successfully')
})
.catch((error) => {
toast.showNotification('Failed to load media on cast device', NotifType.Error)
console.error('Failed to load media:', error)
})
}

function stopCasting() {
if (!castSession.value) return

try {
castSession.value.endSession(true)
} catch (error) {
console.warn('Failed to stop casting:', error)
}
}

function toggleCast() {
console.log('toggleCast clicked! isConnected:', isConnected.value)
if (isConnected.value) {
console.log('Stopping cast...')
stopCasting()
} else {
console.log('Starting cast...')
startCasting()
}
}

return {
canCast,
isConnected,
isConnecting,
initCast,
toggleCast
}
}
63 changes: 63 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,66 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}

// Google Cast SDK types
declare namespace cast {
namespace framework {
class CastContext {
static getInstance(): CastContext
setOptions(options: any): void
requestSession(): Promise<void>
getCurrentSession(): CastSession | null
addEventListener(type: string, listener: (event: any) => void): void
static readonly DEFAULT_MEDIA_RECEIVER_APP_ID: string
}

class CastSession {
loadMedia(loadRequest: any): Promise<void>
endSession(stopCasting: boolean): void
}

enum CastState {
NO_DEVICES_AVAILABLE = 'NO_DEVICES_AVAILABLE',
NOT_CONNECTED = 'NOT_CONNECTED',
CONNECTING = 'CONNECTING',
CONNECTED = 'CONNECTED'
}

enum AutoJoinPolicy {
ORIGIN_SCOPED = 'ORIGIN_SCOPED'
}

enum CastContextEventType {
CAST_STATE_CHANGED = 'caststatechanged'
}
}
}

declare namespace chrome.cast.media {
class MediaInfo {
constructor(contentId: string, contentType: string)
metadata: any
}

class LoadRequest {
constructor(mediaInfo: MediaInfo)
autoplay: boolean
}

class MusicTrackMediaMetadata {
title: string
artist: string
albumName: string
images: Array<{url: string}>
}
}

interface Window {
cast: typeof cast
chrome: {
cast: {
media: typeof chrome.cast.media
}
}
__onGCastApiAvailable: (isAvailable: boolean) => void
}